Error Handling in Go

Go does not have exceptions. Instead, any function that can fail returns an ordinary value that satisfies the built-in error interface, and the caller is expected to check it immediately. This makes failure paths explicit and visible in the code, rather than hidden in a stack of invisible try/catch jumps. Once you understand how error works, how to wrap errors for context, and how to inspect wrapped errors with errors.Is and errors.As, you can handle failure in Go idiomatically and reliably.

Overview / How it works

In Go, error is a built-in interface with a single method:

type error interface {
	Error() string
}

Any type that has an Error() string method automatically satisfies this interface — there is no implements keyword to write, because Go interfaces are satisfied implicitly. This is why you can pass around a plain string-backed error (created with errors.New), a custom struct with extra fields, or a wrapped error, and they all work anywhere an error is expected.

By convention, a function that can fail returns its normal result plus an error as the last return value:

value, err := doSomething()

If err is nil, the call succeeded and value is meaningful. If err is non-nil, something went wrong and value should usually be treated as unreliable (often it is the zero value). The compiler does not force you to check err, but idiomatic Go always does, immediately after the call:

if err != nil {
	// handle or propagate the error
}

Go deliberately avoids exceptions for routine failures. A thrown exception creates an invisible control-flow path that can jump out of many stack frames at once, which makes it hard to know, just by reading a function, everywhere it might exit or what state it leaves behind. Returning errors as values keeps every exit point visible in the source: you can see exactly which lines can fail and exactly what happens when they do. Go does have panic and recover, but those are reserved for truly exceptional, unrecoverable situations (like a programmer bug such as an out-of-bounds index), not for everyday failures like "file not found" or "invalid input".

Wrapping and unwrapping

As an error travels up through several layers of calling functions, each layer often wants to add context ("which operation was being attempted") without losing the original cause. The fmt.Errorf function supports this with the %w verb, which wraps an existing error inside a new one while preserving a link back to it:

wrapped := fmt.Errorf("loading config: %w", err)

Under the hood, fmt.Errorf with %w returns a value whose type has an Unwrap() error method returning the original err. This forms a chain: wrapped error → original error → possibly another wrapped error beneath that, and so on. The standard library’s errors package can walk this chain for you: errors.Is(err, target) walks the chain calling Unwrap() repeatedly, checking at each link whether it equals (or reports itself equal to, via an Is method) the target error. errors.As(err, &target) similarly walks the chain looking for a link whose concrete type matches target, and if found, assigns it into target. This is how you can add layers of human-readable context while still letting calling code detect a specific underlying failure or extract a specific error type.

Syntax

result, err := someFunction(args)
if err != nil {
	// inspect, wrap, log, or return err
}
Form Purpose
errors.New("message") Creates a simple error with a fixed message. Good for sentinel errors.
fmt.Errorf("context: %v", err) Creates a new error whose message includes err‘s message, but does not preserve the chain.
fmt.Errorf("context: %w", err) Same as above, but wraps err so errors.Is/errors.As can still find it.
errors.Is(err, target) Reports whether target appears anywhere in err‘s wrap chain.
errors.As(err, &target) Finds the first error in the chain whose type matches target and assigns it.
Unwrap() error Optional method a custom error type implements to expose the error it wraps.

Examples

Example 1: Checking a basic error

package main

import (
	"fmt"
	"strconv"
)

func main() {
	input := "42"
	n, err := strconv.Atoi(input)
	if err != nil {
		fmt.Println("conversion failed:", err)
		return
	}
	fmt.Println("parsed value:", n)

	input2 := "abc"
	n2, err := strconv.Atoi(input2)
	if err != nil {
		fmt.Println("conversion failed:", err)
		return
	}
	fmt.Println("parsed value:", n2)
}

Output:

parsed value: 42
conversion failed: strconv.Atoi: parsing "abc": invalid syntax

strconv.Atoi returns (int, error). The first call succeeds, so err is nil and execution continues. The second call fails because "abc" is not a valid integer, so err is non-nil, the program prints the message, and return exits main before the final line runs.

Example 2: A custom error type

package main

import (
	"fmt"
)

type ValidationError struct {
	Field string
	Msg   string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed on field %q: %s", e.Field, e.Msg)
}

func validateAge(age int) error {
	if age < 0 {
		return &ValidationError{Field: "age", Msg: "must not be negative"}
	}
	if age > 150 {
		return &ValidationError{Field: "age", Msg: "must be realistic"}
	}
	return nil
}

func main() {
	ages := []int{30, -5, 200}
	for _, age := range ages {
		if err := validateAge(age); err != nil {
			fmt.Println("error:", err)
			continue
		}
		fmt.Println("valid age:", age)
	}
}

Output:

valid age: 30
error: validation failed on field "age": must not be negative
error: validation failed on field "age": must be realistic

ValidationError is a plain struct with a pointer-receiver Error() method, so *ValidationError satisfies the error interface. Because it carries a Field and Msg, callers who care could inspect those fields directly (with a type assertion or errors.As) instead of only reading a formatted string.

Example 3: Wrapping errors and inspecting the chain

package main

import (
	"errors"
	"fmt"
)

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

type DBError struct {
	Op  string
	Err error
}

func (e *DBError) Error() string {
	return fmt.Sprintf("db operation %q failed: %v", e.Op, e.Err)
}

func (e *DBError) Unwrap() error {
	return e.Err
}

func fetchItem(id int) error {
	if id != 1 {
		return &DBError{Op: "fetchItem", Err: ErrNotFound}
	}
	return nil
}

func main() {
	err := fetchItem(42)
	if err != nil {
		fmt.Println("error:", err)
		if errors.Is(err, ErrNotFound) {
			fmt.Println("reason: the item does not exist")
		}
		var dbErr *DBError
		if errors.As(err, &dbErr) {
			fmt.Println("failed operation:", dbErr.Op)
		}
	}
}

Output:

error: db operation "fetchItem" failed: item not found
reason: the item does not exist
failed operation: fetchItem

ErrNotFound is a sentinel error — a package-level error value meant to be compared against. DBError wraps it and exposes it through Unwrap(). errors.Is(err, ErrNotFound) walks from the *DBError down through Unwrap() and finds ErrNotFound, so it reports true even though err‘s concrete type is *DBError, not ErrNotFound itself. errors.As(err, &dbErr) walks the same chain looking for a link whose type is *DBError, finds it immediately, and assigns it into dbErr so the code can read dbErr.Op.

How it works step by step

Walking through what happens when code like errors.Is(err, ErrNotFound) runs against a wrapped error:

  • Go compares err directly to ErrNotFound using ==. In example 3 this fails, because err‘s concrete type is *DBError, not *errors.errorString.
  • errors.Is then checks whether err has an Unwrap() error method. *DBError does, so it calls it, getting back ErrNotFound.
  • It compares this new value to ErrNotFound with ==. They match (both point to the same underlying error value created by errors.New), so errors.Is returns true.
  • If no match had been found, errors.Is would try to Unwrap() again, and again, until either a match is found or the chain ends (an error with no Unwrap() method, or Unwrap() returning nil), at which point it returns false.
  • errors.As follows the identical walk, but instead of comparing with ==, it checks at each link whether that link’s concrete type is assignable to the target pointer’s type, stopping and assigning as soon as it finds a match.

Common Mistakes

1. Discarding the error

Assigning an error to _ silently throws away information about failure:

n, _ := strconv.Atoi(input)
fmt.Println(n)

If input is invalid, n is simply 0 and the program has no idea the conversion failed — it proceeds as though it succeeded. Always check the error:

n, err := strconv.Atoi(input)
if err != nil {
	log.Fatalf("invalid input: %v", err)
}
fmt.Println(n)

2. Comparing wrapped errors with ==

Once an error has been wrapped, it is no longer identical to the original sentinel, so a direct comparison silently fails to match:

err := fetchItem(id)
if err == ErrNotFound {
	fmt.Println("not found")
}

If fetchItem returns ErrNotFound wrapped inside a *DBError (as in example 3), err == ErrNotFound is false even though the underlying cause really is "not found". Use errors.Is, which walks the wrap chain instead of doing a single direct comparison:

err := fetchItem(id)
if errors.Is(err, ErrNotFound) {
	fmt.Println("not found")
}

3. Shadowing err with := inside an if

Using := inside an if statement creates a new, block-scoped variable that shadows an outer one of the same name:

var err error
if err := doSomething(); err != nil {
	log.Println(err)
}
// err is still nil here, even though doSomething failed
fmt.Println("proceeding, err:", err)

The err inside the if is a brand-new variable that only exists for the lifetime of the if; the outer err is never touched, so code after the block still sees nil even after a real failure. Reuse the outer variable with plain assignment instead of :=:

var err error
err = doSomething()
if err != nil {
	log.Println(err)
}
fmt.Println("proceeding, err:", err)

4. Wrapping with %v instead of %w

Both verbs produce a similar-looking message, but only %w preserves the chain that errors.Is and errors.As rely on:

return fmt.Errorf("fetch failed: %v", err)

With %v, the resulting error has no Unwrap() method, so any later errors.Is(result, err) or errors.As call fails to find err, even though its text is embedded in the message. Use %w whenever the caller might need to detect or extract the original error:

return fmt.Errorf("fetch failed: %w", err)

Best Practices

  • Check every error immediately after the call that can produce it — don’t let it flow further unchecked.
  • Wrap errors with fmt.Errorf and %w when adding context, so errors.Is/errors.As keep working further up the call stack.
  • Declare sentinel errors (var ErrX = errors.New("...")) for conditions callers need to detect and compare against with errors.Is.
  • Define a custom error type when callers need structured data about the failure (a field name, a status code), not just a message string.
  • Write error messages in lowercase with no trailing punctuation, since they’re often embedded inside other error messages.
  • Use errors.Is for sentinel comparisons and errors.As for extracting a specific error type — never == on an error that might be wrapped.
  • Reserve panic/recover for programmer bugs or truly unrecoverable situations, not for routine, expected failures.
  • Add context that says which operation failed ("reading config", "connecting to db") rather than repeating the same context at every layer of the call stack.

Practice Exercises

  • Write a function divide(a, b int) (int, error) that returns an error when b is zero, and a normal quotient otherwise. Call it with a few pairs of numbers, including one where b is zero, and print either the result or the error.
  • Define a custom error type ParseError with Line int and Msg string fields and an Error() string method. Write a function that returns a *ParseError for bad input, then in main use errors.As to extract it and print its Line field.
  • Create a sentinel error ErrPermission. Write two functions where the first returns ErrPermission wrapped with %w and some context, and the second wraps that returned error again with more context. In main, call the outer function and use errors.Is to confirm ErrPermission is still detectable through both layers of wrapping.

Summary

  • Go represents failure as ordinary values satisfying the built-in error interface (Error() string), checked explicitly instead of thrown as exceptions.
  • Always check err immediately after a call that returns one; never silently discard it with _ except deliberately in throwaway code.
  • errors.New and fmt.Errorf create errors; fmt.Errorf with %w wraps an existing error and preserves it in a chain, while %v does not.
  • errors.Is walks that chain to check for a specific sentinel error; errors.As walks it to find and extract an error of a specific concrete type.
  • Custom error types implementing Error() (and optionally Unwrap() error) let you attach structured data to a failure instead of only a message string.
  • panic and recover exist for exceptional, unrecoverable situations, not as a substitute for routine error handling.