Error Handling in Go: Why 'if err != nil' is Everywhere

Error Handling in Go: Why 'if err != nil' is Everywhere

If you've read any Go code, you've seen this pattern:

result, err := doSomething()
if err != nil {
    return err
}

It appears everywhere. Functions return errors as values, and you handle them at the call site. No exceptions, no try-catch blocks, no invisible control flow.

This is Go's error handling philosophy: errors are values, and handling them is explicit.

The error Type

In Go, error is an interface:

type error interface {
    Error() string
}

Any type with an Error() method that returns a string satisfies the interface. The standard library provides errors.New for creating simple errors:

err := errors.New("something went wrong")

And fmt.Errorf for formatted errors:

err := fmt.Errorf("failed to open file: %s", filename)

Functions return nil when they succeed and a non-nil error when they fail:

func readFile(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("readFile: %w", err)
    }
    return data, nil
}

The caller checks the error before using the result.

Why if err != nil?

Go forces you to handle errors where they occur. If a function can fail, you decide immediately what to do—return the error, log it, retry, or handle it differently.

This is different from exceptions. Exceptions can propagate up the call stack invisibly. A function that throws deep in the stack might be caught far away, making control flow hard to follow.

In Go, errors are explicit. If a function returns an error, you see it in the signature:

func Parse(data string) (Result, error)

The caller knows Parse can fail and must handle that possibility.

Common Patterns

Return early: If an error occurs, return immediately:

func process() error {
    err := step1()
    if err != nil {
        return err
    }

    err = step2()
    if err != nil {
        return err
    }

    return nil
}

This keeps error paths separate from the success path. The happy path flows straight down without nesting.

Wrap errors: Add context when propagating errors:

if err != nil {
    return fmt.Errorf("failed to process request: %w", err)
}

The %w verb wraps the original error, preserving it for inspection with errors.Is or errors.As.

Sentinel errors: Predefined errors for specific conditions:

var ErrNotFound = errors.New("not found")

func find(id string) (*Item, error) {
    // ...
    if !exists {
        return nil, ErrNotFound
    }
    // ...
}

Callers can check for specific errors:

item, err := find("123")
if errors.Is(err, ErrNotFound) {
    // Handle not found case
}

Custom error types: For more complex errors, define custom types:

type ValidationError struct {
    Field string
    Issue string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed: %s - %s", e.Field, e.Issue)
}

Callers can extract details:

var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Println("Field:", valErr.Field)
}

The Repetition Question

Critics point to the repetition:

if err != nil {
    return err
}

This appears dozens of times in typical Go programs. It's verbose compared to try-catch, where one block handles multiple failure points.

Go's perspective: repetition is acceptable if it makes control flow clear. Each error check is a decision point. Hiding those decisions makes code harder to reason about.

The verbosity also encourages defensive programming. You think about failure cases at every step, not just in a catch block at the end.

Error Wrapping and Unwrapping

Go 1.13 introduced error wrapping. The %w verb preserves the error chain:

err := step1()
if err != nil {
    return fmt.Errorf("step1 failed: %w", err)
}

Later, you can check if the error wraps a specific error:

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

Or extract a specific error type:

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    fmt.Println("Path:", pathErr.Path)
}

This maintains context while allowing precise error handling.

When to Return Errors

Return errors when:

  • The caller can reasonably handle or recover from the failure
  • The operation is expected to fail sometimes (file not found, network timeout)

Don't return errors when:

  • The failure indicates a programming bug (nil pointer, index out of bounds). Use panic for these.
  • The caller can't do anything useful with the error. Log it or handle it locally instead.

panic and recover

Go has panic for unrecoverable errors:

if config == nil {
    panic("config cannot be nil")
}

Panics stop normal execution and unwind the stack, running deferred functions along the way. Use recover in a deferred function to catch panics:

defer func() {
    if r := recover(); r != nil {
        fmt.Println("Recovered from panic:", r)
    }
}()

But panics are for exceptional situations—bugs, invariant violations, truly unrecoverable states. Normal error conditions should return errors.

The defer Pattern

Deferred functions run even when errors are returned, making cleanup easier:

func readConfig(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()  // Runs even if an error occurs below

    // Parse file...
    return nil
}

This ensures resources are released without explicit cleanup in every error path.

Logging Errors

Sometimes you can't return an error (background goroutines, HTTP handlers that already wrote a response). Log it instead:

if err != nil {
    log.Printf("failed to process item: %v", err)
}

Use structured logging for production:

logger.Error("failed to process item",
    "error", err,
    "item_id", id)

Libraries like zap and zerolog provide structured logging with better performance than the standard log package.

Error Handling Proposals

Go's error handling has been debated. Proposals for check/handle and other syntax changes appeared over the years. None were accepted.

The community and core team decided the existing approach—verbose but explicit—is better than alternatives that hide control flow. Explicitness wins.

Further Reading

The Go blog's post on error handling explains the philosophy in detail.

For error wrapping and inspection, see the Go 1.13 errors post.

Dave Cheney's article on handling errors gracefully offers practical advice on error handling patterns.

Error handling is central to writing reliable Go programs.

Wear the code

Product mockup

“if err != nil” Developer T-Shirt (Go Edition — Dark Mode)

£25.00

View product
Product mockup

“if err != nil” Developer T-Shirt (Go Edition — Light Mode)

£25.00

View product

0 comments

Leave a comment

Please note, comments need to be approved before they are published.