When wrapping a database error with fmt.Errorf in a Go repository method, what context should the message add?
answer
- one layer, one fact
- what does this frame alone know?
- an operation and something to search on
- Go errors carry no stack trace
- the query and the DSN stay out
basics
~20 sAdd what this layer alone knows: the operation in domain terms plus the identifier it acted on, as in fmt.Errorf("load user %d: %w", id, err). Leave out the SQL text, the connection string and the driver's own wording.
solid answer
~50 sA Go error carries no stack trace, so the only breadcrumb is the text each layer chooses to add. In a repository method I add the operation and the key it acted on — `fmt.Errorf("load user %d: %w", id, err)` — because that is exactly what this frame knows and the layers above do not. I do not restate the function name (the caller knows which function it called) and I do not paste the query text, the table name or the connection string into the message. Those help nobody reading a log that already says which repository ran, and they are dangerous because the wrapped chain flattens into one string that some boundary above may render to a caller. The rule I keep in my head: one operation, one identifier, once, and nothing an outsider should not see.
code
go · 8 linesfunc (r *Repo) User(ctx context.Context, id int64) (User, error) {
var u User
row := r.db.QueryRowContext(ctx, `SELECT name FROM users WHERE id = $1`, id)
if err := row.Scan(&u.Name); err != nil {
return User{}, fmt.Errorf("load user %d: %w", id, err)
}
return u, nil
}go deeper
Be ready to write the canonical wrap on the spot: an operation in plain words, the identifier, then the cause. Know that a Go error has no stack trace, so the text you type is the only trail anyone gets.
Explain why the message is minimal rather than generous: a wrapped chain flattens into one string that some layer above may render, so anything you add is permanently attached to the error. Say which values are safe to interpolate and which are not.
Show that you police this in review. Point out that query text and connection strings in error messages are a disclosure risk long before anyone renders them, and that useful detail belongs in structured log fields where it is named rather than concatenated.
Own the convention: a one-line rule about what a wrap message may contain is cheap to write, cheap to review, and removes a whole class of incident. Decide whether it is enforced by habit, by a helper, or by a check, and say what that choice costs.
## Why the wrap message matters so much in Go In many languages an exception carries a stack trace, so a bare `throw` still tells the reader where it happened. A Go `error` is just a value with an `Error() string` method. It has no file, no line, no stack. Everything a future reader learns about *where* the failure happened is text that some function deliberately typed into a `fmt.Errorf` call. That is why Go teams wrap, and it is also why wrap messages tend to grow into dumping grounds. ## What belongs in the message The useful test is: **what does this layer know that its callers do not?** A repository method knows two things nobody above it knows: 1. **The operation, in the vocabulary of the layer above** — "load user", "insert order", "list invoices for account". Not "select failed", which is the storage vocabulary, and not "error in User", which is not an operation at all. 2. **The identifier it acted on** — the user id, the order number, the file path. This is the single most valuable fact in the whole chain, because it turns "something failed" into "this record failed", which is what makes a log line searchable. So the canonical shape is: ```go return fmt.Errorf("load user %d: %w", id, err) ``` One verb phrase, one id, then the wrapped cause. ## What does not belong - **The name of the function you are in.** `fmt.Errorf("Repo.User: %w", err)` reads like a poor man's stack trace but adds nothing the call site did not already know, and it crowds out the id. - **The name of the function you called.** The wrapped error already contributes its own text; repeating it produces `scan row: scan: sql: ...`. - **The query text.** A `SELECT ... WHERE ...` in an error message is long, it is the same on every failure, and it describes your schema to whoever ends up reading it. - **The connection string / DSN.** It frequently contains a host, a port, a database name and sometimes credentials. It must never be in a value that might be rendered. - **Row contents.** The id the caller asked for is fine; the email address, the token or the balance you just read out of the row is not. ## The reason the rule is strict A wrapped Go error flattens. `err.Error()` on a chain is just every wrapper's prefix, joined by the separators you typed, ending with the innermost message. Nothing in the language distinguishes "this part is safe to show a customer" from "this part is internal". So the moment one layer puts the DSN in the string, the DSN is in the string for the rest of the error's life, and it travels wherever the error travels — including into a response body if any boundary above ever renders `err.Error()`. Keeping messages minimal at the source is much cheaper than trying to sanitise them at the edge. ## Is the id safe? Usually yes, when it is the id the caller itself supplied: telling a caller that loading *their* user 42 failed reveals nothing they did not already send you. Be more careful with identifiers the caller did not supply — an internal account number you resolved on their behalf, a partner's tenant id, a file path on your host. Those are facts about your system, and they belong in the log rather than in a message that might be rendered. ## What about the driver's message? You keep it — that is the point of wrapping rather than replacing. `%w` keeps the original error reachable, so an operator reading the log sees `load user 42: sql: no rows in result set`, and code above can still test for the specific condition. What you control is only your *own* prefix. Whether the driver's own words ever reach a customer is a decision made once, at the boundary that writes the response, not by every repository method. ## The habit to build Write the message as if the log line will be read at 3am by someone who does not know your code: the operation in words they recognise, the id they can search on, nothing else. If you find yourself adding detail "in case we need it later", that detail belongs in a structured log field, where it has a name, a type, and no chance of being concatenated into a response body.
- Should the wrap message repeat the name of the function that failed?No. The caller already knows which function it called, and the wrapped error contributes the callee's own words, so a function name is both redundant and a waste of the short prefix you get. Spend it on the operation in domain terms plus the identifier, which is what makes the eventual log line searchable.
- Is it safe to put the id the caller supplied into the error text?Generally yes — echoing back an identifier the caller sent you discloses nothing new, and it is the most useful thing in the message. Be stricter with identifiers the caller did not supply: internal account numbers, tenant ids, host file paths. Those are facts about your system and belong in a structured log field.
- If the driver's raw message must not reach the caller, where does it go?Into the log. Keep it in the error with %w so operators and matching code still have it, and make the decision about what a caller sees once, at the boundary that writes the response. Every layer below should assume its text is for operators only.
A wrap message is a luggage tag, not a suitcase: it says where the bag is going and whose it is, and it does not list the contents.
saying these in an interview costs you the question
- Pastes the SQL statement into every wrapped error message
- Puts the connection string in the message to help debugging
- Thinks a Go error carries a stack trace, so context is optional
- Wraps with no operation and no id, just re-emitting the cause
- Repeats the callee's function name instead of the operation
- Interpolates row contents such as an email address into the message