Go Intermediate #2 Error-Handling Patterns

8 min read

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.

basic wrapping
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 #

%v vs %w
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.

errors.Is
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.

custom error type
type ValidationError struct {
	Field   string
	Message string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
extracting with errors.As
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:

  1. When you need extra information — metadata such as Field, StatusCode, Retryable
  2. When the caller has to handle them differently — show validation errors to the user, log DB errors and present a generic message
HTTP error pattern
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).

sentinel errors
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 #

sentinelcustom type
Need extra infononeyes
Compare witherrors.Iserrors.As
Short to defineneeds field definitions
Compatibility when exposedsimpleexposes interface/type

For simple branching, a sentinel; if metadata is needed, a custom type.

Return as-is or wrap? #

Guidelines:

right pattern
// 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.

overwrap — avoid
// 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 error

Wrapping 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 #

limitation of ==
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 check

In 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.

multiple errors
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.

panic
func divide(a, b int) int {
	if b == 0 {
		panic("division by zero")
	}
	return a / b
}

When panic happens:

  1. The function exits immediately
  2. defers run (LIFO)
  3. 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.

recover
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.

Unwrap
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 #

standard sentinel errors
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 permission

Inspect 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 #

wrap at 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 #

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 #

intentional ignore
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).

explicit ignore
_ = file.Close()

Wrap-up #

What we covered:

  • %w for error wrapping — preserves the original
  • errors.Is for sentinel-error comparison (chain-aware)
  • errors.As for extracting custom types
  • Custom type when extra info is needed; sentinel for simple branching
  • Wrap at function/package boundaries; avoid overwrapping everywhere
  • errors.Join for bundling (Go 1.20+)
  • Panic only for truly abnormal situations — return error otherwise
  • recover mostly 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.

X