Go Error Handling Patterns and Best Practices

Go uses explicit error returns requiring immediate checks with if err != nil to handle failures without exceptions.

When silence breaks production

You write a function to parse a JSON config file. It works on your machine. In production, the file is missing. The function returns nil data. The next line dereferences nil. The server crashes. Or the function swallows the error and returns empty data. The app runs with defaults. You spend three hours debugging why the feature is broken.

Go prevents this by making errors explicit values. You cannot ignore them. The compiler forces you to handle every error at the call site. This pattern turns error handling into visible control flow. You see exactly where things can fail. You see exactly how failures are handled. There is no hidden jump table. The runtime does not intervene.

Errors are values, not explosions

In many languages, errors are like explosions. A function throws an exception, the runtime catches fire, and the program jumps to a catch block somewhere else. Control flow becomes a game of telephone. You have to read the entire call stack to understand what can go wrong.

Go takes a different approach. An error is just a value. A function returns an error the same way it returns a result. You check it right there. If something goes wrong, you decide what to do. You can return it, log it, wrap it with more details, or handle it and continue. The compiler stops you if you try to use a result without checking the error first.

This design makes the unhappy path visible. When you read a function, you see every place it can fail. You see how each failure is handled. The code reads linearly. You do not need to hunt for exception handlers.

Minimal example: checking and wrapping

The standard pattern starts with if err != nil. You check the error immediately after the call. If the error is not nil, you handle it. The most common handling is wrapping the error with context and returning it.

package main

import (
	"fmt"
	"os"
)

// OpenConfig opens a config file and returns an error if it fails.
func OpenConfig(path string) (*os.File, error) {
	// os.Open returns two values: the file handle and an error.
	f, err := os.Open(path)
	if err != nil {
		// Wrap the error with context so the caller knows what went wrong.
		// The %w verb preserves the original error for unwrapping later.
		return nil, fmt.Errorf("open config %s: %w", path, err)
	}
	return f, nil
}

func main() {
	// Call the function and capture both return values.
	file, err := OpenConfig("config.json")
	if err != nil {
		// Handle the error immediately. Here we print to stderr and exit.
		fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
		os.Exit(1)
	}
	// Defer close to ensure the file handle is released.
	defer file.Close()
	// Use the file safely.
}

The compiler rejects the program with err declared and not used if you assign the error to a variable and never check it. You cannot accidentally drop an error. The if err != nil check is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not try to hide it with macros or defer tricks that obscure control flow.

How the chain works

When os.Open fails, it returns nil for the file and a non-nil error. The if err != nil check catches this. You wrap the error using fmt.Errorf with the %w verb. This creates a new error that contains your message and the original error. Tools can unwrap the chain to find the root cause.

You return nil for the file because there is no file to return. The caller sees the error and handles it. If you try to use the file without checking the error, the compiler stops you. You get a type error or a nil pointer dereference at runtime if you bypass the check.

The %w verb is key. If you use %v instead, the original error is hidden. You lose the ability to inspect the chain. Use %w whenever you wrap an error that might be inspected later. Use %v only when you want to discard the cause, which is rare.

Realistic example: inspection and custom types

In real code, you often need to check for specific error types. The errors package provides errors.Is and errors.As. Use errors.Is to check if an error matches a sentinel error. Use errors.As to extract a concrete type from a wrapped chain.

package main

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

// ErrNotFound is a sentinel error for missing resources.
var ErrNotFound = errors.New("resource not found")

// FetchResource retrieves data, wrapping errors with context.
func FetchResource(id string) ([]byte, error) {
	// Read the file and check for errors.
	data, err := os.ReadFile("data/" + id)
	if err != nil {
		// Check if the error is specifically "file not found".
		if errors.Is(err, os.ErrNotExist) {
			// Wrap with our own sentinel error.
			return nil, fmt.Errorf("fetch resource %s: %w", id, ErrNotFound)
		}
		// Wrap other errors without changing the type.
		return nil, fmt.Errorf("fetch resource %s: %w", id, err)
	}
	return data, nil
}

// AppError is a custom error type with structured data.
type AppError struct {
	Code int
	Msg  string
}

// Error implements the error interface.
func (e *AppError) Error() string {
	return e.Msg
}

// ProcessResource handles data and checks for specific error types.
func ProcessResource(id string) error {
	data, err := FetchResource(id)
	if err != nil {
		// Check if the error chain contains ErrNotFound.
		if errors.Is(err, ErrNotFound) {
			fmt.Println("Resource missing, using default.")
			return nil
		}
		// Check if the error chain contains an AppError.
		var appErr *AppError
		if errors.As(err, &appErr) {
			// Access the structured data from the custom error.
			fmt.Printf("App error code %d: %s\n", appErr.Code, appErr.Msg)
			return nil
		}
		// Propagate unexpected errors.
		return err
	}
	// Process data...
	return nil
}

errors.Is traverses the error chain. It returns true if any error in the chain matches the target. This works with wrapped errors. errors.As does the same for types. It returns true if any error in the chain matches the target type. It also sets the target variable to the matching error, so you can access its fields.

The receiver name in Error() is e, matching the type AppError. This follows the convention of using short receiver names. Public names start with a capital letter. ErrNotFound is public so other packages can check for it. Private names start lowercase.

Pitfalls and runtime traps

Swallowing errors is the most common bug. If you log an error but do not return it, the caller assumes success. The program continues with invalid state. You get a panic later that is hard to trace. Always return the error or handle it completely.

Returning nil, nil is another trap. If a function returns a value and an error, returning nil for both can confuse the caller. The caller might check the error, see nil, and assume the value is valid. If the value is nil, you get a nil pointer dereference. Return a meaningful error instead. The compiler complains with cannot use nil as type error in return if you try to return a value where an error is expected, but nil is valid for the error type. The logic bug is returning nil for the value when the error is also nil.

Using panic for validation is a mistake. panic stops the program. Use it only for bugs. If a user passes invalid input, return an error. If a file is missing, return an error. If a network request fails, return an error. Panic only when the program is in an unrecoverable state, such as a missing invariant that should never happen in production.

The compiler catches unused errors. Your tests catch the logic bugs. Write tests that check error paths. Verify that errors are wrapped correctly. Verify that errors.Is and errors.As work as expected.

Decision matrix

Use fmt.Errorf with %w when you need to add context to an error while preserving the original cause for inspection.

Use errors.Is when you need to check if an error matches a specific sentinel error or type within a wrapped chain.

Use errors.As when you need to extract a concrete error type from a wrapped chain to access its methods or fields.

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

Use errors.Join when you need to combine multiple errors into a single error value, such as when closing multiple resources.

Use panic only when the program is in an unrecoverable state, such as a programming bug or a missing invariant that should never happen in production.

Use defer with recover only in library code where you need to translate a panic into a safe error for the caller, never in application code where panics indicate a bug.

Where to go next

Errors are values. Handle them where you can see them. Wrap at the boundary. Inspect at the decision point. Trust the compiler to catch ignored errors. Write tests to catch the logic.