Back to Scaling Go Applications guides

The Fundamentals of Error Handling in Go

Ayooluwa Isaiah
Updated on April 17, 2025

Error handling is a critical aspect of writing robust and maintainable Go applications.

Unlike many other modern programming languages that rely on exceptions, Go takes a different approach by treating errors as values that can be directly manipulated, checked, and passed around.

This approach aligns with Go's philosophy of simplicity and explicitness, putting error handling front and center in your code.

When you're coming from languages like Java, Python, or JavaScript that use try/catch exception mechanisms, Go's approach might initially feel strange or even tedious.

codeimage-snippet_17.png

However, there's profound reasoning behind this design choice. Go's creators intentionally avoided exceptions because they can create invisible control flows and make it difficult to understand how a program behaves when failures occur.

By making errors explicit values that must be checked, Go forces developers to think deliberately about failure cases.

Go's error handling model encourages developers to think about potential failure points and handle them appropriately, leading to more reliable software.

While sometimes criticized for its verbosity, this pattern has proven effective for building production-grade systems where reliability is paramount.

Go's error handling fundamentals

At the core of Go's error handling is the built-in error interface, which is elegantly simple:

 
type error interface {
    Error() string
}

This minimal interface requires only a single method that returns a string description of the error.

What makes this interface powerful is its simplicity. Unlike complex exception hierarchies in other languages, Go's error interface focuses solely on providing a human-readable description of what went wrong.

The implementation details of how errors are created, processed, and propagated are left to the developer, providing flexibility without complexity.

The multiple return values pattern

Go functions commonly return an error as the last return value, allowing callers to check if an operation succeeded.

This pattern leverages Go's ability to return multiple values from a function:

 
func ReadFile(filename string) ([]byte, error) {
    file, err := os.Open(filename)
    if err != nil {
        return nil, fmt.Errorf("failed to open file: %w", err)
    }

    defer file.Close()

    // Read file implementation...
    return data, nil
}

When calling such functions, you explicitly handle the returned error:

 
data, err := ReadFile("config.json")
if err != nil {
    log.Fatalf("Could not read config: %v", err)
}

// Use data safely, knowing the operation succeeded

This pattern forces developers to consider error cases at every step, making it harder to accidentally ignore errors.

Notice how each step in the process explicitly checks for errors before proceeding. This creates a clear and predictable flow of control in your programs.

The multiple return values pattern is pervasive in Go code, with most functions that can fail returning an error value.

This consistency makes Go code predictable: when you see a function that returns an error, you immediately know you need to check it before using other returned values.

Errors as values

In Go, errors are just values that can be passed around, compared, and manipulated like any other value. This "errors as values" approach gives developers explicit control over error handling logic.

You can store errors in variables, check them, add context to them, and build sophisticated error handling strategies around them.

For example, you might implement a retry mechanism by storing an error and attempting an operation multiple times:

 
func processWithRetry(task func() error) error {
    const maxRetries = 3

    var lastErr error

    for attempts := 0; attempts < maxRetries; attempts++ {
        err := task()
        if err == nil {
            return nil  // Success!
        }

        lastErr = err
        // Backoff and retry logic...
    }

    // All attempts failed
    return fmt.Errorf("failed after %d attempts: %w", maxRetries, lastErr)
}

The approach allows us to store errors in variables, return them from functions, pass them as parameters, add context, compare them with other errors, and extract structured information from them.

This flexibility makes Go's error handling extraordinarily powerful despite its simplicity.

The idiomatic if err != nil pattern

The most recognizable pattern in Go code is the error check:

 
if err != nil {
    // Handle error
    return nil, fmt.Errorf("operation failed: %w", err)
}
// Continue with normal execution

This explicit checking might seem verbose, especially to developers coming from exception-based languages, but it has several benefits.

Error paths are immediately visible in the code, making it easier to understand what happens when things go wrong. Developers must make a conscious decision about each error, reducing the chance of overlooking failure cases. The happy path (normal execution) and error paths are clearly separated, improving code readability.

When misused, this pattern can lead to deeply nested code or excessive error handling boilerplate. Good Go code organizes functions to minimize this nesting while still maintaining proper error handling.

This often means breaking complex operations into smaller functions, each with their own error handling.

Creating and customizing errors

The standard library provides several ways to create and customize errors to make them more informative and useful. Understanding these options helps you provide better error information to callers of your code.

Using errors.New() and fmt.Errorf()

The simplest way to create a new error is with the errors.New() function, which takes a string message and returns an error:

 
func validateAge(age int) error {
    if age < 0 {
        return errors.New("age cannot be negative")
    }
    // Additional validation...
    return nil  // Validation passed
}

For more complex error messages that include dynamic values, fmt.Errorf() is the go-to function:

 
func validateAge(age int) error {
    if age < 0 {
        return fmt.Errorf("invalid age %d: cannot be negative", age)
    }
    // Additional validation...
    return nil
}

These functions produce simple string-based errors that communicate what went wrong. The errors.New() function is best for static error messages, while fmt.Errorf() shines when you need to include variable values in your error messages.

Creating custom error types

For more sophisticated error handling, especially in libraries or larger applications, custom error types provide additional capabilities:

custom_error.go
type ValidationError struct {
    Field string
    Value interface{}
    Reason string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on field %s with value %v: %s",
                       e.Field, e.Value, e.Reason)
}

func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{
            Field: "age",
            Value: age,
            Reason: "cannot be negative",
        }
    }
    return nil
}

Custom error types offer several advantages. They can include structured data about the error, enable type-based error handling, implement additional interfaces beyond error, and allow for more sophisticated error hierarchies.

When creating custom error types, the Go community generally advises keeping them simple and focused on providing actionable information. Avoid creating deep hierarchies of error types, as this tends to make error handling more complex.

Custom error types are especially valuable in library code, where they help users of your library distinguish between different failure modes and respond appropriately. For instance, a network library might define separate error types for connection timeouts, authentication failures, and invalid requests.

Error wrapping with %w verb

Go 1.13 introduced error wrapping, which allows errors to be chained together while preserving the original error. This feature addresses a common need to add context to errors while preserving their original identity:

 
func processFile(filename string) error {
    data, err := ioutil.ReadFile(filename)
    if err != nil {
        return fmt.Errorf("failed to read file %s: %w", filename, err)
    }

    // Additional processing...
    return nil
}

The %w verb in fmt.Errorf() wraps the original error, preserving it for inspection later. This allows for adding context at each level while maintaining the ability to examine the original error. This is particularly valuable in larger applications where an error might pass through multiple layers of abstraction.

Without the %w verb, the original error would be converted to a string and wrapped in a new error, losing its identity and type. The %w verb preserves the original error as a wrapped error, allowing code higher up in the call stack to inspect both the wrapping error (with its added context) and the original error.

Error unwrapping with errors.Unwrap()

The counterpart to wrapping is unwrapping, which allows accessing the underlying error. The standard library provides the errors.Unwrap() function for this purpose:

 
func handleError(err error) {
    fmt.Printf("Received error: %v\n", err)

    // Unwrap one level
    if wrappedErr := errors.Unwrap(err); wrappedErr != nil {
        fmt.Printf("Unwrapped once: %v\n", wrappedErr)
    }
}

Error unwrapping is particularly useful when you need to examine the cause of an error that might be wrapped in layers of context. This ability becomes even more powerful when combined with the errors.Is() and errors.As() functions we'll explore next.

Advanced error handling techniques

Go's error handling system may appear simple at first, but it offers sophisticated capabilities for more complex applications.

The following techniques are used in production Go code to create robust error handling strategies that provide both actionable information and the ability to make programmatic decisions based on error conditions.

Error inspection using errors.Is() and errors.As()

Go 1.13 introduced errors.Is() and errors.As() to make working with wrapped errors more convenient and to address common error inspection needs:

 
// Using errors.Is to check if a specific error is in the chain
if errors.Is(err, os.ErrNotExist) {
    // Handle case where file doesn't exist
    return fmt.Errorf("config file not found, creating default: %w", err)
}

// Using errors.As to check if any error in the chain is of a specific type
var validationErr *ValidationError
if errors.As(err, &validationErr) {
    // We can now use the fields of validationErr
    fmt.Printf("Validation failed for field %s\n", validationErr.Field)
}

The errors.Is() function checks if an error or any error it wraps matches a specific error value, while errors.As() checks if an error or any error it wraps matches a specific error type.

These functions are particularly powerful because they automatically traverse the entire error wrapping chain, handle edge cases like nil errors, respect custom implementations of Is() and As() methods, and make error inspection code much cleaner.

Many libraries now define their own error types and sentinel errors, and using errors.Is() and errors.As() lets you work with these errors consistently even when they're wrapped with additional context.

Sentinel errors vs. error types

In Go, there are two main approaches to creating identifiable errors: sentinel errors and error types.

Sentinel errors are exported variables of type error that represent specific error conditions:

 
var (
    ErrNotFound = errors.New("resource not found")
    ErrPermissionDenied = errors.New("permission denied")
    ErrTimeout = errors.New("operation timed out")
)

func FindResource(id string) (*Resource, error) {
    // Implementation logic...
    if resourceNotFound {
        return nil, ErrNotFound
    }
    return resource, nil
}

Error types are custom structures that implement the error interface, allowing you to include additional context:

error_types.go
type NotFoundError struct {
    ResourceType string
    ID string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s with ID %s not found", e.ResourceType, e.ID)
}

Sentinel errors are simple to define and use. They're ideal when you need to represent a specific condition without additional context, you want callers to compare errors directly with errors.Is(), the error doesn't need to carry extra data, and you want to minimize API surface area.

Error types are more complex but offer greater flexibility. They're ideal when you need to include contextual information with the error, you want to provide structured data about what went wrong, you want to enable specific handling for different error scenarios, and you need to define behaviors specific to this error type.

Many Go libraries use a combination of both approaches. For example, the standard library's os package defines sentinel errors like os.ErrNotExist while also providing error types like os.PathError. The right approach depends on how much information you need to convey and how callers need to handle the errors.

Error handling in concurrent code

Handling errors in concurrent Go code requires special consideration. When you have multiple goroutines running simultaneously, you need strategies to collect and handle errors from all of them.

A common pattern is to use error channels to propagate errors from goroutines:

 
func processItems(items []Item) error {
    errCh := make(chan error, len(items))

    var wg sync.WaitGroup
    for _, item := range items {
        wg.Add(1)
        go func(it Item) {
            defer wg.Done()
            if err := processItem(it); err != nil {
                errCh <- err
            }
        }(item)
    }

    wg.Wait()
    close(errCh)

    // Collect errors
    var errs []error
    for err := range errCh {
        errs = append(errs, err)
    }

    if len(errs) > 0 {
        return fmt.Errorf("processing failed with %d errors", len(errs))
    }

    return nil
}

This pattern allows you to run multiple operations concurrently, collect all errors that occur, and handle them appropriately after all operations complete. The buffered channel is crucial here as it prevents goroutines from blocking when they encounter errors.

For more sophisticated concurrent error handling, the golang.org/x/sync/errgroup package provides an elegant solution that manages the wait group internally, cancels the context when any goroutine returns an error, returns the first error encountered, and handles proper synchronization for you.

Context package integration with errors

The context package is often used together with errors to handle timeouts, cancellations, and request-scoped values. This integration is particularly important in networked applications, APIs, and services where operations need to be time-bound or cancellable.

 
func fetchWithTimeout(ctx context.Context, url string, timeout time.Duration) ([]byte, error) {
    ctx, cancel := context.WithTimeout(ctx, timeout)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, fmt.Errorf("failed to create request: %w", err)
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        if ctx.Err() == context.DeadlineExceeded {
            return nil, fmt.Errorf("request timed out after %v: %w", timeout, err)
        }
        return nil, fmt.Errorf("request failed: %w", err)
    }
    defer resp.Body.Close()

    // Read response body...
    return data, nil
}

The context package is especially important in network services where requests should never hang indefinitely. By integrating contexts with your error handling, you create more responsive and resilient applications that can handle timeouts and cancellations gracefully.

Final thoughts

Go's approach to error handling stands out in the programming language landscape for its simplicity and explicitness.

By treating errors as values and making error checking a visible part of the code, Go promotes reliability and maintainability.

While this can lead to more verbose code compared to exception-based languages, it results in clearer error paths and more thoughtful error handling.

The if err != nil pattern, often criticized for its repetitiveness, actually becomes a strength in practice.

It forces developers to consider failure modes at every step and prevents errors from being silently ignored. Combined with Go's multiple return values and the error wrapping capabilities introduced in Go 1.13, this creates a powerful yet straightforward system for managing errors.

As your Go applications grow in complexity, leveraging advanced techniques like custom error types, error wrapping, and the context package becomes increasingly important. These tools allow you to build robust systems that fail gracefully and provide meaningful error information that helps diagnose and resolve problems.

Remember that effective error handling isn't just about detecting failures—it's about providing meaningful information that helps diagnose and resolve problems.

By embracing Go's error handling patterns and building upon them with the techniques discussed in this article, you can create robust applications that handle the unexpected with confidence and clarity.

Author's avatar
Article by
Ayooluwa Isaiah
Ayo is a technical content manager at Better Stack. His passion is simplifying and communicating complex technical ideas effectively. His work was featured on several esteemed publications including LWN.net, Digital Ocean, and CSS-Tricks. When he's not writing or coding, he loves to travel, bike, and play tennis.
Got an article suggestion? Let us know
Next article
Dockerizing Go Applications: A Step-by-Step Guide
Learn how to run Go applications confidently within Docker containers either locally or on your chosen deployment platform
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github