Your Error Messages Are a User Interface and Most of Them Are Terrible
An error message is read by a person under stress who needs to make a decision. Almost nobody designs them that way.
Here is an error message I hit last month:
Error: Invalid configuration
That is the whole thing. No file, no field, no value, no hint about what "invalid" means. The program had a configuration object in memory, knew exactly which key failed validation, knew what it expected, and chose to tell me none of it.
It cost me about forty minutes.
I want to argue something that sounds obvious and is almost universally ignored in practice: an error message is a user interface. It is read by a human being, under time pressure, who needs to make a decision. Every principle you would apply to designing a form applies to designing an error, and almost nobody does it.
Who reads an error message
Three audiences, and good messages serve all three.
The user of your product, who needs to know whether they did something wrong, whether they can fix it, and what to do next.
The developer integrating with you, who needs to know which of their assumptions is false.
The future maintainer at 2am, who needs to know what the system was doing when it gave up.
Most error messages are written for a fourth audience that does not exist: the developer who wrote the code, who already knows everything and only needs a marker for where the flow stopped. That is why so many messages read like Error: failed and Something went wrong. They were written by someone who never intended to read them.
What a good error message contains
Four parts. Not all four every time, but a message missing three of them is doing badly.
What happened, in specific terms. Not "invalid input" but "expected an integer, received the string 'twelve'".
Where. File, line, field name, record id, request id. The single most useful improvement to most error messages is naming the thing.
Why it matters, or what the consequence is. Did the operation partially complete? Was anything written? Is retrying safe?
What to do next. The action. Sometimes it is obvious once you know what happened, sometimes it needs stating.
Compare:
Error: Invalid configuration
against:
Invalid configuration in ./config/production.yaml, line 34:
timeout: "30s"
^^^^^
Expected an integer number of seconds, got the string "30s".
Try: timeout: 30
No configuration was loaded and the server did not start.
Same failure. The second one is a fix in fifteen seconds. It also required no information the program did not already have at the moment it failed, which is the part I find genuinely frustrating about bad errors. The data is right there. Someone chose not to include it.
The patterns that make errors useless
The swallowed cause
try:
result = parse(payload)
except Exception:
raise ValueError("Could not process request")
The original exception told you exactly what was wrong. This throws it away and replaces it with something vaguer. In Python, raise ... from e preserves the chain and costs nothing. In JavaScript, the cause option on the Error constructor does the same.
Rewriting a specific error as a general one is the single most destructive thing you can do to a debugging session, and it is usually done in the name of a clean API.
The message with no subject
Error: not found
Error: permission denied
Error: timeout
What was not found? Permission to do what, as whom? Which operation timed out and after how long?
Every one of these is one interpolated variable away from being useful. user 8823 not found and timeout after 30s connecting to payments-api.internal:443 require no extra work and no extra context, just the decision to include what you already have.
The stack trace as the entire message
Sometimes correct, for developers, in logs. Wrong when it reaches a user, and wrong when the top frame is a generic wrapper. A 60 line trace where the first useful frame is number 43 is not communication, it is a data dump with the answer buried in it.
The message that blames the user for a system fault
"Invalid email address" when your regex is wrong and the address is a perfectly valid one with a plus sign or a long TLD. The user now believes they made a mistake and starts editing a correct value.
If you are not certain the input is wrong, do not say the input is wrong.
The unactionable log line
WARN Retrying operation (attempt 3)
Retrying what? Because of what? Will it keep retrying? Should anyone care? A log line that cannot change anybody's behaviour is noise, and noise makes the signal harder to find. There is a real cost to logging things nobody can act on.
Error codes with no lookup
ERR_4471 is a fine thing to include alongside a description. On its own it requires the reader to find documentation that frequently does not exist. If you use codes, make them greppable in your own codebase at minimum, and never make the code the only content.
The security exception, and its limits
The standard objection: detailed errors leak information to attackers.
Sometimes true, and much narrower than it is used for. The genuine cases are authentication, where "user not found" versus "wrong password" is an account enumeration vector, and anything revealing infrastructure internals to an untrusted client.
But this concern is routinely used to justify vagueness everywhere, including in internal tools, CLI programs, build systems, and logs that never leave your infrastructure. Those have no adversary and no reason to be vague.
The right pattern is to split the audience:
error_id = uuid4()
logger.error("payment authorisation failed",
extra={"error_id": error_id, "gateway": gw,
"code": resp.code, "body": resp.text})
return {"error": "Payment could not be processed",
"reference": str(error_id)}, 502
The user gets something safe and a reference. You get everything. Support can join them in one query. Nobody is guessing.
Writing better ones
Include the value that failed. Truncate if huge, redact if sensitive, but include it. "Invalid date" versus "invalid date: '2026-13-45', month must be 1 to 12" is the difference between a search and a glance.
Say what you expected. The reader knows what they provided. They do not know what you wanted. This one line converts most confusing errors into obvious ones.
Name the location. File and line, config key path, field name, array index, record id. In a loop over 4,000 rows, "row 2,847" is everything.
Suggest the fix when you can compute it. If you know the valid options, list them. Did-you-mean on a misspelled key is not hard and it is enormously appreciated:
Unknown option "--verbse".
Did you mean "--verbose"?
Say whether it is safe to retry. This is the question the reader is actually asking during an incident and almost no error answers it.
Preserve the chain. raise ... from e. Always.
Read your own errors on purpose. Once a quarter, grep your codebase for error strings and read them cold, as if you had never seen the code. Most of them will be worse than you remember. I do this and it is consistently humbling.
Who does this well
Worth studying, because good examples are more instructive than complaints.
Rust's compiler is the reference standard. It shows the source line, underlines the exact span, explains the rule in plain language, and very often prints the corrected code. It also has an error index, so rustc --explain E0382 gives you an essay. The Rust team treated error messages as a product surface and it is a large part of why the language has the reputation it does despite being difficult.
Elm made friendly errors an explicit design goal and the messages read like a person explaining something.
ESLint names the rule, which means you can look it up, disable it precisely, or understand the reasoning.
Postgres gives you a message, a detail, and frequently a hint, as three distinct fields. The hint field is a genuinely good idea that more systems should copy.
The common thread is that somebody decided the error message was part of the product rather than an afterthought at the end of a function.
Why this matters more now
Two reasons this has become more important rather than less.
AI tools read your errors. When a coding agent hits a failure, the error message is most of its context for deciding what to do. A vague error produces a wrong fix, confidently applied. A precise error frequently produces a correct one. If you maintain a library, your error messages are now an interface to automated consumers as well as human ones, and the quality difference shows up directly in how well those tools work with your code.
More code is generated than read. Which means more of the time, the person hitting your error did not write the code that called you and has no mental model of it at all. They are relying entirely on what you tell them.
The rule I try to hold
Before shipping an error path, ask: if a stranger hits this at 2am, can they act on it without reading my source code?
If the answer is no, the message is not finished. It is usually one interpolated variable and one sentence away from being finished, and that is a very cheap fix for something that will be read hundreds of times.