Write actionable errors¶
An error that only says what broke leaves the user stuck. This guide covers the four places information can live, and how to choose between them.
The rule for hints¶
A hint tells the user what to do next — not what the error was.
The message carries the error's identity (and is what errors.Is and your logs match
on). The hint carries the advice. Keeping them apart is the whole point: advice
embedded in a message makes the message long, unstable, and useless to match on.
// ✅ identity in the message, advice in the hint
return errors.WithHint(
errors.New("config file not found"),
"Run 'mytool init' to create one",
)
// ❌ advice welded into the identity
return errors.New("config file not found\n\nSuggestions:\n - run mytool init\n - pass --config")
Good hints name a concrete next action — a command to run, an environment variable to set, a setting to check:
errors.WithHintf(err, "Set %s or run 'mytool init --token'.", "GITHUB_TOKEN")
errors.WithHint(errors.Wrap(err, "failed to authenticate"),
"Check that your GITHUB_TOKEN is valid and has the required scopes.")
Make the hint discriminate between failure modes. Two failures at the same call site deserve two different hints:
if err := sql.Open(driver, dsn); err != nil {
return errors.WithHint(errors.Wrap(err, "failed to connect to database"),
"Check that the database server is running and the connection string is correct")
}
if err := conn.Ping(); err != nil {
return errors.WithHint(errors.Wrap(err, "database connection test failed"),
"The connection was established but the database is not responding — check server health")
}
Four carriers, four audiences¶
| Carrier | Audience | Shown |
|---|---|---|
Message (errors.New, errors.Wrap) |
matching code, operators | always |
Hint (errors.WithHint) |
the end user — how to fix it | always, at every level |
Detail (errors.WithDetail) |
developers diagnosing | always, at every level |
Attribute (errors.WithAttrs) |
log queries and dashboards | always, as its own field |
Plus the stack trace, captured automatically and rendered only in debug.
The split that matters is audience, not visibility: hints and details both reach the record. A detail is where a raw HTTP body or a driver-level code goes so it does not clutter the hint — not where a secret goes. Nothing here is redacted or debug-gated, so anything that must not reach a user must not be attached to the error at all.
Creating errors: the ladder¶
errors.New("static message")— the default.errors.Newf("invalid port: %d", port)— when the message needs values.errors.Wrap(err, "context")— adding context as an error travels up.errors.WithStack(err)— re-returning a sentinel and you only want the stack.errors.WithHint(err, "…")— adding advice.errors.NewSentinel(kind, msg)— at package scope. PlainNewthere captures its stack at initialisation, which points atruntime.doInitrather than anywhere the error was returned.
Never fmt.Errorf
fmt.Errorf captures no stack trace, so a failure that reaches your logs has
no origin. Use errors.Newf/errors.Wrap from go/errors instead —
same ergonomics, plus a stack. (The handler tolerates plain errors; you just get
no trace for them.)
Prefer sentinels, wrap for specifics¶
Declare the conditions callers need to distinguish as package-level values, then wrap
for the dynamic part. That keeps errors.Is working while the message stays specific:
var ErrInvalidPort = errors.NewSentinel(
"mytool.invalid_port", "invalid port: must be between 1 and 65535")
func validatePort(port int) error {
if port < 1 || port > 65535 {
return errors.Wrap(ErrInvalidPort, fmt.Sprintf("port %d", port))
}
return nil
}
Wrapping preserves identity — errors.Is(err, ErrInvalidPort) still matches — so
callers can branch, and the boundary that recognises a condition is a natural place
to attach the hint:
if errors.Is(err, ErrTokenExpired) {
return errors.WithHint(err, "Run 'mytool init --github' to re-authenticate.")
}
Message guidelines¶
- Be specific — name the file path, URL, or config key involved.
- Be actionable — if there's a fix, it goes in a hint.
- Be consistent — same terminology across your tool.
- Wrap, don't replace — add context as the error rises; never discard the cause.
Flag a bug, not a user error¶
When a condition means your code is wrong rather than the user's input, say so:
if len(items) == 0 {
return errorhandling.NewAssertionFailure(
"processItems called with empty slice; this is a bug")
}
The message convention is to name the function, state the violated precondition, and say it is a bug. Don't add a hint: nobody outside your team can act on it, and a hint that cannot be acted on trains people to ignore hints.
What identifies it in the log is the kind, errorhandling.assertion_failure, inside the
err group — so a query can count them without
matching on message text. It is otherwise reported like any other error, at whatever
level you asked for.
Related¶
- The reporting model
- Why go/errors
- Control the exit code
- Log fields — which of these carriers renders where