Skip to content
C Codeloom
Go

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.

·4 min read · By Codeloom
Intermediate 9 min read

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
Error wrapping flow

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. Use errors.Is everywhere.
  • Using %v when you meant %w: %v formats but does not wrap. The chain is broken and errors.Is cannot 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.
  • panic for 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.