Error handling best practices

Handle errors in Go by explicitly checking return values and using the error interface to manage failures gracefully.

The receipt on the table

You write a function to load a configuration file. It opens the file, parses JSON, and returns a struct. The file is missing. Your function returns a struct filled with zero values. The caller saves that empty struct to the database. Your application now runs with default settings, corrupting data or exposing secrets.

In languages with exceptions, this might happen because you forgot a try-catch. In Go, this happens because you ignored the error return value. Go forces you to look at the problem before you move on. Every function that can fail returns an error value alongside its result. You have to check that value. If you don't, the compiler or the runtime will eventually remind you.

Errors are values, not exceptions

Go does not have exceptions. There is no throw, no catch, no stack unwinding triggered by a failure. An error is just a value that implements the error interface. The interface has one method:

type error interface {
    Error() string
}

Any type with an Error() string method is an error. This keeps the concept simple. You can pass errors around, store them in variables, and return them from functions just like integers or strings. The convention is that the last return value of a function is the error. If the error is nil, the operation succeeded. If it is not nil, the other return values are invalid.

Think of it like a receipt from a transaction. The waiter brings you a plate and a receipt. If the receipt has a red stamp saying "Kitchen Closed," the plate is empty. You check the receipt before you touch the food. In Go, the error is the receipt. You check err != nil before you use the result.

Check the error before using the result. A nil pointer dereference is a runtime surprise you can avoid.

Minimal example: check before you use

The most common pattern is immediate checking. You call a function, assign the error to a variable, and test it right away.

package main

import (
    "fmt"
    "log"
    "os"
)

func main() {
    // Open returns a file handle and an error.
    // The compiler requires you to handle the error variable.
    f, err := os.Open("config.json")
    if err != nil {
        // If the file is missing, stop here.
        // Using f when err is not nil causes a panic.
        log.Fatal(err)
    }

    // Close the file when main returns.
    // defer ensures cleanup runs even if we return early.
    defer f.Close()

    fmt.Println("File opened successfully")
}

The compiler enforces variable usage. If you write f, err := os.Open("config.json") and never mention err, the compiler rejects the program with err declared and not used. This forces you to make a choice: handle the error, or discard it explicitly.

To discard an error intentionally, assign it to the blank identifier _. This tells the compiler you considered the value and chose to ignore it. Use this sparingly. Ignoring errors is a common source of bugs.

The compiler forces you to acknowledge an error variable. You can discard it, but you have to say so.

Realistic example: custom types and wrapping

In real code, you need more than nil or not nil. You need to know why something failed. You might want to distinguish between "file not found" and "permission denied." You might want to add context as the error bubbles up the call stack.

Go supports custom error types and error wrapping. A custom type is a struct that implements the error interface. Wrapping adds context while preserving the original error for inspection.

package main

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

// ConfigError holds details about configuration failures.
// Custom types let callers check for specific conditions.
type ConfigError struct {
    Path string
    Err  error
}

// Error implements the error interface.
// It returns a human-readable message with context.
func (e *ConfigError) Error() string {
    return fmt.Sprintf("config error at %s: %v", e.Path, e.Err)
}

// Unwrap returns the underlying error.
// This allows errors.Is and errors.As to work correctly.
func (e *ConfigError) Unwrap() error {
    return e.Err
}

// loadConfig reads and validates the configuration file.
// It returns a wrapped error if the file cannot be read.
func loadConfig(path string) error {
    // Read the file content.
    _, err := os.ReadFile(path)
    if err != nil {
        // Wrap the error to add context.
        // %w preserves the error chain for inspection.
        return &ConfigError{
            Path: path,
            Err:  fmt.Errorf("reading file: %w", err),
        }
    }

    // Simulate a validation error.
    return errors.New("config: missing required field 'database_url'")
}

func main() {
    err := loadConfig("missing.json")
    if err != nil {
        // Check if the error is our custom type.
        var cfgErr *ConfigError
        if errors.As(err, &cfgErr) {
            fmt.Printf("Config failed: %v\n", cfgErr)
        } else {
            fmt.Printf("Unknown error: %v\n", err)
        }
    }
}

The Unwrap method is key. It tells the standard library how to traverse the error chain. When you use fmt.Errorf with %w, the resulting error wraps the argument. Functions like errors.Is and errors.As walk the chain by calling Unwrap until they find a match or reach the end.

Wrap errors with context as they bubble up. The caller needs to know where the failure happened, not just that it happened.

The error chain and inspection

Sentinel errors are predefined error values exported from a package. The os package exports os.ErrNotExist and os.ErrPermission. You can check for these using errors.Is.

if errors.Is(err, os.ErrNotExist) {
    // Handle missing file.
}

errors.Is compares by identity. It returns true if the error or any error in its chain is the same object as the target. This works even if the sentinel error is wrapped multiple times.

Use errors.As when you need to extract a specific type from the chain. It attempts to assign the error or any wrapped error to a target pointer. If successful, it returns true and populates the target.

var cfgErr *ConfigError
if errors.As(err, &cfgErr) {
    // cfgErr now points to the ConfigError in the chain.
    fmt.Println(cfgErr.Path)
}

Do not use type assertions like err.(ConfigError) on wrapped errors. Type assertions check the concrete type of the value. If the error is wrapped, the concrete type is the wrapper, not the inner error. errors.As handles the traversal for you.

errors.Is checks identity. errors.As extracts types. Use the standard library functions instead of manual type assertions.

Pitfalls and compiler behavior

Go errors are cheap. Allocating an error is fast. Do not optimize error creation. Creating an error only when it occurs is the right approach. Pre-allocating errors or reusing error instances across goroutines can lead to data races if the error implementation stores state.

The compiler does not force you to handle errors. It only forces you to use variables. You can write os.Open("file.txt") without assigning the result, and the compiler accepts it. This is dangerous. The error is silently dropped. Tools like staticcheck warn about ignored errors with codes like SA5000. Run linters to catch these cases.

Returning nil error with a non-nil value is a bug. If a function returns a result and an error, and the error is nil, the result must be valid. If the error is not nil, the result is undefined. Returning both a non-nil error and a non-nil result confuses callers.

Panic is for bugs, not for errors. panic stops the program and prints a stack trace. Use it only when the program reaches an impossible state, like a nil pointer dereference in a library function or a failed invariant. Never panic for expected failures like network timeouts or missing files. The caller cannot recover from a panic using error handling. They need defer and recover, which is fragile and hard to reason about.

The compiler rejects unused variables with declared and not used. It rejects undefined identifiers with undefined: name. It does not reject ignored errors. Linters and code reviews fill that gap.

Panic is for bugs. Errors are for life.

Decision matrix

Use if err != nil to handle errors immediately at the call site.

Use fmt.Errorf with %w to wrap errors and preserve the error chain.

Use custom error types when you need to expose specific details or allow callers to check for exact types.

Use errors.Is to check for sentinel errors or wrapped errors by identity.

Use errors.As to extract a specific error type from a wrapped chain.

Use log.Fatal in main or top-level initialization when the program cannot proceed safely.

Use panic only for programming errors that indicate a bug in the code, never for expected failure conditions.

Use defer with recover only in libraries that need to protect against panics from third-party code.

Errors are values. Treat them like data. Wrap early, unwrap late, and never ignore the receipt.

Where to go next