if err != nil

Common Patterns and Shortcuts

Handle Go errors by checking `if err != nil` and returning or logging immediately to prevent further execution with invalid data.

The repetition that saves you

You write a command-line tool that fetches a URL, parses JSON, and writes the result to a file. In Python, you wrap the whole block in a try statement and attach an except clause. In JavaScript, you chain a .catch() handler and move on. In Go, the compiler forces you to check every single return value. You write the check, then write it again, then write it a third time. The repetition feels heavy. You skip one check to save vertical space. The program compiles. It runs. It crashes ten lines later with a nil pointer dereference because the JSON never decoded. The crash happens far from the actual problem. This is why Go handles errors the way it does.

Concept in plain words

Go treats errors as regular values. The error type is just an interface with one method: Error() string. When a function succeeds, it returns nil for the error value. When it fails, it returns a non-nil error. There are no hidden exceptions jumping up the call stack. There is no try-catch syntax. Control flow stays visible on the surface.

Think of it like a relay race where each runner must hand off a baton. If the baton is dropped, the race stops immediately at that lane. The next runner never gets to start. You see exactly where the drop happened. You do not have to trace back through three layers of hidden state to find out why the final runner never crossed the line.

The community accepts the if err != nil boilerplate because it makes the unhappy path impossible to ignore. Verbose error handling is a feature, not a bug. It forces you to decide what happens when things go wrong, right at the call site. You cannot accidentally swallow a failure behind a syntactic sugar layer. Every boundary between your code and the outside world gets the same level of scrutiny.

Error handling in Go is mechanical by design. The repetition trains you to think about failure at every boundary. You do not need to memorize exception hierarchies. You just check the value and move on.

Make the failure visible at the boundary. Do not defer the decision.

Minimal example

Here is the standard pattern for handling a failure and exiting early.

func readConfig(path string) ([]byte, error) {
    // Open the file and capture both the data and the error value
    data, err := os.ReadFile(path)
    if err != nil {
        // Return immediately so the caller knows the operation failed
        return nil, err
    }
    // Only reach this point if the file read succeeded
    return data, nil
}

The function calls os.ReadFile. That call returns two values. The first is the file contents. The second is an error. If the file exists and is readable, the error is nil. The if condition evaluates to false. Execution continues to the final return. If the file is missing, the error holds a descriptive message. The condition evaluates to true. The function returns nil for the data and passes the error back to whoever called it. The caller then decides whether to retry, log, or abort.

This early-return style flattens your code. You avoid nesting success logic inside multiple else blocks. The happy path reads top to bottom. The failure paths exit on the left margin. The compiler enforces this structure by requiring you to handle the error value if you assign it to a variable. If you write data, err := os.ReadFile(path) and never check err, the compiler rejects the program with err declared and not used. You cannot accidentally swallow a failure.

Early returns keep the indentation shallow. Trust the left margin.

Walk through what happens

When the Go compiler sees a function that returns an error, it does not track whether you actually check it at runtime. It only tracks whether you use the variable you assigned it to. This is a deliberate trade-off. The language prioritizes explicit variable usage over semantic error checking. If you assign an error to err and then immediately assign it to _, the compiler considers it handled. If you assign it to err and never reference err again, the compiler stops the build.

This design choice means you must be intentional about variable naming. Shadowing is the most common source of silent bugs. If you declare a new err inside a nested scope, the outer err remains unchanged. The compiler will not warn you about shadowing. You will end up checking a stale value. Always reuse the same variable name when possible, or assign explicitly to avoid accidental rebinding.

At runtime, nil is just a zero value for the error interface. Comparing err != nil is a fast pointer check. There is no hidden allocation or method dispatch. The check is as cheap as any other conditional. The overhead comes from the text formatting when you actually log or return the error, not from the check itself.

When an error bubbles up, the stack trace points to the exact line where the check failed. You do not get a cryptic panic from three frames deeper. You get the call site. You get the context. You fix the root cause.

The compiler tracks variable names, not intent. Name your variables carefully.

How error wrapping chains work

Raw errors are rarely enough for production code. You need to know which step failed, what input caused it, and what the underlying system reported. Go solves this with error wrapping. When you use fmt.Errorf("context: %w", err), the compiler creates a new error value that holds both your message and a pointer to the original error. This creates a linked list of error objects.

The %w verb is the key. It tells the formatter to embed the underlying error inside the new one. Without %w, you get a plain string error that loses the original type and chain. With %w, the standard library can traverse the chain later using errors.Is or errors.As. errors.Is walks the chain and compares each link against a sentinel error. errors.As walks the chain and tries to cast each link into a target type. This gives you the best of both worlds: human-readable context at every layer, and machine-readable type information for programmatic checks.

Wrapping does not copy the original error. It stores a reference. The memory footprint is small, and the traversal is fast. You can wrap an error ten times and still unwrap it to the root cause in microseconds. The chain preserves the exact type that created the failure, so you can check for os.ErrNotExist or net.ErrClosed even after five layers of wrapping.

Build the chain at the boundary. Unwrap it at the decision point.

Realistic example

Real applications rarely just return raw errors. They add context, chain operations, and handle terminal failures differently. Here is how a production-ready function chains multiple fallible steps while preserving the original error.

func startServer(configPath string) error {
    // Load configuration from disk
    raw, err := os.ReadFile(configPath)
    if err != nil {
        // Wrap the error with a step label for better stack traces
        return fmt.Errorf("load config: %w", err)
    }

    // Parse the JSON payload into a typed struct
    var cfg Config
    if err := json.Unmarshal(raw, &cfg); err != nil {
        return fmt.Errorf("parse config: %w", err)
    }

    // Bind to the network port specified in the config
    addr := fmt.Sprintf(":%d", cfg.Port)
    listener, err := net.Listen("tcp", addr)
    if err != nil {
        return fmt.Errorf("listen on %s: %w", addr, err)
    }
    // Close the listener if the server fails to start later
    defer listener.Close()

    // Hand off to the HTTP router
    return http.Serve(listener, cfg.Router())
}

The %w verb in fmt.Errorf wraps the underlying error. Wrapping preserves the original error value so you can unwrap it later with errors.Is or errors.As. This lets you check for specific error types higher up the stack while keeping human-readable context at each step. The defer listener.Close() ensures the network socket releases even if http.Serve returns an error.

In a main function, you usually do not want to return errors. You want to stop the process and print the failure. The standard library provides log.Fatal for this exact purpose.

func main() {
    // Attempt to start the server and capture any startup error
    if err := startServer("config.json"); err != nil {
        // Print the error to stderr and call os.Exit(1)
        log.Fatal(err)
    }
    // This line never runs if startServer returns an error
}

log.Fatal writes to standard error and exits with code 1. It also runs deferred functions before terminating, which means cleanup code still executes. Use log.Fatalf when you need to format the message inline. Both are designed for process-level termination, not for routine control flow. The community convention is to reserve log.Fatal for main and initialization code. Library functions should always return errors.

Log.Fatal is for the edge of the world. Return errors everywhere else.

Pitfalls and compiler errors

The most common mistake is ignoring an error without acknowledging it. If you call a function that returns an error and you do not need it, assign it to the blank identifier.

// Intentionally discard the error because we do not care if it fails
_, _ = os.Stat("optional.log")

If you omit the blank identifier, the compiler complains with declared and not used. The blank identifier tells the compiler you considered the return value and chose to drop it. Use it sparingly with errors. Swallowing failures silently creates bugs that surface hours later. The worst goroutine bug is the one that never logs.

Another trap is returning nil when an error actually occurred. If a function documents that it returns an error on failure, returning nil while also returning invalid data confuses the caller. The caller assumes success, uses the bad data, and panics with a nil pointer dereference or an index out of range. The stack trace points to the usage site, not the source of the bad data. Always return a descriptive error value when something goes wrong.

Type mismatches also trip up beginners. If you try to return a string where an error is expected, the compiler rejects the program with cannot use "bad config" (untyped string constant) as error value in return argument. You must wrap the string in fmt.Errorf or errors.New to satisfy the interface. The compiler will not auto-convert primitives to interfaces.

Shadowing variables inside loops or nested blocks creates invisible state divergence. If you write err := doSomething() inside an if block, you create a new err that shadows the outer one. The outer err remains nil. The subsequent check passes incorrectly. Always declare variables at the top of the scope when you plan to reuse them across branches.

Do not fight the type system. Wrap the value or change the design.

Decision matrix

Use if err != nil { return err } when you want to bubble the failure up to the caller and let them decide how to handle it. Use log.Fatal(err) or log.Fatalf when the program cannot continue and should terminate immediately with a non-zero exit code. Use fmt.Errorf("context: %w", err) when you need to add location or state information while preserving the original error for later inspection. Use the blank identifier _ when a function returns an error you intentionally want to ignore, such as closing a writer that already flushed. Use a custom error type with errors.Is or errors.As when you need to distinguish between multiple failure modes and trigger specific recovery logic. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.

Check the error. Return the error. Let the caller decide.

Where to go next