Claude Fable 5.1 & GPT-6 Astra packages are live

Go Errors

Free · MIT

Go error handling — wrapping with %w, errors.Is and errors.As, sentinel versus typed errors, when panic is correct, message conventions, and handling…

218 lines8.6 KB Grok Backend
Target models
Grok 4.6Grok 4.5Grok 4 FamilyGrok Code FastFuture Grok Models
Name
go-errors
Category
Backend
Description
Go error handling — wrapping with %w, errors.Is and errors.As, sentinel versus typed errors, when panic is correct, message conventions, and handling each error exactly once at the edge.
License
MIT
Author
Agent.md maintainers
Last verified
2026-09-13
Reviewed by
unreviewed

#Non-negotiable

The constraints hoisted below override anything later in this document. Read them first; the rest is rationale.


#Purpose

Rules for errors in Go. Errors are values; the language gives you no exceptions and no stack unwinding, and the code that results is only as good as the discipline around if err != nil.

The governing rule: handle every error exactly once. Either return it (wrapped) or handle it (log, retry, respond) — never both.


#Wrap with %w, and only %w

go
// Bad: %v flattens the chain. errors.Is/As stop working past this point.
return fmt.Errorf("load order %s: %v", id, err)

// Good: %w keeps the chain. Add what THIS frame knows and nothing more.
return fmt.Errorf("load order %s: %w", id, err)
  • Every return err that crosses a package boundary should add context. A bare return err from six frames deep produces sql: no rows in result set with no idea which query.
  • Add the operation and the identifiers this frame has. Do not repeat what the callee already said — "load order: load order: query: …" is the sign of wrapping at every line.
  • Messages are lowercase, no trailing punctuation, joined by : . The chain reads top-down as a sentence.
  • Never wrap with %v or %s. They are only correct when you deliberately want to hide the cause from callers.

#errors.Is and errors.As

go
var ErrNotFound = errors.New("not found")          // sentinel: identity matters

type ValidationError struct {                       // typed: carries data
    Field string
    Msg   string
}
func (e *ValidationError) Error() string { return e.Field + ": " + e.Msg }

// Checking — never string-compare, never ==
if errors.Is(err, ErrNotFound) { return http.StatusNotFound }
var ve *ValidationError
if errors.As(err, &ve) { return respondField(ve.Field, ve.Msg) }
  • errors.Is walks the wrap chain comparing identity. Use it for sentinels.
  • errors.As walks the chain looking for a type. Use it when the caller needs fields from the error.
  • Never err == ErrNotFound. It fails the moment anyone wraps the error.
  • Never strings.Contains(err.Error(), "not found"). Messages are for humans and change without notice.
  • Map library errors to your own at the boundary: sql.ErrNoRows becomes orders.ErrNotFound inside the store. Callers should not import database/sql to interpret your errors.

#Sentinel or typed?

NeedUseExample
Caller branches on which errorSentinel var Err… = errors.NewErrNotFound, ErrConflict
Caller needs data from the errorTyped struct implementing errorfield name, retry-after, status
Nobody will inspect itPlain fmt.Errorf with %wmost internal errors
Several failures at onceerrors.Join(errs...)validating every field
  • Export sentinels only when a caller has a reason to check them. An exported error is API; removing it is a breaking change.
  • Typed errors use pointer receivers and are matched with errors.As(err, &ptr). A value receiver with a pointer target silently never matches.
  • errors.Join (1.20+) returns an error that errors.Is matches against any member. Use it to report all validation failures, not just the first.

#Handle once, at the edge

go
// Inside: return, don't log. The caller decides.
func (s *Store) Get(ctx context.Context, id string) (Order, error) {
    row := s.db.QueryRowContext(ctx, q, id)
    var o Order
    if err := row.Scan(&o.ID, &o.Total); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return Order{}, ErrNotFound
        }
        return Order{}, fmt.Errorf("get order %s: %w", id, err)
    }
    return o, nil
}

// Edge: the HTTP handler logs once and translates to a response.
o, err := store.Get(r.Context(), id)
switch {
case errors.Is(err, orders.ErrNotFound):
    http.Error(w, "not found", http.StatusNotFound)
case err != nil:
    logger.Error("get order", "err", err, "id", id)
    http.Error(w, "internal error", http.StatusInternalServerError)
}
  • Log or return. A function that logs and then returns the error causes the same failure to appear once per frame in the logs.
  • The edge (handler, job, main) is the one place that logs, maps to a status, and decides whether to retry.
  • Never leak internal error text to clients. err.Error() in a JSON response exposes table names, hosts, and file paths.

#panic is for bugs, not failures

go
// Correct: a programmer error that cannot happen with valid code.
func MustCompile(pattern string) *regexp.Regexp   // panics at init on a bad literal

// Wrong: a runtime condition. Return an error.
if user == nil { panic("no user") }
  • Panic when the program is in a state that indicates a bug: an impossible enum value, a nil that the type system should have prevented, a failed init.
  • Never panic on I/O, user input, network, or anything an operator could cause. Those are errors.
  • recover belongs in exactly two places: the top of a goroutine you spawn (so one bug does not kill the process) and HTTP middleware that turns a panic into a 500 with a stack trace in the log. Nowhere else.
  • A Must… prefix is the only acceptable signal that a function panics.

#Checking and shadowing

go
// Shadowing bug: the outer err is never assigned; the check below is dead.
if v, err := parse(s); err != nil { … }
if err != nil { … }               // ← checks the outer, still-nil err

// Wrap the error, keep it in scope
v, err := parse(s)
if err != nil {
    return fmt.Errorf("parse %q: %w", s, err)
}
  • go vet and staticcheck catch some shadowing; errcheck catches ignored returns. Run them.
  • Never discard an error with _ unless the comment beside it says why: _ = f.Close() // read-only; nothing to flush.
  • defer f.Close() on a file you wrote to swallows the write error. Check it: defer func() { err = errors.Join(err, f.Close()) }().

#Anti-patterns

Anti-patternWhy it failsFix
fmt.Errorf("…: %v", err)Breaks the chain; errors.Is stops matching%w
err == ErrNotFoundFalse once anything wraps iterrors.Is
strings.Contains(err.Error(), …)Messages change; silent breakageSentinels or typed errors
Log then returnSame failure logged per frameReturn inside, log at the edge
err.Error() in an API responseLeaks internals to clientsMap to a status and a safe message
Wrapping with no added context"query: query: query: …"Add what this frame knows
panic on user or network inputCrashes on routine failuresReturn an error
recover sprinkled through codeHides bugs, corrupts stateTop of goroutines and HTTP middleware only
Shadowed err in if … :=Outer check is dead codeDeclare once; let linters run
_ = doThing() with no commentSilent data lossHandle it or justify ignoring it
Exporting every error variableAPI surface nobody asked forExport only what callers check
Returning sql.ErrNoRows from a storeCallers must import database/sqlTranslate to ErrNotFound

#Checklist

  • Verify: Every wrapped error uses %w, never %v
  • Verify: Each wrap adds this frame's context and does not repeat the callee's
  • Verify: Error messages are lowercase, unpunctuated, and joined by :
  • Verify: Errors are matched with errors.Is / errors.As, never == or string search
  • Verify: Sentinels are exported only when a caller branches on them
  • Verify: Typed errors use pointer receivers and are matched by pointer
  • Verify: Library errors are translated to package errors at the boundary
  • Verify: Errors are logged once, at the edge, never at every layer
  • Verify: No API response contains raw err.Error() text
  • Verify: panic is reserved for programmer errors; recover only in goroutine roots and HTTP middleware
  • Verify: errcheck / staticcheck run in CI; ignored errors carry a comment
  • Verify: Deferred Close on writers has its error checked