DEV Community

Cover image for Mastering Error Handling in Go
Aditya
Aditya

Posted on

Mastering Error Handling in Go

Error handling is one of the most distinctive aspects of Go. Unlike languages that use exceptions, Go treats errors as values — plain return values that you explicitly check and handle. This design philosophy leads to more robust and predictable code once you understand how to work with it.

As Darth Vader would say: "I find your lack of error handling disturbing." In Go, there is no escaping this responsibility — errors are first-class citizens, and the language makes sure you know it.

In this guide, we will cover everything you need to master error handling in Go.

The Basics: Errors as Values

Like Neo in The Matrix discovering there is no spoon, Go programmers must accept a fundamental truth: there are no exceptions — there are only errors. Once you embrace this, everything clicks.

In Go, the built-in error interface is defined as:

type error interface {
    Error() string
}
Enter fullscreen mode Exit fullscreen mode

Functions that can fail conventionally return an error as the last return value:

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

func main() {
    result, err := divide(10, 0)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Result:", result)
}
Enter fullscreen mode Exit fullscreen mode

The nil check is the idiomatic Go way of checking whether an operation succeeded.

Creating Custom Errors

While errors.New and fmt.Errorf are convenient, you often need richer error types to convey more context.

Struct-based Custom Errors

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error on field %q: %s", e.Field, e.Message)
}

func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{Field: "age", Message: "must be non-negative"}
    }
    if age > 150 {
        return &ValidationError{Field: "age", Message: "unrealistically large value"}
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Custom error types let callers inspect the error and make decisions based on its fields.

Sentinel Errors

Think of sentinel errors as Obi-Wan Kenobi calmly waving his hand: "These aren't the errors you're looking for." They are named, predeclared values that let callers identify exactly what went wrong — no guesswork, no string parsing, just a clean identity check.

Sentinel errors are predeclared error values used to signal specific conditions. The standard library uses them extensively:

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

func findUser(id int) (*User, error) {
    user, ok := db[id]
    if !ok {
        return nil, ErrNotFound
    }
    return user, nil
}

// Callers can compare directly:
if errors.Is(err, ErrNotFound) {
    // handle not found case
}
Enter fullscreen mode Exit fullscreen mode

Sentinel errors are best for stable, well-known error conditions that callers need to branch on.

Error Wrapping with fmt.Errorf and %w

Go 1.13 introduced error wrapping, which lets you add context to an error while preserving the original:

func readConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("readConfig: %w", err)
    }
    // ...
    return config, nil
}
Enter fullscreen mode Exit fullscreen mode

The %w verb wraps the error, making it inspectable by errors.Is and errors.As.

Unwrapping Errors: errors.Is and errors.As

errors.Is — Checking for a Specific Error

errors.Is traverses the entire error chain looking for a matching value:

err := readConfig("missing.yaml")
if errors.Is(err, os.ErrNotExist) {
    fmt.Println("Config file does not exist")
}
Enter fullscreen mode Exit fullscreen mode

This works even when the error has been wrapped multiple layers deep.

errors.As — Extracting a Specific Error Type

errors.As traverses the chain looking for an error that can be assigned to the target type:

func processInput(input string) error {
    return fmt.Errorf("processInput: %w", &ValidationError{
        Field:   "input",
        Message: "cannot be empty",
    })
}

func main() {
    err := processInput("")
    var ve *ValidationError
    if errors.As(err, &ve) {
        fmt.Printf("Validation failed on field: %s\n", ve.Field)
    }
}
Enter fullscreen mode Exit fullscreen mode

Implementing the Unwrap Method

For custom error types that wrap another error, implement the Unwrap method so that errors.Is and errors.As can traverse the chain:

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string {
    return fmt.Sprintf("query %q failed: %v", e.Query, e.Err)
}

func (e *QueryError) Unwrap() error {
    return e.Err
}
Enter fullscreen mode Exit fullscreen mode

Panic and Recover

If you have ever read The Hitchhiker's Guide to the Galaxy, you know the most important advice printed on its cover in large, friendly letters: DON'T PANIC. Go shares this philosophy entirely.

While errors are the preferred mechanism for expected failure cases, Go provides panic and recover for truly exceptional situations.

func safeDiv(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered from panic: %v", r)
        }
    }()
    result = a / b
    return
}
Enter fullscreen mode Exit fullscreen mode

Use panic sparingly — typically only for programmer errors (e.g., invalid arguments to a function) or truly unrecoverable situations. Always recover at package boundaries.

Best Practices

1. Always Handle Errors

Remember the Black Knight from Monty Python and the Holy Grail? He kept insisting "It's just a flesh wound!" while losing every limb. Silently discarding errors with _ is the software equivalent — small ignored wounds that quietly become fatal bugs.

Never silently ignore errors. If you genuinely don't need to handle an error, document why with a comment.

// Bad
f, _ := os.Open("file.txt")

// Good
f, err := os.Open("file.txt")
if err != nil {
    return fmt.Errorf("open file: %w", err)
}
Enter fullscreen mode Exit fullscreen mode

2. Add Context When Wrapping

When wrapping errors, add context that describes what was being done — not just what failed.

// Less helpful
return fmt.Errorf("%w", err)

// More helpful
return fmt.Errorf("fetchUserProfile(id=%d): %w", id, err)
Enter fullscreen mode Exit fullscreen mode

3. Prefer errors.Is / errors.As Over Direct Comparison

Direct == comparison breaks when errors are wrapped. Use the standard library helpers instead.

// Fragile — fails if err is wrapped
if err == ErrNotFound { ... }

// Robust
if errors.Is(err, ErrNotFound) { ... }
Enter fullscreen mode Exit fullscreen mode

4. Return Errors, Don't Log and Return

In Ghostbusters, when something goes wrong, you call the Ghostbusters — you don't also fire the alarm, call the mayor, and call the Ghostbusters. Pick one. The same rule applies here: either log the error or return it, not both.

Avoid logging an error and then returning it — this leads to duplicate log entries. Either log it at the top level or return it for the caller to handle.

// Bad: logs AND returns
log.Printf("error: %v", err)
return err

// Good: just return and let the caller decide
return fmt.Errorf("doSomething: %w", err)
Enter fullscreen mode Exit fullscreen mode

5. Keep Error Messages Lowercase

By convention, Go error strings should be lowercase and not end with punctuation, since they are often composed into larger messages.

// Bad
errors.New("File not found.")

// Good
errors.New("file not found")
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

Here is a practical example combining everything we covered:

package main

import (
    "errors"
    "fmt"
    "strconv"
)

var ErrNegativeNumber = errors.New("negative number")

type ParseError struct {
    Input string
    Err   error
}

func (e *ParseError) Error() string {
    return fmt.Sprintf("parse error for input %q: %v", e.Input, e.Err)
}

func (e *ParseError) Unwrap() error {
    return e.Err
}

func parsePositive(s string) (int, error) {
    n, err := strconv.Atoi(s)
    if err != nil {
        return 0, &ParseError{Input: s, Err: err}
    }
    if n < 0 {
        return 0, &ParseError{Input: s, Err: ErrNegativeNumber}
    }
    return n, nil
}

func main() {
    inputs := []string{"42", "-5", "abc"}

    for _, input := range inputs {
        val, err := parsePositive(input)
        if err != nil {
            var pe *ParseError
            if errors.As(err, &pe) {
                fmt.Printf("Bad input: %s\n", pe.Input)
            }
            if errors.Is(err, ErrNegativeNumber) {
                fmt.Println("Hint: provide a positive number")
            }
            continue
        }
        fmt.Printf("Parsed: %d\n", val)
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Go error handling requires a bit of a mindset shift if you are coming from exception-based languages, but the explicitness it enforces pays dividends in code clarity and reliability. By treating errors as values, wrapping them with context, and using errors.Is / errors.As for inspection, you can write Go programs that fail gracefully and are easy to debug.

As Samwise Gamgee wisely put it: "It's a dangerous business, going out your door... but I suppose the answer is to keep going." The same goes for error handling — keep wrapping, keep checking, and your code will be as resilient as the Fellowship of the Ring.

Happy coding!

Top comments (0)