How to Add Context to Errors in Go

Use fmt.Errorf with the %w verb to wrap Go errors with context while maintaining the error chain for unwrapping.

The error says "failed". You need to know why.

You call processFile("data.csv"). It crashes. The error message prints open failed. You stare at the screen. Which file? Did you misspell the path? Was the permission wrong? The error came from deep inside the standard library, and it stripped away all the context about what your code was trying to do. You need to wrap that error with a message that explains the operation, not just the failure.

Go treats errors as values. You can create a new error that contains the old one. This is called wrapping. The wrapper adds context. The original error stays accessible. You use fmt.Errorf with the %w verb to create a wrapper. The %w tells the formatter to embed the error so tools can unwrap it later. Without %w, you just get a string. The chain breaks.

Wrapping preserves the truth. Annotate the operation, not the error.

How the chain works at runtime

When a function fails, it returns an error. That error might be a simple string, or it might be a complex type with methods. When you wrap it, fmt.Errorf creates a new error value. This new value holds your message and a reference to the original error.

The new error implements an Unwrap method. When you call errors.Is or errors.As, those functions call Unwrap recursively. They walk the chain until they find a match or reach the end. The root cause never disappears. You can check for the specific error type at any level of the stack.

Go functions return errors as the last value. This is a universal convention. The compiler doesn't enforce it, but the ecosystem expects it. If you return error, result, you will confuse every reader. The error is the signal. The result is the payload. Put the signal last.

Minimal example: wrap and return

Here's the simplest way to wrap an error. You catch the error, wrap it with a message describing the operation, and return the wrapper.

package main

import (
	"errors"
	"fmt"
	"os"
)

// readFile opens a file and returns its content.
// It wraps the error if the open fails.
func readFile(name string) ([]byte, error) {
	// os.Open returns an error if the file doesn't exist.
	f, err := os.Open(name)
	if err != nil {
		// %w wraps the error, preserving the chain for errors.Is checks.
		// The message describes the operation, not the error type.
		return nil, fmt.Errorf("open config file: %w", err)
	}
	// Close the file when done.
	defer f.Close()

	// Read the file content.
	data, err := os.ReadFile(name)
	if err != nil {
		// Wrap the read error with context about the filename.
		return nil, fmt.Errorf("read %s: %w", name, err)
	}
	return data, nil
}

func main() {
	_, err := readFile("missing.txt")
	if err != nil {
		// Print the full chain.
		fmt.Println(err)
	}
}

The output shows the chain. You see open config file: open missing.txt: no such file or directory. The wrapper added "open config file". The root error added the system message. Together they tell the full story.

Error messages follow a style guide. Lowercase, no period at the end. The wrapper adds the operation. The root error adds the cause. Together they form a sentence. open config: permission denied reads like a log line.

Realistic example: layered functions

Real code has layers. A handler calls a service, which calls a repository. Each layer adds context. The error grows as it bubbles up.

package main

import (
	"errors"
	"fmt"
	"io"
	"os"
)

// Config holds application settings.
type Config struct {
	Port int
	Name string
}

// ErrInvalidPort indicates the port is out of range.
var ErrInvalidPort = errors.New("port out of range")

// loadConfig reads and validates the configuration file.
// It returns a wrapped error if anything goes wrong.
func loadConfig(path string) (*Config, error) {
	// Open the file.
	f, err := os.Open(path)
	if err != nil {
		// Wrap with operation context.
		return nil, fmt.Errorf("open config: %w", err)
	}
	// Ensure the file closes even if parsing fails.
	defer f.Close()

	// Read the content.
	data, err := io.ReadAll(f)
	if err != nil {
		// Wrap read error.
		return nil, fmt.Errorf("read config: %w", err)
	}

	// Parse the config.
	cfg, err := parseConfig(data)
	if err != nil {
		// Wrap parse error.
		return nil, fmt.Errorf("parse config: %w", err)
	}

	// Validate the config.
	if cfg.Port < 1 || cfg.Port > 65535 {
		// Wrap sentinel error with context.
		return nil, fmt.Errorf("validate port %d: %w", cfg.Port, ErrInvalidPort)
	}

	return cfg, nil
}

// parseConfig simulates parsing logic.
func parseConfig(data []byte) (*Config, error) {
	// Simulate a parse failure.
	if len(data) == 0 {
		return nil, fmt.Errorf("empty config file")
	}
	return &Config{Port: 8080, Name: "app"}, nil
}

The loadConfig function wraps errors at every step. If the file is missing, you get open config: open config.yaml: no such file or directory. If the port is invalid, you get validate port 99999: port out of range. The caller can check for ErrInvalidPort using errors.Is, even though the error is wrapped. The chain is intact.

Public names start with a capital letter. Private names start lowercase. ErrInvalidPort is public because other packages might want to check for it. err is private because it's a local variable. Naming conventions make the code readable.

Unwrapping errors with errors.Is and errors.As

Wrapping is useless if you can't check the errors later. Go provides errors.Is and errors.As to inspect the chain.

errors.Is checks if any error in the chain matches a target error. It returns a boolean. Use it for sentinel errors.

errors.As extracts an error of a specific type from the chain. It returns a boolean and sets a pointer to the matched error. Use it when you need to access methods or fields on the error type.

package main

import (
	"errors"
	"fmt"
	"os"
)

// CustomError holds structured error data.
type CustomError struct {
	Code int
	Msg  string
}

// Error implements the error interface.
func (e *CustomError) Error() string {
	return fmt.Sprintf("custom error %d: %s", e.Code, e.Msg)
}

func doWork() error {
	// Simulate a custom error wrapped in context.
	return fmt.Errorf("process request: %w", &CustomError{Code: 500, Msg: "internal failure"})
}

func main() {
	err := doWork()

	// Check for a sentinel error.
	if errors.Is(err, os.ErrNotExist) {
		fmt.Println("file not found")
	}

	// Extract a custom error type.
	var ce *CustomError
	if errors.As(err, &ce) {
		// Access fields on the extracted error.
		fmt.Printf("code: %d, msg: %s\n", ce.Code, ce.Msg)
	}
}

errors.As requires a pointer to a pointer. You pass &ce because errors.As needs to modify ce to point to the found error. The compiler rejects the call if you pass a value instead of a pointer, with an error like second argument to errors.As must be a non-nil pointer to a type that implements error. This is a common mistake. Always pass the address of the variable.

Context is plumbing. Run it through every long-lived call site. Error context is different. Error context is the annotation. context.Context carries request-scoped values and cancellation. Error context carries the story of what went wrong. Don't confuse the two.

Pitfalls and compiler errors

The most common mistake is using %s instead of %w. If you write fmt.Errorf("failed: %s", err), the result is a plain string error. The original error is gone. errors.Is will fail. The compiler won't stop you. This is a runtime logic error. You lose the chain.

Another pitfall is wrapping a nil error. If err is nil, fmt.Errorf("msg: %w", nil) produces an error with the message msg: <nil>. You just created an error out of thin air. Always check if err != nil before wrapping.

The compiler complains with cannot use x (untyped int constant) as string value in argument if you pass the wrong type to fmt.Errorf. If you forget to import errors, you get undefined: errors. If you try to use errors.Is on a non-error type, the compiler rejects it with first argument to errors.Is must be error.

Never wrap nil. Check the error before you format it.

When to wrap, when to return, when to define

Use fmt.Errorf with %w when you have an error from a lower layer and need to annotate it with the current operation's context.

Use errors.New when you are defining a sentinel error that represents a specific condition callers can check with errors.Is.

Use a custom error type implementing the error interface when you need to attach structured data or methods to the error.

Use fmt.Errorf with %v when you want to include an error message in a new error but intentionally break the chain to hide the underlying cause.

Return the error as-is when the caller has enough context and adding a wrapper would just add noise.

Use fmt.Errorf("operation: %w", err) pattern consistently across your codebase so error logs form a readable stack trace.

Context travels up. The cause stays down.

Where to go next