About go-error-handling
go-error-handling is a Go development skill for developers and coding agents, describing patterns for creating, wrapping, propagating, and publishing errors. It emphasizes actionable context, error-chain behavior, and handling errors at system boundaries.
Key features
- 01Sentinel and custom error patterns
- 02Contextual wrapping and error chains
- 03Rules for logging, returning, and panics
- 04Boundary translation and stable messages
- 05go vet and golangci-lint verification
Use cases
- 01Designing errors callers can identify
- 02Propagating failures with useful context
- 03Reviewing error handling at API boundaries
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ eduardo-sl/ go-agent-skills --skill go-error-handlingAfter installing, type/go-error-handlingin Claude Code to confirm it loaded.
Bundled filesreferences/patterns-and-boundaries.md
Details
From the project READMEGo Error Handling
Go's explicit error handling is a feature, not a limitation. These patterns ensure errors are informative, actionable, and properly propagated.
Community default. A project skill or agent-config rule that explicitly supersedes
go-error-handlingtakes precedence over this one.
Detailed reference material, loaded on demand:
references/patterns-and-boundaries.md— checking patterns,Ismethods, translating errors at the HTTP boundary, low-cardinality logging, the Go 1.27 vet check on%wwith pointer errors.
Read a reference file only when the summary below is not enough.
1. Error Decision Tree
When creating or returning an error, follow this tree:
- Simple, no extra context needed? →
errors.New("message") - Need to add context to existing error? →
fmt.Errorf("doing X: %w", err) - Caller needs to detect this error? → Sentinel
varor custom type - Error carries structured data? → Custom type implementing
error - Propagating from downstream? → Wrap with
%wand add context
2. Sentinel Errors
Use package-level var for errors that callers need to check:
// ✅ Good — exported sentinel error
var (
ErrNotFound = errors.New("user: not found")
ErrUnauthorized = errors.New("user: unauthorized")
)
// Naming convention: Err + Description
// Prefix with package context in the message
Callers check with errors.Is:
if errors.Is(err, user.ErrNotFound) {
// handle not found
}
NEVER compare errors with ==. Always use errors.Is().
3. Custom Error Types
When errors need to carry structured information:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: field %s: %s", e.Field, e.Message)
}
// Callers extract it from the chain — Go 1.26+:
if valErr, ok := errors.AsType[*ValidationError](err); ok {
log.Printf("invalid field: %s", valErr.Field)
}
// Before Go 1.26:
var valErr *ValidationError
if errors.As(err, &valErr) {
log.Printf("invalid field: %s", valErr.Field)
}
A custom type that wraps a cause must expose it, or errors.Is/errors.As
stop at your type and never see the error underneath:
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string { return "query " + e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err } // keeps the chain walkable
4. Error Wrapping
ALWAYS add context when propagating errors up the stack.
Use %w to preserve the error chain:
// ✅ Good — context added, chain preserved
func getUser(id int64) (*User, error) {
row, err := db.QueryRow(ctx, query, id)
if err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
// ...
}
// ❌ Bad — no context
return nil, err
// ❌ Bad — chain broken, callers can't errors.Is/As
return nil, fmt.Errorf("failed: %v", err)
When NOT to use %w
Use %v instead of %w when you explicitly want to break the error chain,
preventing callers from depending on internal implementation errors:
// Intentionally hiding internal DB error from public API
return nil, fmt.Errorf("user lookup failed: %v", err)
5. Handle Errors Exactly Once
An error should be either logged OR returned, never both:
// ✅ Good — return the error, let caller decide
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("load config %s: %w", path, err)
}
// ...
}
// ❌ Bad — log AND return (error handled twice)
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
log.Printf("failed to read config: %v", err) // handled once
return nil, err // handled again
}
// ...
}
The rule: the component that decides what to do about the error is the one that logs/metrics it. Everyone else wraps and returns.
6. Error Naming Conventions
// Sentinel errors: Err prefix
var ErrNotFound = errors.New("not found")
// Error types: Error suffix
type NotFoundError struct { ... }
type ValidationError struct { ... }
// Error messages: lowercase, no punctuation, no "failed to" prefix
// Include context: "package: action: detail"
errors.New("auth: token expired")
fmt.Errorf("user: get by id %d: %w", id, err)
7. Panic Rules
Panic is NOT error handling. Use panic only when:
- Program initialization fails and cannot continue (
template.Must, flag parsing) - Programmer error that should never happen (violated invariant)
- Nil dereference that indicates a bug, not a runtime condition
In tests, use t.Fatal / t.FailNow, never panic.
In HTTP handlers and middleware, recover from panics at the boundary to prevent one request from crashing the server.
8. Error Checking Patterns
- Scope the error to the
ifwhen nothing else needs it:if err := do(); err != nil { return fmt.Errorf("do: %w", err) }. - A deferred wrap on a named
errresult adds one prefix to every return path of a long function. - Collect independent failures (validation, parallel work) and return
errors.Join(errs...)—errors.Is/Assee every joined error.
9. Errors at Boundaries
- Publishing an error is a contract. Inside the module, wrap with
%w. At a boundary you publish (an exported library API, an RPC),%wmakes the wrapped error part of your API;%vor a new error keeps it private. - Translate before it reaches a user. Map domain errors to a status
and a stable message at the handler, and log the original once with its
chain.
err.Error()in a response leaks SQL, paths and hostnames. - Keep logged messages low-cardinality. The stable text is the message; IDs, paths and counts go in structured attributes so the log backend can group identical failures.
- Panics do not cross goroutines. A
recoveronly works in the goroutine that panicked — see go-concurrency-review. - Implement
Is(target error) boolon a type to leterrors.Ismatch a whole family of errors against one sentinel.
Examples for sections 8 and 9: references/patterns-and-boundaries.md.
Executable Verification
After writing or reviewing error handling, run the linters that prove the rules above (skip any that is not installed and note it):
go vet ./... # includes printf %w misuse
golangci-lint run --enable errcheck,errorlint # unchecked errors, %v-vs-%w,
# == comparisons on errors
Verification Checklist
- No
_discarding errors (unless explicitly justified with comment) - Every
fmt.Errorfwrapping uses%w(or%vwith documented reason) - Sentinel errors use
var Err...naming - Custom error types implement
errorinterface - Callers use
errors.Is/errors.As(errors.AsTypeon Go 1.26+), never==or type assertion - Custom types that wrap a cause implement
Unwrap() error - No log-and-return patterns
- Error messages are lowercase, contextual, chain-friendly
- No
err.Error()text in responses to users; logged messages are stable, details are attributes