10 KiB
Error Handling
Typed errors, wrap chains, errors.Is / errors.As, no panic in libraries, resource cleanup. Go errors look simple and are full of footguns. This document is the canonical set of moves.
The five rules
- Every error is wrapped on the way up, with
%w, with context. Neverreturn errfrom a non-trivial site. - Compare with
errors.Is, not==. Wrap chains break==. Theerrorlintlinter forbids==on errors. - Cast with
errors.As, not type assertion. Same reason. panicis reserved for programmer errors. Library code never panics on user input or environment failures. Use(T, error).- Resources released via
deferimmediately after acquisition. No "I'll add it later".
Sentinel errors — for invariant programmatic checks
package domain
import "errors"
var (
ErrInvalidEmail = errors.New("domain: invalid email")
ErrInvalidPhone = errors.New("domain: invalid phone")
ErrInvalidAge = errors.New("domain: invalid age")
)
func NewEmail(s string) (Email, error) {
if !emailRe.MatchString(s) {
return Email{}, fmt.Errorf("email %q: %w", s, ErrInvalidEmail)
}
return Email{raw: strings.ToLower(s)}, nil
}
Caller branches on identity:
email, err := domain.NewEmail(input)
if errors.Is(err, domain.ErrInvalidEmail) {
return c.JSON(400, gin.H{"error": "email format"})
}
errors.Is walks the wrap chain. err == domain.ErrInvalidEmail would have failed because fmt.Errorf wrapped it.
Typed errors — when you need structured data
When callers need fields off the error (the offending value, the failing field name, the upstream HTTP status):
type ValidationError struct {
Field string
Value string
Rule string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s=%q failed %s", e.Field, e.Value, e.Rule)
}
// Optional: identity sentinel for errors.Is comparisons
var ErrValidation = errors.New("validation")
func (e *ValidationError) Is(target error) bool {
return target == ErrValidation
}
Caller:
err := svc.Save(ctx, user)
var vErr *ValidationError
if errors.As(err, &vErr) {
// vErr.Field, vErr.Rule are available
c.JSON(400, gin.H{"field": vErr.Field, "rule": vErr.Rule})
return
}
errors.As requires a non-nil pointer-to-pointer. Almost always the type is *ConcreteError. Forgetting the leading * is the most common bug here.
Wrapping — %w is mandatory
// BAD — drops context
return err
// BAD — drops the error chain (errors.Is/As stops working)
return fmt.Errorf("failed to save user: %v", err)
// GOOD — preserves chain via %w
return fmt.Errorf("save user %s: %w", userID, err)
The errorlint linter catches %v where %w was meant. Wrap once per layer, with the minimum useful context:
api/handler: "create user request: %w"
service: "validate inputs: %w"
domain: "email %q: %w"
Each frame adds one fact, not a duplicate. The top-level error message reads as a path: create user request: validate inputs: email "foo": domain: invalid email.
errors.Join — multiple errors at once
// Validate all fields, collect all errors
var errs []error
if _, err := NewEmail(req.Email); err != nil {
errs = append(errs, fmt.Errorf("email: %w", err))
}
if _, err := NewUsername(req.Username); err != nil {
errs = append(errs, fmt.Errorf("username: %w", err))
}
if len(errs) > 0 {
return errors.Join(errs...)
}
errors.Is still walks each joined error. Use when reporting batch validation, not for "wrap two unrelated errors".
Panics — when allowed, when banned
Banned:
- Anywhere a
(T, error)could be returned. - Inside HTTP handlers (gin's
Recoverymiddleware catches them, but you've already lost the error context). - Inside any goroutine that survives request lifetime.
Allowed (with documentation):
- Map literal init at package level:
var statusNames = map[Status]string{...}followed by afunc init()that panics if a const has no name. Catches the bug at startup, not runtime. - The
must*convention for genuinely unrecoverable startup:func MustParseURL(s string) *url.URL { u, err := url.Parse(s) if err != nil { panic(err) } return u } // Use only with literals known at compile time: var defaultAPI = MustParseURL("https://api.example.com") default:case of an exhaustive sealed-interface switch — seetype-patterns.md.
The revive linter rule error-return will flag suspect panic sites; treat them as bugs.
defer for resources — the only safe pattern
func writeReport(path string) (err error) {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = fmt.Errorf("close %s: %w", path, cerr)
}
}()
if _, err := f.Write(data); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
Key points:
defer f.Close()immediately afteros.Create— never further down.- Named return
(err error)so the deferred close can mutate it on close failure. bodycloselinter catches misseddefer resp.Body.Close()for HTTP responses.sqlclosechecklinter catches misseddefer rows.Close()for SQL.
errors.Join for multi-stage cleanup
func process(path string) (err error) {
f, err := os.Open(path)
if err != nil { return err }
defer func() {
err = errors.Join(err, f.Close())
}()
// ... use f ...
return nil
}
When both the main operation AND Close can fail, errors.Join reports both without dropping either.
HTTP error responses — a single funnel
Build one helper, route all handler errors through it:
package httperr
type APIError struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
}
func (e *APIError) Error() string { return e.Code + ": " + e.Message }
var (
NotFound = &APIError{Status: 404, Code: "not_found", Message: "resource not found"}
Unauthorized = &APIError{Status: 401, Code: "unauthorized", Message: "unauthorized"}
BadRequest = &APIError{Status: 400, Code: "bad_request", Message: "bad request"}
Internal = &APIError{Status: 500, Code: "internal", Message: "internal error"}
)
// Wrap a domain error into an API error.
func From(err error) *APIError {
if err == nil { return nil }
var apiErr *APIError
if errors.As(err, &apiErr) { return apiErr }
switch {
case errors.Is(err, domain.ErrInvalidEmail),
errors.Is(err, domain.ErrInvalidUsername):
return &APIError{Status: 400, Code: "validation", Message: err.Error()}
case errors.Is(err, ErrNotFound):
return NotFound
case errors.Is(err, ErrUnauthorized):
return Unauthorized
default:
// unknown — log full chain, return generic
slog.Error("unmapped error", slog.Any("err", err))
return Internal
}
}
func Write(c *gin.Context, err error) {
apiErr := From(err)
c.JSON(apiErr.Status, apiErr)
}
Handlers become trivial:
func (h *Handler) Create(c *gin.Context) {
user, err := h.svc.Create(c.Request.Context(), req)
if err != nil {
httperr.Write(c, err)
return
}
c.JSON(201, user)
}
errgroup — error propagation across goroutines
import "golang.org/x/sync/errgroup"
func fetchAll(ctx context.Context, urls []string) ([][]byte, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // concurrency cap
results := make([][]byte, len(urls))
for i, u := range urls {
g.Go(func() error {
body, err := fetch(ctx, u)
if err != nil {
return fmt.Errorf("fetch %s: %w", u, err)
}
results[i] = body
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
errgroup.WithContextcancels remaining tasks on first error.SetLimitbounds concurrency.- First non-nil error is returned; others are discarded — by design.
See concurrency.md for the full pattern.
Logging errors — structured, once
slog.ErrorContext(ctx, "save user failed",
slog.String("user_id", string(id)),
slog.Any("err", err), // %w chain is fully rendered
)
Log once, at the outermost frame. Logging at every wrap site produces five log lines for one error.
The sloglint linter enforces slog.Any("err", err) over slog.String("err", err.Error()) — the former preserves the chain when handlers walk the value.
Antipatterns
| Bad | Why | Good |
|---|---|---|
_ = err |
Silent ignore | Handle, log, or wrap |
if err != nil { return err } chained 10 deep without wrap |
No path info | Add one fact per layer: fmt.Errorf("step: %w", err) |
panic(err) in HTTP handlers |
Loses error chain, hits gin Recovery | httperr.Write(c, err) |
err.Error() == "some string" |
Brittle, breaks on wrap | Define a sentinel, use errors.Is |
if err == sql.ErrNoRows |
Breaks under wrap | errors.Is(err, sql.ErrNoRows) |
catch-all log.Fatal(err) in library code |
Crashes the caller's process | Return error, let main decide |
| Returning a typed nil pointer wrapped in error interface | Classic "nil != nil" bug | Return explicit nil for the error |
The last bug deserves its own example:
// BUG — returns a non-nil error interface containing a nil concrete type
func bad() error {
var e *MyError = nil
return e // interface wraps nil pointer; errors == nil is FALSE
}
// Caller
if err := bad(); err != nil {
// ← entered, but err.(*MyError) is nil — surprise panic
}
Fix: return explicit nil, not a typed nil. The nilnil linter catches this in (T, error) returns.
Sources
- Go blog "Working with Errors in Go 1.13+": https://go.dev/blog/go1.13-errors
errors.Join(Go 1.20+): https://pkg.go.dev/errors#Join- errorlint: https://github.com/polyfloyd/go-errorlint
- nilaway nil-interface check: https://github.com/uber-go/nilaway