Skip to content
Home
Go Error Handling: Best Practices and Patterns

Go Error Handling: Best Practices and Patterns

Go Go 8 min read 1549 words Beginner ExcellentWiki Editorial Team

Error handling in Go is deliberately explicit. There are no exceptions, no try-catch blocks, and no unchecked propagations. Functions that can fail return an error value as their last return value, and callers are expected to check it. This explicitness makes error paths visible in the code, encourages handling errors at the appropriate level, and avoids the hidden control flow that exceptions create.

The trade-off is that error handling can feel verbose, especially to developers coming from exception-based languages. However, Go’s error handling idioms — wrapping, sentinel errors, type assertions, and the errors package — provide everything needed to build robust, debuggable systems.

The Error Interface

At its core, error handling in Go is built on a single interface:

type error interface {
    Error() string
---

Any type that implements Error() string satisfies the interface. This means you can create custom error types trivially:

type NotFoundError struct {
    Resource string
    ID       int
---

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s with id %d not found", e.Resource, e.ID)
---

Basic Error Handling Pattern

func readConfig(path string) (Config, error) {
    file, err := os.Open(path)
    if err != nil {
        return Config{}, err
    }
    defer file.Close()

    var cfg Config
    if err := json.NewDecoder(file).Decode(&cfg); err != nil {
        return Config{}, err
    }
    return cfg, nil
---

Every call that can fail returns an error. The convention is to check err != nil immediately and return early. The zero value for the success type is returned alongside the error — callers should not use the value when err is non-nil.

Sentinel Errors

Sentinel errors are predefined error values used as signals:

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

func GetUser(id int) (*User, error) {
    if id <= 0 {
        return nil, ErrNotFound
    }
    // ...
---

Callers compare against the sentinel:

user, err := GetUser(42)
if err == ErrNotFound {
    // handle not found
--- else if err != nil {
    // handle other errors
---

Use errors.Is instead of == when errors may be wrapped (see below):

if errors.Is(err, ErrNotFound) {
    // works even if err is wrapped
---

Custom Error Types

For errors that carry additional context, define a custom type with methods:

type ValidationError struct {
    Field string
    Value any
    Rule  string
---

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed: %s %v (rule: %s)", e.Field, e.Value, e.Rule)
---

// Optional: implement Unwrap to support errors.Is / errors.As
type DetailedError struct {
    Msg string
    Err error
---

func (e *DetailedError) Error() string {
    return e.Msg
---

func (e *DetailedError) Unwrap() error {
    return e.Err
---

Use errors.As to check for and extract a custom error type:

var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Printf("Field %s failed rule %s\n", valErr.Field, valErr.Rule)
---

Error Wrapping

Wrapping adds context to an error while preserving the original error for inspection:

func loadConfig(path string) (Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return Config{}, fmt.Errorf("reading config: %w", err)
    }

    var cfg Config
    if err := yaml.Unmarshal(data, &cfg); err != nil {
        return Config{}, fmt.Errorf("parsing config %s: %w", path, err)
    }
    return cfg, nil
---

The %w verb in fmt.Errorf wraps the error, creating an error chain that errors.Is and errors.As can traverse. Use %v instead of %w when you do not want the wrapping to be inspectable:

// Wrapped — inspectable with errors.Is/As
fmt.Errorf("reading config: %w", err)

// Not wrapped — message only
fmt.Errorf("reading config: %v", err)

When to Wrap

Wrap errors when adding context about the current operation. Do not wrap errors that are already descriptive enough or when the wrapping adds no meaningful context. A good rule of thumb: every function in the call chain except the original source and the top-level handler should wrap the error with contextual information.

errors.Is and errors.As

These two functions traverse the error chain created by wrapping:

// errors.Is — check for a specific error value
if errors.Is(err, io.EOF) {
    // reached end of file
---

// errors.As — check for a specific error type and extract it
var netErr *net.DNSConfigError
if errors.As(err, &netErr) {
    fmt.Printf("DNS configuration error: %v\n", netErr)
---

errors.Is checks each error in the chain using == (or the Is method if defined). errors.As checks each error using type assertion (or the As method if defined). Both short-circuit on the first match.

Custom Is/As Methods

Implement the Is or As method on your error type to customize matching:

type RetryableError struct {
    Err error
---

func (e *RetryableError) Error() string {
    return fmt.Sprintf("retryable: %v", e.Err)
---

func (e *RetryableError) Unwrap() error {
    return e.Err
---

// Custom Is — match any retryable error
func (e *RetryableError) Is(target error) bool {
    _, ok := target.(*RetryableError)
    return ok
---

Defer, Panic, and Recover

Panics are Go’s equivalent of exceptions, but they should be used sparingly:

func safeOperation() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("Recovered from panic: %v", r)
        }
    }()

    riskyOperation() // if this panics, we recover gracefully
---

Use panics only for truly unrecoverable situations: programmer errors (nil pointer dereference, out-of-bounds access), initialization failures where the program cannot function, or unimplemented code. Never use panics for routine error handling — that is what the error interface is for.

Common Patterns

Retry with Backoff

func fetchWithRetry(url string, maxRetries int) (*http.Response, error) {
    var err error
    for i := 0; i < maxRetries; i++ {
        var resp *http.Response
        resp, err = http.Get(url)
        if err == nil {
            return resp, nil
        }
        time.Sleep(time.Duration(math.Pow(2, float64(i))) * 100 * time.Millisecond)
    }
    return nil, fmt.Errorf("fetching %s after %d retries: %w", url, maxRetries, err)
---

Error Group with errgroup

For concurrent operations, the errgroup package from golang.org/x/sync provides structured error handling:

import "golang.org/x/sync/errgroup"

func fetchAll(urls []string) ([]*http.Response, error) {
    g, ctx := errgroup.WithContext(context.Background())
    results := make([]*http.Response, len(urls))

    for i, url := range urls {
        i, url := i, url
        g.Go(func() error {
            req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
            resp, err := http.DefaultClient.Do(req)
            if err != nil {
                return fmt.Errorf("fetching %s: %w", url, err)
            }
            results[i] = resp
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return results, nil
---

Best Practices

Check errors immediately — Do not stash an error and continue processing. The early return pattern keeps error-free paths clear and makes review obvious.

Add context when wrapping — A wrapped error should answer “what were you trying to do?” Do not wrap with empty strings or redundant messages.

Use sentinel errors sparingly — They create coupling between packages. Consider exporting an Is function instead of exporting the sentinel value if you want to decouple.

Log at the boundary — Log errors where they cross a system boundary (HTTP handler, background worker, CLI command). Internal functions should return errors, not log them.

Avoid _ = fn() — If you are deliberately ignoring an error, document why with a comment. Better yet, log it.

Error Handling in Concurrent Code

When errors occur in goroutines, they must be propagated back to the caller. The errgroup package from golang.org/x/sync is the standard approach:

func processBatch(items []Item) error {
    g, ctx := errgroup.WithContext(context.Background())
    results := make([]Result, len(items))

    for i, item := range items {
        i, item := i, item
        g.Go(func() error {
            result, err := process(ctx, item)
            if err != nil {
                return fmt.Errorf("processing item %d: %w", i, err)
            }
            results[i] = result
            return nil
        })
    }

    return g.Wait() // returns the first non-nil error
---

For simpler cases, use channels to collect errors:

func collectErrors() error {
    errCh := make(chan error, 3)
    var wg sync.WaitGroup

    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            if err := doWork(id); err != nil {
                errCh <- err
            }
        }(i)
    }

    go func() {
        wg.Wait()
        close(errCh)
    }()

    var errs []error
    for err := range errCh {
        errs = append(errs, err)
    }
    return errors.Join(errs...)
---

Summary

Go’s error handling is explicit by design. The error interface is minimal but extensible through custom types. Sentinel errors signal specific conditions. Wrapping adds context while preserving the original error for inspection. errors.Is and errors.As traverse the error chain. Panics are reserved for truly exceptional situations. Following these patterns produces code where error paths are visible, debuggable, and resilient.


Related: Learn about Go packages and modules and JSON handling.

FAQ

Q: What is the difference between errors.Is and errors.As? A: errors.Is checks if an error matches a specific value (sentinel). errors.As checks if an error matches a specific type and extracts it.

Q: When should I use %w vs %v in fmt.Errorf? A: Use %w when callers need to inspect the wrapped error with errors.Is or errors.As. Use %v when the error message alone is sufficient.

Q: Should errors be logged where they occur or at the top level? A: Log at system boundaries (HTTP handlers, CLI commands, workers). Internal functions should return errors without logging them to avoid duplicate log entries.

Q: How do I handle panics in production? A: Use defer and recover at goroutine boundaries. Never let a panic crash your server — always recover in HTTP middleware or worker entry points.

Q: What is errors.Join used for? A: errors.Join combines multiple errors into a single error value. It is useful for batch operations where multiple errors need to be reported together.

Section: Go 1549 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top