go-errors — Go error handling
Deterministic backstop: golangci-lint run --enable-only=errorlint, plus errcheck (in the
standard set) for unchecked errors. Run the tool first; this skill is the judgment around it.
Rules
- Wrap with
%wwhen the caller may need to inspect the cause (Go 1.13):return fmt.Errorf("read config %s: %w", path, err). Use%vonly to deliberately sever the chain (e.g. to avoid leaking an internal error type across an API boundary) — and say so. Put%wlast so the message reads outside-in; a leading%wis right only when the sentinel is the sentence:fmt.Errorf("%w: %s", ErrNotFound, key). - The
%v-where-%wtrap: formatting a cause with%vdiscards the chain, so downstreamerrors.Is/errors.Assilently fail.errorlintflags it. - Inspect with
errors.Is(sentinel) /errors.AsType[E](typed) — nevererr == ErrXor a type assertion once any layer wraps, or the result is sentinel breakage (the comparison silently stops matching).if perr, ok := errors.AsType[*fs.PathError](err); ok { … }(Go 1.26): the generic form — compile-time-checked target, no pointer to prepare, no reflection, cannot panic on a mistyped target.errors.As(Go 1.13) is not deprecated and existing call sites are not bugs — theerrorsastypemodernizer converts them (via golangci-lint'smodernize; not yet in the 1.26.4 toolchain'sgo fix). - Sentinel errors (
var ErrNotFound = errors.New("not found")) for expected, comparable conditions that are part of the API contract — keep the set small and documented. Typed errors (a struct implementingerror) when callers need fields (*PathError). errors.Join(err1, err2)(Go 1.20) to aggregate independent failures (cleanup, validation) — replaces manual concatenation and most third-party multierror use.- Never swallow: no
_ = f()on an error that matters; no emptyif err != nil {}. Handle, wrap-and-return, or (deliberately, with a comment) ignore. - Check
Closeon anything written to.defer f.Close()discards a failed flush — the write looks successful and the file is truncated. Capture it into a named result:defer func() { err = errors.Join(err, f.Close()) }().errcheckflags the discarded form; read-only handles are the one safe place to drop it (say so with_ =). - Keep the happy path at minimal indentation — handle the error and return early; no
elseafter a terminatingif. Error flow goes in the indented branch, business logic does not. - Don't panic across a package boundary. Errors are the mechanism for anything a caller can
plausibly hit; panic is for programmer error, API misuse, and genuinely unreachable states. If a
package uses panic internally for unwinding,
recoverit inside that package and return an error — a panic must never escape into a caller. MustXis for package initialisation and test helpers, not for input. A helper that stops the program on failure carries theMustprefix (regexp.MustCompile,template.Must) and is called while setting up package-level values from constants the author controls; the same prefix fits a test helper that stops only the current test witht.Fatal(mustParse(t, s)). Anything that can fail on user input, a file, or the network returns an error instead — aMuston a request path turns bad input into a crash.- Fail loudly on impossible dispatch: a
switchover an internal enum/kind gets adefaultthat returns an error (panic only for the genuinely unreachable) — never a silent pass-through that lets a later-added member ride the weakest arm. Pin exhaustiveness with theexhaustivelinter (enabled in the reference config) or a completeness test that iterates the enum. - Boundary errors carry classification, not payload: upstream messages can embed data values —
a database driver quoting the offending stored value, a validator echoing the request body. At a
logging or API boundary, pass the stable class/code (e.g. SQLSTATE) and keep the raw message
internal — the message-content analogue of severing an internal error type with
%v. - Add context at each layer, log once at the boundary. Wrapping at every level and logging at
every level produces duplicate noise — return wrapped, log at the top. Keep the added context
terse:
"new store: %w", not"failed to create new store: %w"— "failed to" states the obvious and piles up (failed to x: failed to y: …) as the error climbs the stack. - Error strings: lowercase, no trailing punctuation (they get wrapped):
"cannot parse %q".
Sources
- Go 1.13 errors — https://go.dev/blog/go1.13-errors;
errors.AsType(Go 1.26) — https://pkg.go.dev/errors#AsType - Code Review Comments (Error Strings, Handle Errors, Indent Error Flow, Don't Panic) — https://go.dev/wiki/CodeReviewComments
- Google Go Style Decisions (Must functions, Returning errors, Error strings, Handle errors, In-band errors, Don't panic) — https://google.github.io/styleguide/go/decisions; Best Practices (Error handling, Panics,
%wplacement) — https://google.github.io/styleguide/go/best-practices - Uber Go Style Guide (Errors) — https://github.com/uber-go/guide
os.File.Closereturns write errors — https://pkg.go.dev/os#File.Close
Decomposition inspired by samber/cc-skills-golang (MIT © 2026 Samuel Berthe); rules grounded in the sources above.