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 onlyPitfalls
errors.Isdoes a==compare plus unwrap — sentinel errors must be the same value, not just the same message.panicis 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.