GoBackend

Go Error Handling Patterns: The Complete Guide

TT
TopicTrick Team
Go Error Handling Patterns: The Complete Guide

Go Error Handling Patterns: Complete Guide

How Does Go Handle Errors?

Go handles errors by returning them as values from functions, typically as the last return value of type error. Callers must check the returned error immediately and decide whether to handle it, wrap it with additional context, or propagate it up the call stack. Unlike exceptions, there is no automatic stack unwinding — execution remains linear and predictable. The idiomatic check is if err != nil { return fmt.Errorf("operation: %w", err) }.

Error Handling Patterns: The Go Way

In most modern languages, errors are handled via a try/catch mechanism. When something goes wrong, an exception is "thrown," and the control flow jumps to the nearest handler. Go takes a radically different approach.

In Go, errors are values. They are returned from functions just like integers or strings. This design choice forces you to acknowledge and handle errors immediately where they occur, rather than letting them bubble up and explode unexpectedly later.

Why No Exceptions?

    The "Check and Return" Idiom

    The most common pattern in Go is returning a value and an error as a tuple. If the error is not nil, you stop execution and return the error up the stack.

    go

    This "if err != nil" pattern might seem repetitive at first, but it ensures that you never use a "corrupted" or "nilled" value by accident.

    Creating and Customizing Errors

    Simple errors can be created using the errors.New function or fmt.Errorf if you need to include dynamic data.

    go

    Wrapping and Unwrapping

    Sometimes you want to add context to an error as it moves up the call stack. Go's fmt.Errorf supports the %w verb to "wrap" an error.

    go

    Advanced: Custom Error Types

    Because error is just an interface in Go (anything with an Error() string method), you can create your own error structs to carry extra diagnostic data.

    go

    Error Handling Tools

    errors.Is()errors.Is(err, os.ErrNotExist)

    Checks if an error (or any error in its chain) matches a specific target error.

    errors.As()errors.As(err, &myCustomErr)

    Tries to cast the error to a specific custom type to access its unique fields.

    Sentinel Errorsio.EOF

    Pre-defined error variables that you can compare against to identify specific conditions.

    Task / FeatureTraditional ExceptionsGo Explicit Errors
    Control FlowJump to catch blockLinear, predictable execution
    VisibilityImplicit (Hidden in the stack)Explicit (Visible in the code)
    EnforcementEasy to 'forget' and crashCompiler warns if values aren't used

    The Error Wrapping Chain

    Go 1.13 introduced formal error wrapping via the %w verb in fmt.Errorf. This creates a chain of errors where each error wraps its cause. You can then inspect the chain with errors.Is and errors.As:

    go

    This chain structure means you get both a human-readable error message with context at every layer AND the ability to programmatically check what the root cause was.


    Practical Pattern: Repository Error Types

    For production services, a clean pattern is defining error types at the repository layer that the service and handler layers can inspect:

    go

    This gives your HTTP handler precise control over status codes without needing to know the internal details of the error — it just asks "is this a not-found error?"


    Reducing if err != nil Boilerplate

    The repetitive if err != nil pattern is one of Go's most common criticisms. A few techniques reduce the noise:

    The Early Return Pattern

    Organise your code to return early on error at each step, keeping the "happy path" at the left margin:

    go

    Error Accumulator for Validation

    When you need to collect multiple errors (like form validation), accumulate them:

    go

    Further Reading

    For Go's catastrophic error handling mechanism, the next post covers Go panic, recover, and defer. For a different perspective on error handling, see our Python exception handling guide to contrast Go's approach with Python's try/except model. For testing your error handling logic, see Go testing with unit tests and benchmarks.


    Next Steps

    Now that you can handle errors like a pro, we need to talk about what happens when things go catastrophically wrong. In our next tutorial, we will explore Panic, Recover, and Defer, and learn how to manage the application lifecycle even during a crash.

    Common Go Error Handling Mistakes

    1. Swallowing errors with _ result, _ := doSomething() silently discards the error. This is only acceptable for operations that truly cannot fail (like writing to a bytes.Buffer). For anything that can fail — file I/O, network calls, database queries — always check the error.

    2. Using fmt.Errorf without %w when wrapping fmt.Errorf("failed: %v", err) creates a new error that loses the original type, making errors.Is and errors.As fail. Use fmt.Errorf("failed: %w", err) to wrap and preserve the original error for unwrapping.

    3. Comparing errors with == instead of errors.Is err == io.EOF works for sentinel errors, but fails for wrapped errors. errors.Is(err, io.EOF) traverses the error chain and handles wrapping correctly. Always prefer errors.Is and errors.As over direct comparison. See the Go blog on error handling.

    4. Panicking on expected errors panic is for unrecoverable programmer errors (nil dereference, index out of bounds). Expected runtime errors — file not found, network timeout — should be returned as values. Reserve panic for truly exceptional situations.

    5. Over-wrapping errors Wrapping every error through multiple layers with the same message produces noise: "service: repo: db: query: sql: no rows". Wrap at meaningful abstraction boundaries and skip wrapping when the message adds no context.

    Frequently Asked Questions

    What is the difference between errors.Is and errors.As? errors.Is(err, target) checks whether any error in the chain matches target by value (for sentinel errors) or implements a Is(error) bool method. errors.As(err, &target) checks whether any error in the chain can be assigned to target's type, letting you extract a concrete error type. The Go errors package docs cover both in detail.

    Should I define custom error types or use errors.New? Use errors.New("message") for simple sentinel errors that callers check with errors.Is. Define a custom error type (a struct implementing the error interface) when the error needs to carry additional data — for example, an HTTP status code or a field name that failed validation.

    How does Go's error handling compare to exceptions? Go returns errors as values rather than throwing them. This makes error paths explicit and forces the caller to decide how to handle each error immediately. The downside is verbosity. The upside is that control flow is always visible — no hidden exception propagation through call stacks.