How to Handle Errors Idiomatically in Go

Handle errors in Go by checking return values immediately, using errors.Is for specific types, and wrapping errors with fmt.Errorf to preserve context.

Errors are values, not exceptions

You are building a service that reads configuration from a JSON file. You write the code to parse the file, but you skip the error check on the os.Open call because you are confident the file exists. You run the program. It crashes with a nil pointer dereference. Or worse, it proceeds with empty data and corrupts your database.

In Go, errors are not exceptions that bubble up invisibly. They are explicit values that you must handle. The language forces you to acknowledge every failure point. This design choice makes error handling visible and predictable. You control the flow. You decide what happens when things go wrong.

The error interface and the receipt analogy

Errors in Go are just values. The error type is an interface with a single method: Error() string. Any type that implements this method is an error. When a function can fail, it returns the error as the last return value. The caller receives the error and decides what to do.

This differs from languages with try-catch blocks. There is no hidden control flow. The error travels up the call stack only if you explicitly return it. Think of it like a mechanic handing you a car and a slip of paper. The slip says "brakes need service." You cannot drive the car without reading the slip. You have to make a choice: fix the brakes, report the issue to the owner, or accept the risk and drive anyway. Go makes you read the slip.

The community accepts the verbosity of if err != nil checks because it makes the unhappy path impossible to ignore. You cannot accidentally drop an error. If you assign an error to a variable and do not use it, the compiler rejects the program with err declared and not used. This forces you to handle the error, even if the handling is just logging and continuing.

Errors are values. Treat them like data.

The minimal error handling pattern

Here is the standard flow: call a function, check the error immediately, and return or log if it is non-nil. This pattern ensures failures are caught early and propagated correctly.

// loadFile reads a file and returns its contents.
// It demonstrates the standard error handling flow with wrapping and deferral.
func loadFile(path string) ([]byte, error) {
    // os.Open returns a file handle and an error.
    // Go requires you to handle both return values.
    f, err := os.Open(path)
    if err != nil {
        // Return early on error.
        // Use %w to wrap the error so callers can inspect the cause later.
        return nil, fmt.Errorf("load file: %w", err)
    }
    // Defer the close to ensure the file descriptor is released.
    // This runs when the function returns, regardless of the path taken.
    defer f.Close()

    // Read the file contents.
    // Check this error too; reading can fail even if opening succeeded.
    data, err := io.ReadAll(f)
    if err != nil {
        return nil, fmt.Errorf("read file: %w", err)
    }
    return data, nil
}

When you call os.Open, the function returns two values. If the file exists, you get a valid *os.File and a nil error. If the file is missing, you get a nil file and an error value describing the problem. The if err != nil check branches the logic. If the error is not nil, you enter the block. You wrap the error using fmt.Errorf with the %w verb. Wrapping adds context ("load file") while preserving the original error. The caller receives the wrapped error. They can print the full chain or check for specific causes. The defer f.Close() ensures the file is closed when the function returns. This runs even if you return an error. Resource cleanup is guaranteed.

Wrap at the boundary. Check at the decision point.

Checking for specific errors with errors.Is

Sometimes you need to handle a specific error differently. Maybe you want to skip a file if it has an insecure path, or retry if the network is temporarily down. You cannot use == to compare errors in Go. Direct comparison fails when errors are wrapped.

Use errors.Is to check for a specific error. It walks the error chain to find a match. This works even if the error was wrapped multiple times.

// processArchive reads entries from a tar archive.
// It uses errors.Is to handle specific sentinel errors like insecure paths.
func processArchive(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("open archive: %w", err)
    }
    defer f.Close()

    tr := tar.NewReader(f)
    for {
        // Next returns the next header and an error.
        // The loop continues until Next returns an error.
        hdr, err := tr.Next()
        if err != nil {
            // Check for the specific sentinel error.
            // errors.Is walks the chain to find a match.
            if errors.Is(err, archive/tar.ErrInsecurePath) {
                log.Printf("skipping unsafe entry: %s", hdr.Name)
                continue
            }
            // EOF signals the end of the archive.
            // Handle it gracefully instead of returning an error.
            if errors.Is(err, io.EOF) {
                return nil
            }
            // Any other error is fatal.
            return fmt.Errorf("read archive: %w", err)
        }
        // Process the entry here.
        _ = hdr
    }
}

errors.Is checks if any error in the chain matches the target. It looks for the underlying cause. This is crucial because you often wrap errors. If you wrap archive/tar.ErrInsecurePath, a direct comparison with == fails. errors.Is succeeds. It allows you to define sentinel errors in your package and check for them anywhere in the call stack.

Define sentinel errors using errors.New. Export them if other packages need to check for them. Keep them simple.

Pitfalls and common mistakes

Swallowing errors is the most common mistake. Logging an error and continuing without returning it hides failures. The program might be in a bad state. Return the error or handle it completely. If you log and continue, make sure the operation is truly safe to skip.

Wrapping errors with %v instead of %w breaks the chain. fmt.Errorf("msg: %v", err) formats the error as a string but loses the underlying error. errors.Is will not find the cause. Always use %w when you want to preserve the error for inspection.

Comparing errors with == fails on wrapped errors. Use errors.Is for checks. Use errors.As when you need to extract a specific error type to access its fields or methods.

Returning nil error when something failed confuses callers. The caller assumes success and proceeds. This leads to subtle bugs. Always return a non-nil error if the operation did not complete as expected.

If you ignore an error and use a nil value, you get a runtime panic. panic: runtime error: invalid memory address or nil pointer dereference is the result of calling a method on a nil file or reader. The compiler cannot catch this. You must check the error before using the value.

The compiler forces you to handle errors. Trust the compiler.

When to use which pattern

Use if err != nil when you need to handle a failure immediately after a call. This is the default pattern for every function that returns an error.

Use fmt.Errorf with %w when you want to add context to an error and preserve the chain. Wrap errors at the boundary of your function to provide meaningful messages for the caller.

Use errors.Is when you need to check for a specific error type or sentinel error, including wrapped errors. This is the correct way to compare errors in Go.

Use errors.As when you need to extract a specific error type to access its fields or methods. This is useful for custom error types that carry additional data.

Use panic when the program is in an unrecoverable state, such as a configuration failure at startup. Do not use panic for expected errors.

Use plain sequential code when you do not need concurrency. The simplest thing that works is usually the right thing.

Where to go next