Skip to content
Codeloom
Go

Error Handling Best Practices in Go

Write idiomatic Go error handling — wrapping, sentinel errors, custom error types, errors.Is, errors.As, and patterns for clean error flows.

·4 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • Why Go uses explicit error returns instead of exceptions
  • Wrapping errors with fmt.Errorf and %w
  • Sentinel errors and custom error types
  • Inspecting error chains with errors.Is and errors.As

Prerequisites

  • Go basics (functions, structs, interfaces)
  • Familiarity with the error interface

Go handles errors through explicit return values, not exceptions. This design forces you to think about failure at every call site. Here is how to do it well.

The error interface

type error interface {
    Error() string
}

Any type with an Error() string method satisfies the interface. The standard library’s errors.New and fmt.Errorf create simple error values.

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

The if err != nil pattern

Check errors immediately after the call. This is the most common pattern in Go.

result, err := divide(10, 0)
if err != nil {
    log.Fatal(err)
}
fmt.Println(result)

Do not ignore errors silently. If a function returns an error, handle it or deliberately discard it with a comment explaining why.

Wrapping errors

Use fmt.Errorf with %w to add context while preserving the original error.

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

The %w verb wraps the error so callers can unwrap and inspect the chain.

Adding context at every layer

func loadApp() error {
    cfg, err := readConfig("app.yaml")
    if err != nil {
        return fmt.Errorf("loading app: %w", err)
    }
    // use cfg...
    return nil
}

The resulting error message reads like a stack: "loading app: reading config app.yaml: open app.yaml: no such file or directory".

Sentinel errors

A sentinel error is a package-level variable you compare against.

var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")

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

Checking with errors.Is

errors.Is walks the error chain to find a match.

user, err := findUser("123")
if errors.Is(err, ErrNotFound) {
    http.Error(w, "user not found", http.StatusNotFound)
    return
}
if err != nil {
    http.Error(w, "internal error", http.StatusInternalServerError)
    return
}

Never compare with == — it does not unwrap wrapped errors.

Custom error types

For errors that carry structured data, define a type.

type ValidationError struct {
    Field   string
    Message string
}

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

func validateAge(age int) error {
    if age < 0 || age > 150 {
        return &ValidationError{
            Field:   "age",
            Message: "must be between 0 and 150",
        }
    }
    return nil
}

Checking with errors.As

errors.As finds the first error in the chain that matches a target type.

var ve *ValidationError
if errors.As(err, &ve) {
    fmt.Printf("field %s is invalid: %s\n", ve.Field, ve.Message)
}

Handling multiple errors

Go 1.20+ supports wrapping multiple errors with errors.Join.

func validateUser(u User) error {
    var errs []error
    if u.Name == "" {
        errs = append(errs, &ValidationError{Field: "name", Message: "required"})
    }
    if u.Age < 0 {
        errs = append(errs, &ValidationError{Field: "age", Message: "must be positive"})
    }
    return errors.Join(errs...)
}

Patterns for clean error flow

Early return

func processOrder(id string) error {
    order, err := fetchOrder(id)
    if err != nil {
        return fmt.Errorf("fetching order: %w", err)
    }

    if err := validateOrder(order); err != nil {
        return fmt.Errorf("validating order: %w", err)
    }

    if err := chargePayment(order); err != nil {
        return fmt.Errorf("charging payment: %w", err)
    }

    return nil
}

Defer for cleanup

func writeFile(path string, data []byte) (err error) {
    f, err := os.Create(path)
    if err != nil {
        return fmt.Errorf("creating file: %w", err)
    }
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = fmt.Errorf("closing file: %w", cerr)
        }
    }()

    if _, err := f.Write(data); err != nil {
        return fmt.Errorf("writing data: %w", err)
    }
    return nil
}

Summary

Go error handling is verbose by design — it forces you to confront failures. Wrap errors with %w to build useful context chains. Use sentinel errors for known conditions, custom types for structured data, and errors.Is/errors.As to inspect them. Keep your error paths short with early returns.