Back to Notes

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:

  • %v vs %w: %v flattens the chain — errors.Is stops working downstream.
  • errors.As requires a pointer to a type implementing error (var e *QueryError; errors.As(err, &e)); passing the value or a *error panics.
  • Wrapping everything blindly leaks internals: wrapped errors become API — callers start depending on sql.ErrNoRows escaping your repo layer. Deliberately don't wrap (%v or 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.New in a var, 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

  1. 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 defines var ErrNotFound = errors.New("not found") (sentinel — pure signal) and type 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 a Temporary() bool behavior interface or an ErrTransient sentinel so callers can retry. Callers use errors.Is(err, repo.ErrNotFound) and errors.As(err, &conflict)sql never leaks upward, so swapping Postgres for Dynamo can't break callers. Anti-pattern to call out: wrapping sql.ErrNoRows with %w all the way up makes driver errors public API.
  2. Why does %v in an error-wrapping fmt.Errorf call silently break errors.Is for downstream callers?%w specifically produces a wrapped error implementing Unwrap(), which is what lets errors.Is/errors.As walk back to the original cause; %v just formats the error's string representation into a brand-new plain error with no Unwrap() 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.
  3. 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.Is is 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 by errors.As is required instead, since a sentinel value carries no payload.

Cross-links

[[Go Interface Internals]] · [[Go Concurrency Patterns]] · [[SQL Transactions & ACID]]