Effective Error Handling Patterns

Go treats errors as ordinary values instead of exceptions: any function that can fail simply returns an extra error result alongside its normal output, and the caller decides what to do next. This keeps control flow explicit and visible right in the code you read, but it also means the quality of a Go program depends heavily on how carefully you check, wrap, and communicate those errors. This lesson covers the full toolkit for handling errors well in modern Go: sentinel errors, error wrapping with %w, errors.Is and errors.As, custom error types, and the patterns experienced Go developers rely on to keep error handling both correct and readable.

Overview: How Go Handles Errors

The error type is not a special language construct — it is a plain built-in interface, conceptually defined as:

type error interface {
	Error() string
}

Any type with a method matching exactly this signature — Error() string — automatically satisfies the error interface. There is no implements keyword and no explicit declaration of intent, the way you might write in Java or C#. This is Go’s implicit interface satisfaction: the compiler checks structurally, at compile time, whether a type’s method set matches an interface, and if it does, the type can be used anywhere that interface is expected. That is why errors.New("boom"), a custom *MyError struct with an Error() method, and a value returned from the standard library can all be assigned to the same error variable.

Go deliberately has no exception mechanism for routine failures. A file that doesn’t exist, a network call that times out, a string that fails to parse — these are expected, recoverable outcomes, and Go’s designers chose to make them visible in a function’s signature and at its call sites rather than hiding them behind an invisible, hard-to-trace throw/catch path. panic and recover do exist, but they are reserved for programming errors and truly unrecoverable situations — not for things like “the file wasn’t found.”

Under the hood, an error variable — like every interface value in Go — is really a two-word pair: a pointer to type information describing the concrete value it holds, and the data itself (a pointer, for pointer types). This has a surprising consequence: an interface value is only == nil when both the type and the value slots are nil. If you store a nil pointer of a concrete type — say, a nil *MyError — inside an error variable, the interface’s type slot gets filled in (*MyError) even though the value slot is nil. The resulting interface is not equal to nil. This “typed nil” trap catches even experienced Go programmers and is covered in Common Mistakes below.

Since Go 1.13, the standard errors package and fmt.Errorf support error wrapping: one error can carry a reference to the error that caused it, forming a chain. errors.Is and errors.As walk that chain so you can check for a specific error, or extract a specific error type, even after it has been wrapped several layers deep by intermediate functions. Go 1.20 added errors.Join, which combines multiple independent errors into a single value that errors.Is/errors.As can still inspect — handy when several independent checks fail at once.

Syntax

There is no special syntax for errors beyond ordinary Go — errors are just values of type error, checked with a plain if. The idiomatic shape looks like this:

if err != nil {
	// handle the error here: log it, wrap it with more
	// context, return it to the caller, or in rare cases recover
}

Any function that can fail returns its normal result alongside an error as the last return value, by convention:

func doWork(input string) (Result, error) {
	if input == "" {
		return Result{}, errors.New("input must not be empty")
	}
	// ... perform the real work ...
	return Result{Value: input}, nil
}
Function Purpose
errors.New(msg) Creates a new error from a plain string. Good for sentinel errors.
fmt.Errorf(format, ...) Creates a formatted error; use %w to wrap another error inside it.
errors.Is(err, target) Reports whether err — or anything it wraps — matches target.
errors.As(err, &target) Finds the first error in the chain matching a given type and assigns it to target.
errors.Unwrap(err) Returns the error directly wrapped by err, or nil.
errors.Join(errs...) Combines multiple errors into one inspectable value (Go 1.20+).

Examples

Example 1: A Basic Error Return with a Sentinel Error

The simplest pattern: define a package-level sentinel error with errors.New, return it when something goes wrong, and check it immediately after the call.

package main

import (
	"errors"
	"fmt"
)

var ErrDivideByZero = errors.New("division by zero")

func divide(a, b float64) (float64, error) {
	if b == 0 {
		return 0, ErrDivideByZero
	}
	return a / b, nil
}

func main() {
	result, err := divide(10, 2)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("result:", result)

	_, err = divide(10, 0)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
}

Output:

result: 5
error: division by zero

divide returns the shared ErrDivideByZero value whenever the divisor is zero. The first call succeeds and prints the result; the second call fails, the if err != nil check catches it immediately, and the function returns early rather than continuing with an invalid computation.

Example 2: Wrapping an Error and Extracting a Custom Type

Real programs pass errors up through several layers of function calls. Each layer can add context with fmt.Errorf and %w without losing the original error underneath. A caller further up the chain can still recover the original type with errors.As.

package main

import (
	"errors"
	"fmt"
)

type NotFoundError struct {
	Name string
}

func (e *NotFoundError) Error() string {
	return fmt.Sprintf("%s not found", e.Name)
}

func findUser(name string) error {
	return &NotFoundError{Name: name}
}

func loadProfile(name string) error {
	err := findUser(name)
	if err != nil {
		return fmt.Errorf("loading profile for %s: %w", name, err)
	}
	return nil
}

func main() {
	err := loadProfile("alice")
	if err == nil {
		fmt.Println("no error")
		return
	}

	fmt.Println("full error:", err)

	var nf *NotFoundError
	if errors.As(err, &nf) {
		fmt.Println("missing user:", nf.Name)
	}
}

Output:

full error: loading profile for alice: alice not found
missing user: alice

findUser returns a *NotFoundError. loadProfile wraps it with %w, producing an error whose message concatenates both layers but which still remembers the original *NotFoundError underneath. errors.As walks the wrap chain, finds the *NotFoundError, and assigns it to nf so the caller can read its Name field directly — something a plain string comparison could never do.

Example 3: Checking for a Specific Error with errors.Is

When you only need to know whether a particular sentinel error occurred anywhere in the chain — not extract structured data from it — errors.Is is the right tool.

package main

import (
	"errors"
	"fmt"
)

var ErrPermissionDenied = errors.New("permission denied")

func openResource(allowed bool) error {
	if !allowed {
		return fmt.Errorf("opening resource: %w", ErrPermissionDenied)
	}
	return nil
}

func main() {
	err := openResource(false)
	if errors.Is(err, ErrPermissionDenied) {
		fmt.Println("access blocked:", err)
	} else {
		fmt.Println("unexpected error:", err)
	}
}

Output:

access blocked: opening resource: permission denied

openResource wraps ErrPermissionDenied with extra context. errors.Is doesn’t compare the top-level error directly against ErrPermissionDenied — it unwraps layer by layer until it finds a match or runs out of chain, so the check succeeds even though the two values are not literally identical.

How It Works Step by Step

Understanding what fmt.Errorf("...: %w", err) actually produces makes wrapping much less mysterious:

  • When you use %w in a format string, fmt.Errorf returns a value of an internal type that stores both the formatted message and a reference to the wrapped error, and that type implements an Unwrap() error method returning the original error.
  • errors.Is(err, target) starts at err and repeatedly compares it to target with == (or calls an Is(error) bool method if the error defines one). If there’s no match, it calls Unwrap() on the current error to move one link down the chain, and repeats until it finds a match or Unwrap() returns nil.
  • errors.As(err, target) works the same way, except at each link it checks whether that error’s concrete type is assignable to what target points to; on the first match it assigns the error into target and returns true.
  • This is why wrapping with %w instead of the plain %v verb matters: %v only formats the message into a new string with no link back to the original error, breaking the chain that errors.Is/errors.As depend on.
  • A function can wrap more than one error at once by passing multiple %w verbs (Go 1.20+), or by building a value with errors.Join; both produce an error whose Unwrap() returns a slice, which errors.Is/errors.As also know how to walk.

Common Mistakes

Mistake 1: Discarding the Error

Using _ to throw away an error return is the single most common Go bug. The zero value of the success result silently stands in for the failure, with no warning.

package main

import (
	"fmt"
	"strconv"
)

func main() {
	n, _ := strconv.Atoi("abc")
	fmt.Println(n * 2)
}

Output:

0

strconv.Atoi("abc") fails because "abc" isn’t a number, so it returns 0 and a non-nil error — but the error is discarded, so the program silently computes with 0 as if that were the real parsed value. Checking the error surfaces the real problem:

package main

import (
	"fmt"
	"strconv"
)

func main() {
	n, err := strconv.Atoi("abc")
	if err != nil {
		fmt.Println("invalid number:", err)
		return
	}
	fmt.Println(n * 2)
}

Output:

invalid number: strconv.Atoi: parsing "abc": invalid syntax

Mistake 2: Comparing a Wrapped Error with ==

Once an error has been wrapped, it is a new value — comparing it directly against a sentinel with == almost always fails, even though the sentinel is right there in the chain.

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func lookup() error {
	return fmt.Errorf("lookup: %w", ErrNotFound)
}

func main() {
	err := lookup()
	if err == ErrNotFound {
		fmt.Println("not found")
	} else {
		fmt.Println("unexpected:", err)
	}
}

Output:

unexpected: lookup: not found

err is now a wrapper value, not ErrNotFound itself, so == is false and the branch that should have recognized “not found” is skipped. Use errors.Is, which understands wrapping, instead:

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func lookup() error {
	return fmt.Errorf("lookup: %w", ErrNotFound)
}

func main() {
	err := lookup()
	if errors.Is(err, ErrNotFound) {
		fmt.Println("not found")
	} else {
		fmt.Println("unexpected:", err)
	}
}

Output:

not found

Mistake 3: Shadowing err with :=

Using := inside a nested block silently creates a brand-new local err instead of assigning to the outer one, so the outer variable never learns that anything went wrong.

package main

import "fmt"

func step(n int) (int, error) {
	if n < 0 {
		return 0, fmt.Errorf("negative input: %d", n)
	}
	return n * 2, nil
}

func main() {
	var err error
	n := -5

	if n != 0 {
		result, err := step(n) // shadows the outer err
		fmt.Println("result:", result)
		_ = err
	}

	if err != nil {
		fmt.Println("failed:", err)
	} else {
		fmt.Println("succeeded")
	}
}

Output:

result: 0
succeeded

Even though step(-5) fails, the program reports “succeeded”, because the inner result, err := step(n) declared a fresh err scoped to the if block — the outer err was never touched. Reusing the outer variable with plain = fixes it:

package main

import "fmt"

func step(n int) (int, error) {
	if n < 0 {
		return 0, fmt.Errorf("negative input: %d", n)
	}
	return n * 2, nil
}

func main() {
	var err error
	n := -5

	if n != 0 {
		var result int
		result, err = step(n) // reuses the outer err, no shadowing
		fmt.Println("result:", result)
	}

	if err != nil {
		fmt.Println("failed:", err)
	} else {
		fmt.Println("succeeded")
	}
}

Output:

result: 0
failed: negative input: -5

Mistake 4: Returning a Typed Nil as an error

This one trips up even experienced Go developers because the code looks completely reasonable. Returning a nil pointer of a concrete error type through an error-typed return value produces a non-nil interface.

package main

import "fmt"

type MyError struct {
	msg string
}

func (e *MyError) Error() string {
	return e.msg
}

func doSomething(fail bool) error {
	var err *MyError
	if fail {
		err = &MyError{msg: "something went wrong"}
	}
	return err
}

func main() {
	err := doSomething(false)
	fmt.Printf("err == nil: %v, dynamic type: %T\n", err == nil, err)
}

Output:

err == nil: false, dynamic type: *main.MyError

Even though fail is false and the local *MyError pointer is nil, doSomething returns that nil pointer through an error-typed return, which fills in the interface’s type slot with *MyError. The interface is no longer == nil, so any caller doing if err != nil is fooled into thinking the call failed. The fix is to return the literal nil when there is no error, rather than a nil-valued named variable:

package main

import "fmt"

type MyError struct {
	msg string
}

func (e *MyError) Error() string {
	return e.msg
}

func doSomething(fail bool) error {
	if fail {
		return &MyError{msg: "something went wrong"}
	}
	return nil // return the literal nil, not a nil-valued *MyError
}

func main() {
	err := doSomething(false)
	fmt.Printf("err == nil: %v\n", err == nil)
}

Output:

err == nil: true

Best Practices

  • Check every error immediately after the call that can produce it — don’t collect several calls and check errors later, since the state after a failure is often unusable.
  • Add context when wrapping with fmt.Errorf and %w so the resulting chain reads like a story, but avoid wrapping the same error redundantly at every single layer.
  • Reach for a sentinel error (errors.New) or a custom error type only when callers need to programmatically distinguish that failure from others; otherwise a wrapped message is enough.
  • Prefer errors.Is and errors.As over direct == comparisons or type assertions the moment wrapping is involved anywhere in the call chain.
  • Keep error strings lowercase and free of trailing punctuation, since they are frequently embedded inside a larger wrapped message.
  • Reserve panic/recover for truly unrecoverable situations or well-defined library boundaries, never for routine error flow.
  • Never return both a non-nil result and a non-nil error unless that combination is explicitly documented — callers should be able to trust one or the other.
  • Use errors.Join (Go 1.20+) when an operation can fail in more than one independent way at once, instead of concatenating error strings by hand.

Practice Exercises

  • Write parsePercentage(s string) (float64, error) that turns a string like "42%" into 0.42. Return a wrapped error if the string doesn’t end in % or the numeric part fails to parse. Test it against "50%" (expect 0.5, nil) and "abc%" (expect a non-nil error).
  • Define a sentinel ErrTooManyRetries and a function retry(n int) error that returns it once n exceeds 3, wrapped with the attempt count for context. In main, call it with a few values of n and use errors.Is to print a friendly message specifically when that sentinel occurs.
  • Take the typed-nil example from Common Mistakes and rewrite doSomething so it can never return a typed nil, no matter how the function grows in the future. Add a check in main that prints whether err == nil for both a successful and a failing call.

Summary

  • Go returns errors as explicit values checked with if err != nil, not exceptions — panic/recover are for exceptional, unrecoverable situations only.
  • Any type with an Error() string method satisfies the error interface implicitly — there is no implements keyword.
  • Wrap errors with fmt.Errorf and %w to add context while preserving the original error for later inspection.
  • errors.Is checks for a specific sentinel error and errors.As extracts a specific error type, and both understand wrapped chains — plain == comparisons do not.
  • A nil pointer stored in a non-nil-typed error interface is itself non-nil — always return the literal nil, not a nil-valued named error variable.
  • Watch for discarded errors, shadowed err variables inside nested blocks, and redundant wrapping — all are common, easy-to-miss bugs.