Why is matching on an error's Error() text a defect in Go, and what must a package export instead?
answer
- who ever promised that wording?
- renaming breaks the build; rewording does not
- each wrapping layer rewrites the string
- the message quotes the caller's own input
- the package must export something matchable
basics
~20 sAn error's Error() text is documentation, not contract: a package can reword it in any release and the caller's match stops firing, with no compile error. The package must export something matchable instead, a sentinel value or an error type.
solid answer
~50 sThe only thing an `error` promises is a `string` meant for a human reading a log line. Nothing pins that wording: the author can reword it, `fmt.Errorf` layers prepend their own operation as the value travels up, and the message usually interpolates the input, so a substring test can match the caller's own data. When the wording changes the caller still compiles and its tests still pass; the branch just quietly stops being taken, and the wrong path runs in production. The fix is on the package side, not the caller side: if a caller is expected to distinguish a failure, the package has to export something with a stable identity to match against, either a sentinel error value or an exported error type. If it exports neither, the honest reading is that the package treats all its failures the same, and callers should too.
code
go · 10 lines// v1.0.0
return fmt.Errorf("invalid version %q", in)
// v1.0.1 - author adds a package prefix, still non-breaking by every Go convention
return fmt.Errorf("semver: invalid version %q", in)
// the caller, unchanged and still compiling
if strings.HasPrefix(err.Error(), "invalid version") {
// silently stops running after v1.0.1
}go deeper
Be ready to say plainly that Error() returns a message for humans and that nothing stops a package from changing it. Recall that the caller keeps compiling, so nothing tells you the branch stopped working.
Explain the mechanics: wrapping layers prepend text, messages interpolate the failing input, and none of that touches the exported surface. Then name what the package should have exported instead of leaving callers to read prose.
Show how the failure presents in production: a green build, passing tests, and a wrong branch taken quietly after someone else's patch release. Say how you would find it and how you would stop the class of bug rather than the one instance.
Own the policy side. Decide what your packages state as contract, which failures get a matchable surface at all, and how you tell importers in writing that wording is never it. That statement is what lets you reword freely later.
## The whole contract is one method In Go, `error` is an interface with a single method that returns a `string`. That is the entire language-level contract. The string has exactly one intended audience: a person reading a log, a CLI message, or a stack of context in an incident. Nothing in the compiler, `go vet`, the module system, or semantic-versioning tooling treats those characters as API. No tool warns a package author that some importer three modules away is comparing against them. So when a caller writes a branch on the text, it has made a promise on the package's behalf that the package never made. ## Why the break is invisible This is the property that makes it worse than an ordinary bug. Rename an exported function and every importer fails to build; that is loud, and it happens before anything ships. Reword a message and: - the library still builds, and its own tests still pass, because they assert behaviour rather than prose; - the importer still builds, because a string comparison is valid Go against any string; - the importer's tests may still pass, because they usually construct the failure by calling the library and comparing the same way, so both sides move together in the fake; - in production, the comparison returns false and the `else` branch runs. The failure mode is a wrong decision, not a crash. A parse failure that was being reported to the user as bad input starts being treated as an unexpected internal error and retried, or logged at the wrong severity, or swallowed. It surfaces days later as behaviour nobody can tie to a release. ## Three ways the text moves under you **Rewording.** Authors improve messages. Adding the offending input, fixing capitalisation, adding a package prefix, shortening a message that was too long for a log line: all of these are considered non-breaking by every convention Go has, because the text is not contract. **Wrapping.** As a value travels up through layers, each layer typically adds its operation and an identifier with `fmt.Errorf` and the `%w` verb. Every added layer changes the string a caller at the top sees. An equality test against the innermost wording breaks the first time anyone in between adds context, and that anyone may be a package neither the caller nor the original author controls. **Interpolation.** Messages usually embed the data that failed, such as the version string that could not be parsed. That means the caller's own input is inside the text being searched. A substring test for `unsupported` will fire on a perfectly ordinary failure whose message happens to quote an input containing that word. The match is not merely fragile, it is decidable by whoever supplies the input. ## What the package owes the caller The defect is usually shared. A caller reaching for the text is normally a caller who had no other option, because the package exported no way to distinguish the failure. So the question turns into a design question for the author: for each failure this package can return, does a caller need to do something different about it? If the answer is no, and it is no far more often than people expect, nothing needs exporting. The caller adds its own context and returns the value upward, and the text ends up where it belongs, in a log. If the answer is yes, the package must export something with a stable identity for the caller to match against. The two shapes are an exported sentinel error value, whose identity is the whole signal, or an exported error type, whose fields carry detail the caller can act on. A third, more conservative shape is to export only a predicate function that answers the question while keeping the value itself unexported. Choosing among those is a separate decision with real costs on each side, but all three share the property the text does not have: they are declarations, so a change to them is a change to the package's exported surface, visible to tooling and to any reviewer looking at a diff between two tags. ## What to say in an interview Name the asymmetry first: renaming an identifier is a compile error, rewording a message is not, and that is exactly why the second one is dangerous. Then put the responsibility in the right place. A caller matching on text is a symptom; the cure is a package that exports a matchable surface for the failures it expects callers to branch on, and documents that everything else, including all wording, is not contract.
- The wording changed in a patch release and a caller broke. Whose defect is it?Mostly the caller's, since the text was never contract. But if the package expected callers to distinguish that failure and exported no way to do it, the author shares it: the caller had no supported option. The fix is to export a matchable surface for that one condition and document that message wording is not part of the API.
- Why is a substring test on an error's text worse than an equality test, not better?Equality at least fails closed when the wording moves. A substring test keeps matching after unrelated changes and starts matching things you did not mean, because messages interpolate the failing input. Whoever supplies that input can decide which branch the caller takes.
- Is there a legitimate use for an error's Error() text?Yes: showing it to a human. Log it, print it in a CLI, include it in an incident note. It is also fine to assert on it in the package's own tests, where the author controls both sides. What it must not be is the input to a control-flow decision in another module.
saying these in an interview costs you the question
- Says the message is stable because it is written in the docs
- Uses strings.Contains on err.Error() and calls it robust
- Assumes a reworded message would fail the caller's build
- Thinks wrapping leaves the string a caller sees unchanged
- Forgets the message interpolates caller-supplied input