Writing Error Messages That Help — What to Include and What to Skip
Invalid configuration. That’s the whole message. Somewhere in a 300-line YAML file there’s a problem, and the program knows exactly which key, which line, and what it expected — it just chose not to say. Error messages are the interface your code presents at its worst moment, and most of them are written in the thirty seconds before a deadline by someone who already knows the answer.
The Three Questions
A good error message answers three things, in this order:
- What went wrong — specifically, not categorically
- Where — the file, line, key, record, or field
- What to do about it — the fix, or the next diagnostic step
Most messages answer the first question badly and skip the other two.
Example:
# Answers nothing
raise "Invalid configuration"
# Answers 1
raise ConfigError, "timeout must be a positive integer"
# Answers 1 and 2
raise ConfigError, "config/app.yml:14 — timeout must be a positive integer, got \"thirty\""
# Answers all three
raise ConfigError, <<~MSG
config/app.yml:14 — `timeout` must be a positive integer, got "thirty".
Set it to a number of seconds:
timeout: 30
MSG
The last version is eight lines instead of one. It is worth it, because it’s read by someone who is confused and it replaces a fifteen-minute investigation.
Include the Actual Value
The single highest-value habit. “Invalid email” is a category. Invalid email: "ada@@example.com" is a diagnosis — the reader sees the double @ immediately.
Example:
# Before
raise ArgumentError, "unsupported format"
# After
raise ArgumentError,
"Unsupported format #{format.inspect}. Supported: #{SUPPORTED.map(&:inspect).join(', ')}"
Use inspect, not to_s. The difference matters constantly:
value = " 30 "
"got #{value}" # => got 30 ← looks fine, isn't
"got #{value.inspect}" # => got " 30 " ← whitespace visible
Trailing whitespace, an empty string versus nil, a symbol versus a string, "1" versus 1 — inspect makes all of them visible and to_s hides every one.
Truncate large values
def summarize(value, limit: 200)
text = value.inspect
text.length > limit ? "#{text[0, limit]}… (#{text.length} chars)" : text
end
A 40,000-character payload in an exception message is worse than no payload — it blows out your log storage and nobody reads past line two.
List the Valid Options
When the input is one of a fixed set, name the set. This turns an error into documentation.
Example:
VALID_STATUSES = %i[pending processing shipped delivered cancelled].freeze
def status=(value)
unless VALID_STATUSES.include?(value)
raise ArgumentError,
"Unknown status #{value.inspect}. Valid statuses: #{VALID_STATUSES.join(', ')}"
end
@status = value
end
For larger sets, suggest the nearest match instead of dumping all of them:
Example:
require "did_you_mean"
def suggest(input, candidates)
DidYouMean::SpellChecker.new(dictionary: candidates).correct(input.to_s).first
end
def fetch_setting(key)
return @settings[key] if @settings.key?(key)
hint = suggest(key, @settings.keys)
message = "Unknown setting #{key.inspect}."
message += " Did you mean #{hint.inspect}?" if hint
raise KeyError, message
end
did_you_mean ships with Ruby. It’s the reason NoMethodError sometimes tells you about a typo, and you can use the same machinery in your own code for two lines of effort.
Say What to Do, Not Just What Happened
Compare:
Connection pool exhausted
Connection pool exhausted: all 5 connections in use, 12 threads waiting. Increasepoolin config/database.yml, or check for a request holding a connection outside the executor.
The second one contains the numbers that tell you which fix applies. Five connections and twelve threads means the pool is undersized relative to your thread count. Five and one means something is holding a connection open.
For domain errors, state the next action:
Example:
raise PaymentDeclined, <<~MSG
Payment declined by the processor (code: #{code}, reason: #{reason}).
This is not retryable. Ask the customer to use a different payment method.
Reference: #{charge_id}
MSG
“This is not retryable” saves the on-call engineer from a wasted retry loop, and the reference ID saves them from a support ticket.
Don’t Leak, Don’t Blame, Don’t Apologize
Leaking
Error messages travel — into logs, into error trackers, sometimes into HTTP responses. Never interpolate credentials, tokens, full request bodies, or personal data.
# Bad
raise AuthError, "Invalid token: #{token}"
# Better
raise AuthError, "Invalid token (length #{token.length}, prefix #{token[0, 6]}…)"
The prefix and length are usually enough to distinguish “wrong environment’s key” from “malformed” without writing a secret into a log that’s retained for ninety days.
Blaming
“You provided an invalid value” reads worse than “Invalid value” and communicates nothing extra. Drop the second person; describe the state.
Apologizing
“Oops! Something went wrong :(“ is the message of a system that knows it failed and has decided not to tell you anything. If the user genuinely can’t act on the detail, give them a reference ID and put the detail in the log.
rescue => e
reference = SecureRandom.hex(4)
Rails.logger.error("[#{reference}] #{e.class}: #{e.message}\n#{e.backtrace&.first(10)&.join("\n")}")
render json: { error: "Request failed", reference: }, status: :internal_server_error
end
Support can search the reference. The user gets something actionable — a code to quote — rather than a frowning face.
Use Specific Exception Classes
The class is part of the message and it’s what callers rescue on.
Example:
module Billing
Error = Class.new(StandardError)
class RateLimited < Error
attr_reader :retry_after
def initialize(retry_after:)
@retry_after = retry_after
super("Rate limited by billing API. Retry after #{retry_after}s.")
end
end
class InvalidAccount < Error
def initialize(account_id)
super("Account #{account_id} is not billable — no payment method on file.")
end
end
end
Two benefits. Callers can rescue Billing::RateLimited and read retry_after programmatically rather than parsing a string. And a namespaced hierarchy means rescue Billing::Error catches everything from that subsystem without catching unrelated failures.
Carrying structured data on the exception is underused and it’s where a lot of the value is — the message is for humans, the attributes are for code.
Pro-Tip: Write the error message before you write the validation. It sounds backwards, and it works because the message forces you to state precisely what the invalid condition is, which frequently reveals that your check is wrong. Trying to write “timeout must be a positive integer, got X” makes you notice that your guard was
if timeout.nil?and doesn’t catch zero, a negative, or a string. The message is the specification. This is also the fastest way to find validations that overlap or contradict — if two checks produce messages that could describe the same input, one of them is redundant or they disagree about the rule.
Test the Messages
Messages rot. A refactor renames a config key and eleven error strings still reference the old one.
Example:
RSpec.describe Config do
it "names the file, the key, and the received value" do
expect { described_class.load("spec/fixtures/bad_timeout.yml") }
.to raise_error(ConfigError, /bad_timeout\.yml:\d+.*`timeout`.*"thirty"/m)
end
it "suggests the nearest key on a typo" do
expect { described_class.fetch(:timeuot) }
.to raise_error(KeyError, /Did you mean :timeout\?/)
end
end
Don’t assert on the full string — that makes every wording improvement a test failure. Assert on the parts that carry information: the identifier, the received value, the suggestion.
Conclusion
An error message is documentation delivered at the exact moment someone needs it, to a reader who is already frustrated. Include the value with inspect so whitespace and type are visible, name the location, list the valid options or suggest the nearest one, and say what to do next. Use specific exception classes carrying structured attributes so callers can branch on them without parsing prose. Keep secrets out, keep blame out, and test that the message still names the things it claims to. The messages you write today are read by you, at 3am, in about eight months.
FAQs
Q1: How long should an error message be?
As long as it needs to be and no longer. One line for something obvious; a short block with an example for a configuration error. Length is not the cost — irrelevant length is.
Q2: Should error messages be translated?
User-facing ones, yes. Developer-facing exceptions, no — a stack trace in a language the on-call engineer doesn’t read is worse than useless, and translated messages are unsearchable.
Q3: Is it worth defining custom exception classes for a small script?
Usually not. raise ArgumentError, "clear message" is fine. Custom classes pay off when callers need to distinguish failure modes programmatically.
Q4: How do I avoid leaking data while still being specific?
Include shape rather than content — length, type, prefix, a hash of the value, the field name. "expected Integer, got String (12 chars)" is specific and leaks nothing.
Q5: Should the message end with a period?
Be consistent within a project. Multi-sentence messages need them; single fragments read fine without. The consistency matters more than which convention you pick.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀