Go Intermediate #2 Error-Handling Patterns
If you covered the basics of error in Basics #4, this post builds on them with practical patterns — wrapping, inspection, custom types, and where panic fits.
Error wrapping — %w
#
The %w verb in fmt.Errorf is the key tool here. It builds a new error that wraps the original error inside.
func readConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read config (%s): %w", path, err)
}
// ...
return nil
}This new error:
- Has a more descriptive message (
failed to read config (...): open ...: no such file...) - Holds the original error inside — pullable via
errors.Is/errors.As
%v vs %w difference
#
err1 := fmt.Errorf("failed: %v", origErr) // string concat only — original error info lost
err2 := fmt.Errorf("failed: %w", origErr) // preserves the original + message%v is plain string concatenation. %w preserves the original inside. When propagating an error upward, almost always use %w.
errors.Is — comparing sentinel errors
#
When comparing against a predefined error.
import "errors"
var ErrNotFound = errors.New("not found")
func lookup(key string) (string, error) {
if key == "" {
return "", fmt.Errorf("lookup: %w", ErrNotFound)
}
return "value", nil
}
func main() {
_, err := lookup("")
if errors.Is(err, ErrNotFound) {
fmt.Println("not found")
}
}errors.Is walks the error chain to compare. It checks the original error inside a wrapped error too.
What’s the difference from err == ErrNotFound? Plain == doesn’t see inside a wrapped error. When you’ve wrapped with %w, errors.Is is the right answer.
errors.As — extracting custom error types
#
errors.As checks whether an error is an instance of a specific type, and if so, stores it in a variable of that type.
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}func processForm(form Form) error {
if form.Email == "" {
return &ValidationError{Field: "email", Message: "required"}
}
return nil
}
func main() {
err := processForm(form)
var verr *ValidationError
if errors.As(err, &verr) {
fmt.Printf("validation failed for field %s: %s\n", verr.Field, verr.Message)
}
}The key is errors.As(err, &verr):
- First argument — the error to inspect
- Second argument — a pointer to a pointer to receive the result
When walking the chain finds an error of that type — it stores it in verr and returns true. After that you can access verr’s fields (Field, Message).
Custom error types — when to make them #
They fit in cases like:
- When you need extra information — metadata such as Field, StatusCode, Retryable
- When the caller has to handle them differently — show validation errors to the user, log DB errors and present a generic message
type HTTPError struct {
StatusCode int
Message string
URL string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d %s (%s)", e.StatusCode, e.Message, e.URL)
}
// caller
var httpErr *HTTPError
if errors.As(err, &httpErr) {
if httpErr.StatusCode == 404 {
// different handling
}
}Sentinel errors — simple comparison #
Errors defined as values (no metadata).
var (
ErrNotFound = errors.New("not found")
ErrPermission = errors.New("permission denied")
ErrAlreadyExists = errors.New("already exists")
)
if errors.Is(err, ErrNotFound) {
// ...
}A pattern you’ll see often in the standard library — io.EOF, sql.ErrNoRows, etc.
Sentinel vs custom type — which when #
| sentinel | custom type | |
|---|---|---|
| Need extra info | none | yes |
| Compare with | errors.Is | errors.As |
| Short to define | ✓ | needs field definitions |
| Compatibility when exposed | simple | exposes interface/type |
For simple branching, a sentinel; if metadata is needed, a custom type.
Return as-is or wrap? #
Guidelines:
// Forward an error upward → wrap (add a clue about where it happened)
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config: %w", err)
}
// Or — pass through if the message is already clear to the caller
if err != nil {
return err
}A common guideline — wrap at layer boundaries, OK to forward as-is within the same layer.
// bad — wrapping in too many places
return fmt.Errorf("foo: %w", err)
return fmt.Errorf("bar: %w", err)
return fmt.Errorf("baz: %w", err)
// result: foo: bar: baz: real errorWrapping everywhere makes messages long and meaning fuzzy. Wrap only at meaningful boundaries (function entry, package boundary) for cleaner output.
Error-comparison pitfall — == vs errors.Is
#
var ErrFoo = errors.New("foo")
func wrap() error {
return fmt.Errorf("wrap: %w", ErrFoo)
}
err := wrap()
err == ErrFoo // false ← because it's wrapped
errors.Is(err, ErrFoo) // true ← chain checkIn the basics, a plain if err != nil check was enough, but for inspecting sentinel errors you should almost always use errors.Is. That’s the modern standard.
errors.Join — bundling multiple errors
#
A tool from Go 1.20+. When multiple errors happen at once.
import "errors"
func validate(form Form) error {
var errs []error
if form.Name == "" {
errs = append(errs, fmt.Errorf("name missing"))
}
if form.Email == "" {
errs = append(errs, fmt.Errorf("email missing"))
}
if len(form.Password) < 8 {
errs = append(errs, fmt.Errorf("password must be at least 8 chars"))
}
return errors.Join(errs...)
}
err := validate(form)
if err != nil {
fmt.Println(err) // all errors joined with newlines
}errors.Join automatically filters out nil errors, and returns nil if all are nil.
It also works with errors.Is — if the bundle contains ErrFoo, errors.Is(joined, ErrFoo) is true.
Panic — only for truly abnormal situations #
Panic looks like throw in other languages — but in Go you almost never use it.
func divide(a, b int) int {
if b == 0 {
panic("division by zero")
}
return a / b
}When panic happens:
- The function exits immediately
- defers run (LIFO)
- The caller exits, then its caller… eventually the whole program ends
Go convention — almost always return error. Panic only fits in cases like:
- Truly broken state where the program shouldn’t continue — out of memory, broken invariants
- Internal to your package — not exposed to outside callers (catch with recover and convert to error)
recover — surviving a panic
#
Calling recover inside a defer function catches a panic.
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
return divide(a, b), nil
}recover() returns the panic value if a panic is in progress and stops it. It’s only meaningful inside a deferred function.
It is useful at library boundaries — keeping internal panics from leaking out to callers. It’s rarely seen in ordinary application code.
errors.Unwrap — unwrap directly
#
Sometimes you want to peel a wrapped error one layer.
err := fmt.Errorf("outer: %w", fmt.Errorf("middle: %w", io.EOF))
inner := errors.Unwrap(err) // middle: EOF
inner2 := errors.Unwrap(inner) // EOF
inner3 := errors.Unwrap(inner2) // nil (nothing further inside)You’ll rarely use this directly — errors.Is / As handle it for you. Only for debugging or special cases.
Errors you meet often in the standard library #
io.EOF // end of input
io.ErrUnexpectedEOF // unexpected end
sql.ErrNoRows // no SQL rows
context.Canceled // context canceled
context.DeadlineExceeded // timeout
os.ErrNotExist // file not found
fs.ErrPermission // no permissionInspect these with errors.Is(err, io.EOF)-style checks. Errors from standard library functions usually wrap these sentinels.
Common patterns #
1) Wrap with context at function entry #
func processOrder(orderID string) error {
if err := validateOrder(orderID); err != nil {
return fmt.Errorf("processOrder: %w", err)
}
if err := saveOrder(orderID); err != nil {
return fmt.Errorf("processOrder: %w", err)
}
return nil
}Function name as a prefix — easier to trace the call stack.
2) Early return #
data, err := fetch()
if err != nil { return err }
parsed, err := parse(data)
if err != nil { return err }
if err := save(parsed); err != nil { return err }Flat instead of deeply nested ifs. Almost the standard shape of Go code.
3) Some errors are ignored #
defer file.Close() // close failures are usually ignored (the success carries little signal)
f.WriteString("hi") // ignore the returned error (minor case)Explicit ignore with _ = ... is also possible (to avoid linter warnings).
_ = file.Close()Wrap-up #
What we covered:
%wfor error wrapping — preserves the originalerrors.Isfor sentinel-error comparison (chain-aware)errors.Asfor extracting custom types- Custom type when extra info is needed; sentinel for simple branching
- Wrap at function/package boundaries; avoid overwrapping everywhere
errors.Joinfor bundling (Go 1.20+)- Panic only for truly abnormal situations — return error otherwise
recovermostly at library boundaries
In the next post (#3 Goroutines and Channels Intro) we cover Go’s most powerful weapon — concurrency. We’ll work through lightweight goroutines and channels from the start.