Go error handling
26 production-grade Go skills for Claude Code, Gemini CLI, and opencode.
npx -y skills add muratmirgun/gophers --skill go-error-handlingAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 8 stars8 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when writing, wrapping, inspecting, or logging Go errors. Covers strategy choice (sentinel vs typed vs opaque), wrapping with %w/%v, errors.Is/As/Join, the log-or-return rule, error strings, and panic/recover boundaries. Apply proactively whenever a function returns or accepts an error, even if the user has not asked about error handling.
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
7.0 KB, as published. Nobody here has run it
Go Error Handling
Errors in Go are values. Treat them as part of the API: choose a strategy per failure mode, propagate with wrapping, inspect with errors.Is/As, and handle each error exactly once.
Core Rules
- Errors are values, not exceptions. Return them; do not panic across API boundaries.
- Handle each error exactly once. Either log it or return it — never both.
- The caller decides what is exceptional. Library code returns; binaries (or top-level handlers) decide whether to log, retry, or exit.
- Wrap only when you add real context. A wrap that just repeats the function name is noise. Use
%wto preserve identity;%vto deliberately hide an unstable type.
Strategy Decision
Pick the simplest strategy that meets the caller's needs:
| Strategy | When to use | Example |
|---|---|---|
| Opaque error (default) | Caller only needs to know something failed | errors.New("invalid input") |
| Sentinel error | Caller needs to test for a specific named condition | io.EOF, sql.ErrNoRows |
| Typed error | Caller needs structured fields (path, code, retry-after) | *os.PathError, *url.Error |
| Joined errors | A single operation produced several independent failures | errors.Join(errA, errB) |
Read references/strategy-decision.md when the caller's needs are unclear or when migrating between strategies without breaking callers.
Writing Errors
Strings
- Lowercase, no trailing punctuation. Errors are composed:
fmt.Errorf("write %s: %w", path, err)reads as one sentence. - Be specific.
"open config: permission denied"beats"failed to open file". - Do not include the function name. Stack context is added by wrapping at each layer.
Creating
// Opaque — the caller only checks != nil
return errors.New("invalid character in token")
// Sentinel — exported, package-level, named ErrXxx
var ErrNotFound = errors.New("user: not found")
// Typed — when callers need structured fields
type ValidationError struct {
Field string
Rule string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s violates %s", e.Field, e.Rule)
}
Wrapping and Inspection
Wrap with %w to add context while preserving identity
if err := db.Get(id); err != nil {
return fmt.Errorf("loading user %d: %w", id, err)
}
Inspect with errors.Is (identity) and errors.As (type)
if errors.Is(err, sql.ErrNoRows) { /* handled */ }
var ve *ValidationError
if errors.As(err, &ve) {
return reply.BadRequest(ve.Field)
}
Never compare error strings (err.Error() == "...") — strings are not stable API.
Join independent failures
errs := errors.Join(
validate(name),
validate(email),
validate(password),
)
if errs != nil {
return errs // errors.Is/As walks both branches
}
Read references/wrapping-vs-shadowing.md when deciding between
%w(expose) and%v(hide), or when wrapping would leak an implementation detail.
Error Flow
Log or return — not both
// Bad: caller will log it again, producing duplicate lines
if err := svc.Do(ctx); err != nil {
slog.ErrorContext(ctx, "svc.Do failed", "err", err)
return err
}
// Good: log only at the boundary that decides the request is done
if err := svc.Do(ctx); err != nil {
return fmt.Errorf("doing svc work: %w", err)
}
The HTTP handler / job runner / main is the only layer that logs.
Reduce nesting with guard clauses
// Bad
if err == nil {
if x, ok := f(); ok {
return x, nil
}
}
return zero, err
// Good
if err != nil {
return zero, err
}
x, ok := f()
if !ok {
return zero, errSomething
}
return x, nil
Panic and Recover
panic is for programmer errors (impossible states) and package initialization. It is never the right way to return a normal failure.
// Acceptable: invariants the type guarantees
func (q *Queue) MustEnqueue(v T) { if err := q.Enqueue(v); err != nil { panic(err) } }
// Acceptable: recover at the goroutine boundary so one bad request cannot kill the server
defer func() {
if r := recover(); r != nil {
log.Error("panic recovered", "value", r, "stack", debug.Stack())
http.Error(w, "internal error", 500)
}
}()
Do not use recover to convert panics into errors as normal control flow.
For custom error types, implement Unwrap() error (or Unwrap() []error in Go 1.20+) so errors.Is/As can reach the cause. See references/wrapping-vs-shadowing.md.
Anti-Patterns
| Anti-pattern | Why it hurts | Do this instead |
|---|---|---|
return errors.New(err.Error()) | Drops identity; errors.Is breaks | return fmt.Errorf("ctx: %w", err) |
if err.Error() == "EOF" | String matching against unstable text | errors.Is(err, io.EOF) |
_ = doThing() | Silently swallows failures | Handle, log at boundary, or document why |
Returning *MyError (concrete pointer) | Typed-nil trap; non-nil interface | Return error (see references/typed-nil-trap.md) |
| Logging then returning the same error | Duplicate log lines, no single source of truth | Log only at the top boundary |
| Wrapping at every layer with no new info | "a: b: c: d: real error" chains | Drop the wrap and return err |
Verification Checklist
Before finishing an error-handling change:
- No
err.Error()string comparisons - All wrapping uses
%w(or%vis intentional and commented) - Functions return the
errorinterface, not concrete types - Each error is logged at most once (at the request/job boundary)
- Sentinels are package-level
var ErrXxx = errors.New(...) - Typed errors expose only the fields callers actually need
References
- references/strategy-decision.md — picking opaque vs sentinel vs typed
- references/wrapping-vs-shadowing.md —
%wvs%vdecisions - references/typed-nil-trap.md — why returning
*MyErrbreaks== nil