Every Go developer has typed if err != nil { return err } more times than they can count. People love to complain about it. I've come around to liking it, with one condition: the errors you return have to carry enough information to be useful when they reach the top of the stack.
A bare return err from five layers down usually gets you a log line like sql: no rows in result set. Which query? For which user? From which endpoint? You'll never know. This post covers the habits that fixed that for me.
Wrap with context, every time you cross a boundary
Since Go 1.13, fmt.Errorf with the %w verb wraps an error while keeping the original inside it:
func (r *UserRepo) ByID(ctx context.Context, id string) (*User, error) {
var u User
err := r.db.QueryRowContext(ctx, `SELECT id, email FROM users WHERE id = $1`, id).
Scan(&u.ID, &u.Email)
if err != nil {
return nil, fmt.Errorf("query user %s: %w", id, err)
}
return &u, nil
}
If every layer adds a short note about what it was trying to do, the final message reads like a stack trace written by a human:
load dashboard: get user profile: query user 42: sql: no rows in result set
A few conventions keep these messages readable:
- Lowercase, no trailing punctuation. Messages get glued together with colons, so
Failed to query user.in the middle of a chain looks odd. - Say what you were doing, not that it failed. Write
query user 42, notfailed to query user 42. Every error is a failure, so "failed to" just repeats itself at every level. - Add information the caller doesn't have. Repeating the function name adds nothing, but the ID you were looking up is exactly what you'll need.
Checking errors: errors.Is and errors.As
Once errors are wrapped, err == sql.ErrNoRows stops working, because the outer error isn't sql.ErrNoRows anymore. Use the helpers in the errors package, which walk the whole chain:
if errors.Is(err, sql.ErrNoRows) {
// the chain contains this exact sentinel value
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
// the chain contains a *pgconn.PgError; pgErr now points to it
}
errors.Is compares against a specific value. errors.As looks for a type and, if it finds one, assigns it to your variable so you can read its fields. In the example, 23505 is PostgreSQL's unique_violation code, and pgconn is the error type from the popular pgx driver.
Sentinel errors vs. error types
There are two main ways to let callers react to a specific failure.
Sentinel errors are package-level values:
var ErrNotFound = errors.New("not found")
They're simple and cheap, and errors.Is handles them well. Use them when the caller only needs to know what kind of failure happened.
Error types are structs that implement error:
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid %s: %s", e.Field, e.Reason)
}
Use them when the caller needs data about the failure, such as which field was invalid or how long to wait before retrying.
A word of caution: every sentinel or type you export becomes part of your API. Once callers depend on ErrNotFound, you can't quietly remove it. I only export the errors a caller can realistically act on and keep everything else as wrapped, unexported detail.
Don't leak your storage layer
A common mistake is letting sql.ErrNoRows travel all the way up to the HTTP handler. The handler then needs to know you use SQL, and switching to a different store breaks error handling everywhere.
Instead, translate at the boundary. The repository knows about SQL, and everything above it knows only about domain errors:
var ErrUserNotFound = errors.New("user not found")
func (r *UserRepo) ByID(ctx context.Context, id string) (*User, error) {
var u User
err := r.db.QueryRowContext(ctx, `SELECT id, email FROM users WHERE id = $1`, id).
Scan(&u.ID, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("query user %s: %w", id, err)
}
return &u, nil
}
Notice the asymmetry. The expected, meaningful case (not found) gets turned into a domain error. The unexpected case (connection reset, syntax error) gets wrapped and passed along, because no caller is going to handle it specially anyway.
If you want to translate and keep the original for logging, wrap the domain error instead: fmt.Errorf("%w: user %s", ErrUserNotFound, id). errors.Is(err, ErrUserNotFound) still matches.
Mapping errors to HTTP responses in one place
With domain errors in place, the HTTP layer gets a single function that decides how each kind of error looks to the outside world:
func writeError(w http.ResponseWriter, err error) {
var ve *ValidationError
switch {
case errors.As(err, &ve):
http.Error(w, ve.Error(), http.StatusBadRequest)
case errors.Is(err, ErrUserNotFound):
http.Error(w, "user not found", http.StatusNotFound)
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "request timed out", http.StatusGatewayTimeout)
default:
slog.Error("unhandled error", "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
The default branch does two jobs: it logs the full wrapped chain for you, and it shows the client a generic message. Never send raw internal error strings to clients. They leak table names, hostnames and sometimes worse.
Multiple errors with errors.Join
Sometimes several things fail at once: validating a form, closing several resources, or running a batch. Go 1.20 added errors.Join, and errors.Is/errors.As both look inside joined errors:
func (f SignupForm) Validate() error {
var errs []error
if f.Email == "" {
errs = append(errs, &ValidationError{Field: "email", Reason: "required"})
}
if len(f.Password) < 12 {
errs = append(errs, &ValidationError{Field: "password", Reason: "too short"})
}
return errors.Join(errs...) // nil if errs is empty
}
errors.Join returns nil when every error passed in is nil, so you don't need a length check. The same release also let fmt.Errorf accept more than one %w verb in a single call.
Handle an error once
The last habit is the one that cleans up logs the most: either handle an error or return it, never both. "Handling" includes logging. This pattern is everywhere, and it's noise:
if err != nil {
log.Printf("could not load user: %v", err) // logged here...
return err // ...and again by every caller
}
You end up with the same failure logged four times at four levels, and whoever is on call has to work out that it's one incident. Wrap and return until you reach a layer that can actually decide what to do, whether that's retrying, falling back, or turning it into a response. Log there, once, with the full chain.
A short checklist
- Wrap with
%wand a lowercase note about what you were doing. - Use
errors.Isfor values anderrors.Asfor types. Never compare wrapped errors with==. - Translate storage errors into domain errors at the repository boundary.
- Map domain errors to transport responses in one place.
- Use
errors.Joinwhen several independent things can fail. - Handle each error once. Logging counts as handling.
None of this is clever, and that's the point. Go's error handling is plain on purpose. Apply it consistently and the error message you see in production tells you what broke and where.
Want to talk Go or backend architecture? Drop me a message on the contact page.