What does a package commit to when it exports var ErrNotFound as part of its API?
answer
- you invited callers to branch on it
- two promises, not one
- the compiler cannot police behaviour
- quietly stopping breaks nothing loudly
- a test is the only red build you get
basics
~20 sAn exported sentinel commits the package to returning an error that still matches ErrNotFound for that condition in every release. The promise is enforced only at run time, so quietly stopping breaks callers silently rather than at compile time.
solid answer
~50 sExporting `var ErrNotFound` publishes a value callers will branch on, so from then on the package promises two things: that it returns something matching that value whenever the documented condition occurs, and that it does not return it for other conditions. Both are run-time promises. If a later version stops returning it — say the code path is rewritten and returns a different error — every caller still compiles and every caller silently takes the wrong branch. That is a stricter obligation than an exported function signature, which at least breaks loudly. The cost is also breadth: the sentinel says only that the condition happened, so any caller wanting detail has to get it from elsewhere, and once the value is exported you cannot narrow that meaning later without changing behaviour under callers. Document it on the variable, cover it with a test, and treat changing it as a breaking change.
code
go · 3 lines// ErrNotFound is returned by Lookup and Delete when no record
// exists with the given id. Callers should test for it with errors.Is.
var ErrNotFound = errors.New("store: record not found")go deeper
Know that once a package exports an Err value, other code branches on it, so it is public API — not an internal detail you can change while tidying up.
Be able to state both obligations: keep returning it for the documented condition, and never return it for another. Explain why neither is checked by the compiler.
Show how you hold the promise across refactors — a precise doc comment, a package-level test that asserts the match on a real call, and treating a change to it as a breaking change rather than a cleanup.
Own the policy question: which conditions deserve a permanent public signal at all, how the team versions a change to one, and what you tell dependent teams when a condition's meaning has to shift.
## What exporting actually publishes An exported sentinel is not just a variable. It is an invitation: "branch on this." Callers write ```go if errors.Is(err, store.ErrNotFound) { ... } ``` and from that moment the package has taken on obligations that the compiler does not police. ## The two promises **Completeness — you keep returning it.** Every future version must still produce an error that matches `ErrNotFound` whenever the documented condition occurs. If a rewrite of the lookup path returns some other error for a missing record, the caller's branch simply stops firing. Nothing fails to compile. Nothing in `go vet` notices. The caller's "no such record" path is quietly replaced by its generic failure path, which is exactly the class of regression that reaches production. **Soundness — you do not over-return it.** If a later version returns `ErrNotFound` for some adjacent condition — a deleted record, an unauthorised read reported as absence — callers that treated it as "safe to create it then" now act on a different reality. Widening a sentinel's meaning is as breaking as removing it, and less visible. ## Why this is a heavier promise than a signature Go's tooling enforces the *shape* of an API: change an exported function's parameters and dependents fail to build; remove an exported identifier and they fail to build. A sentinel's contract lives entirely in run-time behaviour. Deleting the variable is at least a loud break — dependents stop compiling. Keeping the variable and no longer returning it is the dangerous edit, because it is invisible at every stage of a dependent's build. The standard library treats these values accordingly: `io.EOF`, `sql.ErrNoRows` and `os.ErrNotExist` are as fixed as any function signature, and the documentation of the functions that return them states the condition precisely. That precision is the point — a sentinel documented as "returned on failure" is useless, because callers cannot tell what they are allowed to conclude. ## Other costs worth naming - **It becomes part of your import graph's vocabulary.** Callers write `errors.Is(err, store.ErrNotFound)`, which means they import `store` to classify. If you later split or rename the package, you are moving a value that appears in other teams' `if` statements. - **It admits no detail.** A sentinel says the condition happened, not which key, which field, or which limit. That is its virtue and its ceiling. It cannot be extended to carry data later without becoming a different thing. - **Your callers' matching depends on your internals.** The match works only while the code path that produces the condition keeps that value in the returned error's chain. Refactors that reconstruct errors in the middle break the contract from the inside, without touching the exported declaration at all. - **The condition set only grows.** Callers will ask for more sentinels — `ErrConflict`, `ErrClosed` — and each one is another permanent promise. Exporting the first one establishes that this is how your package communicates. ## How to hold the promise **Document it on the variable, in caller-facing terms.** ```go // ErrNotFound is returned by Lookup and Delete when no record // exists with the given id. Callers should test for it with errors.Is. var ErrNotFound = errors.New("store: record not found") ``` Say which functions return it, under exactly what condition, and how to test for it. Anything not stated is something a caller will guess. **Test the promise, not the implementation.** Package-level tests that assert `errors.Is(err, ErrNotFound)` on a real call for the documented condition are the only mechanism that turns a run-time promise into a red build. Include one that exercises the assembled path rather than the innermost function, so a refactor that reconstructs the error mid-chain is caught. **Treat a change as a breaking change.** Removing or repurposing a sentinel gets the same care as changing a function signature — a major version, a release note, or, more often, simply not doing it. **Export only what callers must branch on.** Every condition that does not drive a caller decision is better left as an ordinary error with a good message. The sentinel is a control-flow signal; if nobody branches on it, it is only a permanent commitment with no payoff. A package can also keep the value unexported and expose a predicate function instead, which keeps the decision inside the package — a boundary-design call worth making deliberately, not by default. ## The summary a reviewer should hear An exported sentinel is a permanent, run-time, silently-breakable promise about behaviour. It buys callers a cheap, precise branch; it costs you the freedom to change what that code path returns, for as long as anyone depends on the package.
- Why is a package that quietly stops returning its exported sentinel worse than one that deletes it?Deleting the variable breaks every dependent's build immediately, which is loud and fixable. Keeping it while no longer returning it leaves every dependent compiling and every classification branch silently dead, so the failure shows up as wrong behaviour in production instead of a red build.
- What makes a sentinel's doc comment good enough to be a contract?It names the functions that return it and the exact condition, and it tells callers to match with errors.Is. "Returned on failure" is not a contract because callers cannot tell what they may conclude. Precision here is what lets you refactor later without guessing at what people relied on.
- How do you keep a sentinel promise from breaking during an internal refactor?A package-level test that calls the real exported function for the documented condition and asserts the match. That converts a run-time promise into a compile-and-test signal, and it catches the common regression where a rewritten middle layer reconstructs the error and drops the original from the chain.
saying these in an interview costs you the question
- Treating an exported sentinel as an implementation detail
- Assuming callers will get a compile error if it stops being returned
- Documenting it only as returned on failure
- Widening the conditions it covers in a later release
- Exporting a sentinel nobody branches on