Go Error Handling Patterns
Idiomatic error handling in Go: sentinel values, wrapping with %w, errors.Is and As, custom types, and the structural patterns that keep code readable.
What you'll learn
- ✓Why Go returns errors instead of throwing them
- ✓Wrapping errors with fmt.Errorf and %w
- ✓Inspecting errors with errors.Is and errors.As
- ✓When to define sentinel errors vs custom types
- ✓Patterns for logging and propagating errors cleanly
Prerequisites
- •Basic familiarity with Go syntax
What and Why
Go does not have exceptions. Functions that can fail return an error as their last value, and the caller is expected to check it. This sounds verbose, but it makes failure paths visible at every call site and lets you decide locally how to recover, propagate, or annotate.
Modern Go (1.13+) added errors.Is, errors.As, and %w wrapping. Together they make sentinel and structured errors composable without losing context.
Mental Model
An error is just an interface: type error interface { Error() string }. Anything that has an Error() method is an error. Wrapping turns a chain of errors into a linked list where each layer adds context but preserves the underlying cause.
os.Open -> *PathError ("open file: no such file or directory")
|
fmt.Errorf("loading config: %w", err) -- wraps PathError
|
fmt.Errorf("starting service: %w", err) -- wraps the above
errors.Is(top, fs.ErrNotExist) // true
errors.As(top, &pathErr) // true, fills *PathError
errors.Unwrap(top) // peels one layer Hands-on Example
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
var ErrInvalidConfig = errors.New("invalid config")
type ParseError struct {
Line int
Msg string
}
func (e *ParseError) Error() string {
return fmt.Sprintf("parse error line %d: %s", e.Line, e.Msg)
}
func loadConfig(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening %s: %w", path, err)
}
defer f.Close()
// pretend parsing failed on line 7
return fmt.Errorf("reading %s: %w", path, &ParseError{Line: 7, Msg: "missing key"})
}
func main() {
err := loadConfig("missing.yaml")
if err == nil { return }
if errors.Is(err, fs.ErrNotExist) {
fmt.Println("hint: create the file first")
}
var pe *ParseError
if errors.As(err, &pe) {
fmt.Printf("parse failed on line %d\n", pe.Line)
}
fmt.Println("full chain:", err)
}
Three patterns are doing real work here. The sentinel ErrInvalidConfig lets callers compare with errors.Is. The ParseError type carries structured data (line, message). %w preserves the chain so both inspections work even though the outer error is just a string-formatted wrapper.
Common Pitfalls
- Comparing errors with
==: works only for sentinels at the top of the chain. Once you wrap with%w, the comparison fails. Useerrors.Iseverywhere. - Using
%vwhen you meant%w:%vformats but does not wrap. The chain is broken anderrors.Iscannot see through. - Bare
if err != nil { return err }: callers receive context-free messages. Wrap with the action that failed:fmt.Errorf("fetching user %d: %w", id, err). - Logging then returning: a single error often gets logged at every layer. Pick one: log at the boundary, or return up the stack. Doing both creates noise.
panicfor ordinary failure: panics are for unrecoverable bugs, not for “file not found.” If a deferred recover catches it, you are reinventing exceptions badly.
Practical Tips
Design a small set of sentinel errors for cases callers will branch on. Keep them in a package-level var block:
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrForbidden = errors.New("forbidden")
)
Use custom error types when you need structured data. Implement Error() plus optional Unwrap() if you want to chain another cause.
When working with multiple errors (e.g., closing several resources), errors.Join (Go 1.20+) bundles them into one error that satisfies errors.Is for any of its parts.
Annotate each layer with what it was trying to do, not what went wrong. The leaf error already says “no such file.” The middle layers should add the higher-level intent.
Use errors.Is(err, context.Canceled) and errors.Is(err, context.DeadlineExceeded) to handle cancellation gracefully. They are the most common errors you ignore-or-handle in network code.
Wrap-up
Go’s error model is verbose by design. Each if err != nil block forces you to confront the failure mode at the call site. With %w wrapping and the errors.Is/errors.As helpers, that verbosity becomes a precise tool: errors carry exactly the context callers need to decide what to do. Stick to sentinels for branchable cases, custom types for structured data, and wrap consistently. The result is failure handling that reads almost as well as the happy path.
Related articles
- Go Go defer, panic, and recover Tutorial
Learn how Go's defer, panic, and recover work together, when to use each, and how to write resilient code without abusing exceptions.
- Go Error Handling in Go: errors.Is, errors.As, and Wrapping
A practical guide to Go error handling: the error interface, sentinel values, custom types, wrapping with fmt.Errorf %w, and errors.Is and errors.As.
- Rust Rust Error Handling with Result
How idiomatic Rust handles errors with Result and the ? operator: propagation, conversion, custom error types, and when to use anyhow or thiserror.
- Go Go Build Tags Explained
Use Go build tags to include or exclude files per OS, architecture, or custom condition. Learn the new //go:build syntax, common patterns, and how tags interact with the test runner.