Skip to content

Lesson 2 of 9 · 25m

Error Handling Discipline: Wrap, Check, Return Once

Sentinel errors, %w wrapping, errors.Is/As, and the one rule that stops double-logged, context-free failures.

The page went down at 2am and the logs said error: error. Somewhere between the database and the HTTP response, every scrap of context got eaten. Go's explicit error handling is verbose on purpose — the verbosity is where the context lives, if you discipline yourself to keep it.

Sentinel errors and wrapping

Define the errors callers need to branch on as package-level sentinels:

package store

import "errors"

var ErrNotFound = errors.New("not found")

When you propagate an error, add context and keep the chain with %w:

u, err := s.getUser(ctx, id)
if err != nil {
	return nil, fmt.Errorf("store: get user %d: %w", id, err)
}

Callers then inspect the chain without string matching:

if errors.Is(err, store.ErrNotFound) {
	http.Error(w, "user not found", http.StatusNotFound)
	return
}
var pgErr *pgconn.PgError // errors.As unwraps to a concrete type
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
	// unique violation
}

%v formats the error and throws the chain away. %w keeps it. If your caller might ever need errors.Is, use %w. This is the bug that ships: someone changes %w to %v while editing the message, a errors.Is check silently starts failing, and every 404 becomes a 500.

Handle each error exactly once

The cardinal rule: an error is either handled or returned, never both. log.Printf + return err means the same failure gets logged at every layer of the stack — five lines of noise per request, zero extra information. Log at the boundary where you actually handle it (usually the HTTP handler); everywhere else, wrap and return.

At the top of your main goroutine the rule flips: log.Fatal-style handling is fine in main() because there is no caller left to return to.

Security boundary

Errors returned to clients and errors logged internally are different artifacts. A database error string can contain query fragments, table names, and connection details — none of which belong in a response body. Return a generic message to the caller and log the wrapped chain:

http.Error(w, "internal error", http.StatusInternalServerError)
log.Printf("getUser %d: %+v", id, err) // full chain in your logs only

Pitfalls

  • errors.Is does a == compare plus unwrap — sentinel errors must be the same value, not just the same message.
  • panic is for programmer bugs (index out of range), not for "the file didn't open." Services that panic on bad input get DoS'd by bad input.
  • Don't wrap with fmt.Errorf("...: %w", err) at every single frame — add context where it adds information, pass through where it doesn't.

Try this

Find (or write) a function that does log.Printf("failed: %v", err); return err. Refactor so the error is wrapped with context and logged once, in the handler. Then deliberately change a %w to %v and watch an errors.Is test fail — now you know what that regression smells like.