Go Error Handling
Error Wrapping: %w, errors.Is / errors.As / errors.Join
What: Wrapping preserves an error chain while adding context; Is walks the chain comparing to a target value, As walks it looking for a target type, Join (1.20+) aggregates multiple errors into a tree.
Syntax/Signature: fmt.Errorf("ctx: %w", err) · errors.Is(err, target) bool · errors.As(err, &typedTarget) bool · errors.Join(errs...) error.
func loadUser(id string) (*User, error) {
u, err := db.Get(id)
if err != nil {
return nil, fmt.Errorf("loadUser %q: %w", id, err)
}
return u, nil
}
// Caller:
u, err := loadUser("42")
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound // translate at the boundary
}
var netErr *net.OpError
if errors.As(err, &netErr) {
// retryable path
}
_ = u
How it works under the hood: %w produces a *fmt.wrapError implementing Unwrap() error. Is/As do a DFS over Unwrap() error and Unwrap() []error (the multi-error form from Join and multi-%w, 1.20+). Is first tries ==, then the target's/err's own Is(error) bool hook; As uses reflection to check assignability to the target pointer's element type and copies on match.
Interview angle: "Is vs As vs ==?" — == checks only the head; Is matches sentinel values through the chain; As extracts typed errors carrying data. Follow-up: "When would you implement Is() yourself?" — when equality should be semantic (e.g., match any *HTTPError with the same status code).
Gotchas / common wrong answers:
%vvs%w:%vflattens the chain —errors.Isstops working downstream.errors.Asrequires a pointer to a type implementingerror(var e *QueryError; errors.As(err, &e)); passing the value or a*errorpanics.- Wrapping everything blindly leaks internals: wrapped errors become API — callers start depending on
sql.ErrNoRowsescaping your repo layer. Deliberately don't wrap (%vor translate) at layer boundaries.
Trade-offs / when NOT to use: Wrapping is for programmatic inspection; if callers should never branch on the cause, opaque errors + logged context are a smaller contract.
Sentinel Errors vs. Custom Error Types
What: Sentinel = exported package-level var Err... = errors.New(...) matched with errors.Is. Custom type = struct implementing error, carrying structured data, matched with errors.As.
var ErrNotFound = errors.New("thing not found") // sentinel
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: field %q %s", e.Field, e.Reason)
}
func Get(id string) (*Thing, error) {
if id == "" {
return nil, &ValidationError{Field: "id", Reason: "empty"}
}
t, ok := store[id]
if !ok {
return nil, fmt.Errorf("get %q: %w", id, ErrNotFound)
}
return t, nil
}
How it works under the hood: Sentinels rely on pointer identity of the errors.New result (*errorString), which is why errors.Is defaults to ==. Custom types are matched by dynamic type via As's reflection walk. Both survive wrapping because Is/As unwrap.
Interview angle: "How do you decide between them?" — sentinel when the only signal is "this condition happened" (io.EOF, sql.ErrNoRows); custom type when the caller needs data (field name, HTTP status, retry-after). Third option: behavior interfaces (interface{ Timeout() bool }) when you want matching without an import dependency.
Gotchas / common wrong answers:
- Comparing error strings (
err.Error() == "not found") — brittle, and interviewers treat it as disqualifying. - Sentinels are mutable package vars — technically anyone can reassign them (use
errors.Newin avar, never expose settable error state). - Pointer-receiver
Error()on a custom type reintroduces the typed-nil trap (see [[Go Interface Internals]]) when returned concretely.
Trade-offs / when NOT to use: Every exported sentinel/type is permanent API surface. Internal-only failure modes should stay opaque (unexported types or plain wrapped errors).
Interview Drills
- Design the error strategy for a repo layer over Postgres: how do callers detect "not found" vs "constraint violation" vs transient failure, without importing
database/sql? — Translate at the boundary. The repo definesvar ErrNotFound = errors.New("not found")(sentinel — pure signal) andtype ConflictError struct{ Constraint string }(custom type — carries data). Internally:if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("get user %d: %w", id, ErrNotFound) }; map pgconn error codes to*ConflictError. Transient: either wrap with aTemporary() boolbehavior interface or anErrTransientsentinel so callers can retry. Callers useerrors.Is(err, repo.ErrNotFound)anderrors.As(err, &conflict)—sqlnever leaks upward, so swapping Postgres for Dynamo can't break callers. Anti-pattern to call out: wrappingsql.ErrNoRowswith%wall the way up makes driver errors public API. - Why does
%vin an error-wrappingfmt.Errorfcall silently breakerrors.Isfor downstream callers? —%wspecifically produces a wrapped error implementingUnwrap(), which is what letserrors.Is/errors.Aswalk back to the original cause;%vjust formats the error's string representation into a brand-new plain error with noUnwrap()method — the chain is severed, and any caller trying to match against the original sentinel/type will fail even though the string might look identical. - When would you choose a sentinel error over a custom error type for a new failure mode? — When the only information the caller needs is "did this specific condition happen or not" (no additional data to carry) — a sentinel matched by
errors.Isis the simplest correct tool. The moment a caller needs structured data from the failure (which field was invalid, what the retry-after value is), a custom type matched byerrors.Asis required instead, since a sentinel value carries no payload.
Cross-links
[[Go Interface Internals]] · [[Go Concurrency Patterns]] · [[SQL Transactions & ACID]]