Error Handling
Q: Why does Go use errors as values instead of exceptions?
Go errors are ordinary values that implement the error interface (Error() string). Callers handle them explicitly at each call site — there is no implicit stack unwinding for control flow.
Benefits:
- Error paths are visible in code; no hidden exception propagation across call frames.
- The compiler enforces you receive the error; ignoring it requires
_(explicit, not accidental). - Error values can carry structured data, be inspected, wrapped, and passed across goroutine boundaries easily.
- Performance: no unwinding machinery; error handling is just a branch.
Cost: verbosity. if err != nil { return ..., err } repeats throughout code. The Go team acknowledges this; proposals for syntactic sugar exist but none have shipped as of Go 1.23.
The key discipline: add context when returning errors, but don't double-log. Log once (at the top of the call stack where you handle the error) or wrap for structured inspection — not both.
Q: How do you wrap errors, and what is the difference between errors.Is and errors.As?
Wrapping adds context to an error while preserving the original for inspection:
if err := db.Query(sql); err != nil {
return fmt.Errorf("listUsers query: %w", err)
}%w (Go 1.13+) stores the original error and makes it accessible via errors.Unwrap.
errors.Is(err, target) — checks whether any error in the chain is equal to target. Used for sentinel errors:
var ErrNotFound = errors.New("not found")
if errors.Is(err, ErrNotFound) { // walks the Unwrap chain
// handle 404 case
}
// Also works with stdlib: errors.Is(err, io.EOF)errors.As(err, &target) — checks whether any error in the chain can be assigned to target's type (via type assertion). Used for typed errors where you need the fields:
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
fmt.Println(pgErr.Code) // e.g. "23505" unique violation
}Never use == directly on wrapped errors — it compares only the outermost value and misses wrapped causes. Use errors.Is/errors.As throughout.
Q: What are sentinel, typed, and opaque errors — when do you use each?
Sentinel errors (var ErrX = errors.New(...)) — package-level variables compared by identity with errors.Is. Use when callers need to branch on a specific condition and the condition has no additional data. They become part of your package's API surface — changing them is a breaking change.
var ErrNotFound = errors.New("record not found") // callers do: errors.Is(err, repo.ErrNotFound)Typed errors — structs implementing error, inspected with errors.As. Use when callers need structured data (HTTP status code, database error code, field validation failures).
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s: %s", e.Field, e.Message)
}Opaque errors — returned as plain error, with no exported sentinel or type for callers to match. Use when the error is informational and callers should not branch on it — only log or propagate it. This is the most decoupled form; implementation details don't leak.
Guideline: start opaque. Promote to typed/sentinel only when a caller demonstrably needs to distinguish the error. Exposing a typed error is a public API commitment.
Q: How do you add context without leaking internals or creating double-log noise?
Add context at every return boundary that crosses a meaningful abstraction:
// service layer
func (s *UserService) Create(ctx context.Context, u User) error {
if err := s.repo.Insert(ctx, u); err != nil {
return fmt.Errorf("UserService.Create %q: %w", u.Email, err)
}
return nil
}Don't: log AND return — the log statement at a low level creates noise when the caller also logs:
// BAD: double-log
func (r *UserRepo) Insert(ctx context.Context, u User) error {
if err := r.db.Exec(...); err != nil {
log.Printf("insert failed: %v", err) // logged here...
return fmt.Errorf("insert: %w", err)
}
return nil
}
// And the caller logs it again on receipt — two log lines for one failure.Don't leak internal details: wrapping an internal DB error string in an API response exposes schema information. Unwrap at the boundary and return a safe external message.
// HTTP handler boundary: unwrap, classify, respond safely
if errors.Is(err, repo.ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
// log the full chain internally
slog.Error("create user", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)Q: When is panic acceptable, and where should the panic/error boundary sit?
Within a package's exported API, return errors. Use panic only for:
- Invariant violations that represent bugs in the calling code (e.g., nil argument to a function that documents non-nil as a precondition).
must-style helpers for initialization-time failures where continuing would be meaningless:
// Exported Must pattern — acceptable at package level / init
func MustParse(s string) *Template {
t, err := Parse(s)
if err != nil {
panic(fmt.Sprintf("MustParse: %v", err))
}
return t
}- Library internals that spawn goroutines: recover at the goroutine root and convert to an error or structured log, preventing a silent crash.
The boundary rule: recover at the outermost layer where you can produce a meaningful error response — an HTTP handler, a background worker loop, a gRPC interceptor. Never let a panic cross a package boundary into caller code that doesn't expect it.
Practical metric: if recover appears in business logic rather than infrastructure/framework code, the design is off — the business logic should use errors-as-values.
Common follow-ups
- How do you implement a custom error type that supports both
errors.Isanderrors.Ason the same value? - What does
errors.Join(Go 1.20) do and when is it preferable to wrapping? - How does
slogstructured logging interact with error wrapping chains? - When should you use
panicvslog.Fatalfor startup failures?