Skip to main content
Go intermediate Lesson 13 of 25

Error Handling in Go

Master Go's error interface, create custom errors, use fmt.Errorf for wrapping, and apply errors.Is and errors.As for inspection.

The error Interface

In Go, errors are ordinary values — not exceptions and not special language constructs. Any type that implements the built-in error interface can be used as an error. This design keeps error handling explicit and local: you see exactly which calls can fail, and you handle each failure where it happens rather than in a distant catch block. It also means errors can carry arbitrary data, be composed, and be inspected with the full power of Go’s type system.

// The entire error interface — just one method
type error interface {
    Error() string
}

Functions signal failure by returning an error as the last return value. nil means success:

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

result, err := divide(10, 0)
if err != nil {
    fmt.Println("Error:", err) // Error: division by zero
    return
}
fmt.Println(result)

Always check errors immediately after the call that produced them.

Creating Errors

Go provides several ways to create errors, each suited to different situations. Simple string errors work for static messages. Formatted errors let you include dynamic values. Sentinel errors — package-level variables — are the right choice when callers need to identify a specific condition by comparing against a known value.

import (
    "errors"
    "fmt"
)

// Simple static error message
err1 := errors.New("something went wrong")

// Formatted error message with dynamic content (does not wrap)
err2 := fmt.Errorf("user %d not found", 42)

// Sentinel errors — package-level variables that callers can compare against
// Name them with the Err prefix by convention
var (
    ErrNotFound   = errors.New("not found")
    ErrPermission = errors.New("permission denied")
    ErrTimeout    = errors.New("operation timed out")
)

func findUser(id int) error {
    if id <= 0 {
        return ErrNotFound // return the sentinel directly
    }
    return nil
}

err := findUser(-1)
if err == ErrNotFound {
    fmt.Println("user does not exist")
}

Error Wrapping with fmt.Errorf and %w

As an error travels up the call stack, each layer should add context explaining what it was doing when the failure occurred. fmt.Errorf with the %w verb does exactly this: it creates a new error whose message includes your context string, while keeping the original error accessible inside the chain. The result reads like a breadcrumb trail — you can see exactly which operation failed and why.

func readConfig(path string) error {
    data, err := os.ReadFile(path)
    if err != nil {
        // %w wraps err — the original is preserved inside the new error
        return fmt.Errorf("readConfig %q: %w", path, err)
    }
    _ = data
    return nil
}

func loadApp() error {
    if err := readConfig("/etc/app/config.json"); err != nil {
        return fmt.Errorf("loadApp: %w", err) // wrap again with more context
    }
    return nil
}

err := loadApp()
// err.Error() = "loadApp: readConfig \"/etc/app/config.json\": open /etc/app/config.json: no such file or directory"
// Reads left-to-right: what failed (loadApp) → what it was doing (readConfig) → the root cause

The error message reads like a call stack — each layer adds context before the : separator.

errors.Is — Checking Sentinel Errors

Once errors are wrapped, you can no longer compare them with == — the wrapping adds a layer that breaks direct equality. errors.Is solves this by unwrapping the entire error chain and checking if any error in it matches your target. This lets you write defensive, layer-agnostic error checks that work regardless of how deeply an error has been wrapped.

var ErrNotFound = errors.New("not found")

func getRecord(id int) error {
    // wraps ErrNotFound with additional context
    return fmt.Errorf("getRecord %d: %w", id, ErrNotFound)
}

err := getRecord(99)

// Direct comparison fails — err is a *fmt.wrapError, not ErrNotFound
fmt.Println(err == ErrNotFound) // false

// errors.Is unwraps the chain and finds ErrNotFound inside
fmt.Println(errors.Is(err, ErrNotFound)) // true

if errors.Is(err, ErrNotFound) {
    fmt.Println("record does not exist — return 404")
}

errors.As — Extracting Typed Errors

errors.As is errors.Is for types rather than values. It walks the error chain looking for an error that can be assigned to your target type. When it finds one, it extracts it so you can access the type’s fields. This is how you retrieve structured error data — like an HTTP status code, a field name, or a database constraint — from deep inside a wrapped error chain.

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on %q: %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
}

func processUser(age int) error {
    if err := validateAge(age); err != nil {
        return fmt.Errorf("processUser: %w", err) // wrapped — but still extractable
    }
    return nil
}

err := processUser(-5)

var valErr *ValidationError
if errors.As(err, &valErr) {
    // valErr is now populated — access its fields directly
    fmt.Printf("Bad field: %s%s\n", valErr.Field, valErr.Message)
    // Bad field: age — must be between 0 and 150
}

Custom Error Types

Custom error types let you attach machine-readable data to failures. Instead of callers parsing error strings, they extract a typed value and switch on its fields. This is the right approach whenever different error conditions require different handling — for example, mapping HTTP status codes to user-facing messages or retry logic.

// HTTP-style status error — carries a numeric code alongside the message
type StatusError struct {
    Code    int
    Message string
}

func (e *StatusError) Error() string {
    return fmt.Sprintf("%d: %s", e.Code, e.Message)
}

func fetchData(url string) error {
    // simulate a 404 response
    return &StatusError{Code: 404, Message: "resource not found"}
}

err := fetchData("https://api.example.com/users/99")

var statusErr *StatusError
if errors.As(err, &statusErr) {
    // switch on the numeric code — no string parsing needed
    switch statusErr.Code {
    case 404:
        fmt.Println("Not found — check the ID")
    case 500:
        fmt.Println("Server error — retry later")
    default:
        fmt.Println("Unexpected status:", statusErr.Code)
    }
}

Joining Multiple Errors (Go 1.20+)

Some operations need to validate or process many things and report all the failures at once rather than stopping at the first one. errors.Join (introduced in Go 1.20) combines multiple errors into a single error value. The resulting error satisfies errors.Is and errors.As for any of the constituent errors, so callers can still inspect individual conditions.

import "errors"

// validateForm collects ALL validation failures instead of stopping at the first
func validateForm(name, email string, age int) error {
    var errs []error

    if name == "" {
        errs = append(errs, errors.New("name is required"))
    }
    if !strings.Contains(email, "@") {
        errs = append(errs, errors.New("invalid email"))
    }
    if age < 0 {
        errs = append(errs, errors.New("age must be non-negative"))
    }

    return errors.Join(errs...) // nil if errs is empty
}

err := validateForm("", "notanemail", -1)
if err != nil {
    fmt.Println(err)
    // name is required
    // invalid email
    // age must be non-negative
}

Panic and Recover

Panics are for truly unrecoverable situations: nil pointer dereferences, out-of-bounds index access, or violated invariants that mean the program cannot continue safely. They are not a substitute for error returns. The main use of recover is in library code that must not crash the caller — for example, an HTTP server that catches panics in a handler so one bad request doesn’t bring down the entire server.

func safeDivide(a, b int) (result int, err error) {
    defer func() {
        // recover catches any panic that occurred in this function
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered panic: %v", r)
        }
    }()
    return a / b, nil // integer division by zero causes a panic at runtime
}

result, err := safeDivide(10, 0)
fmt.Println(result, err) // 0 recovered panic: runtime error: integer divide by zero

recover() must be called inside a defer function to catch a panic. It’s primarily used in library code that must not crash the calling program.

Practical Example — Repository with Rich Errors

This example shows the full pattern: a sentinel error for generic matching, a custom type for structured details, and a custom Is method so both work together. Callers can do a broad check (errors.Is(err, ErrNotFound)) for HTTP status decisions, and a detailed check (errors.As) when they need to log which resource was missing.

package main

import (
    "errors"
    "fmt"
)

// Sentinel — for generic "not found" checks anywhere in the codebase
var ErrNotFound = errors.New("not found")

type User struct {
    ID   int
    Name string
}

// NotFoundError carries structured detail about what was missing
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)
}

// Is allows errors.Is(err, ErrNotFound) to match even though this is a different type
func (e *NotFoundError) Is(target error) bool {
    return target == ErrNotFound
}

type UserRepo struct {
    users map[int]User
}

func (r *UserRepo) Find(id int) (User, error) {
    u, ok := r.users[id]
    if !ok {
        // return the rich type — callers can use Is or As depending on what they need
        return User{}, &NotFoundError{Resource: "user", ID: id}
    }
    return u, nil
}

func main() {
    repo := &UserRepo{
        users: map[int]User{1: {1, "Alice"}, 2: {2, "Bob"}},
    }

    u, err := repo.Find(99)
    if err != nil {
        // Generic check — enough to decide on a 404 HTTP response
        if errors.Is(err, ErrNotFound) {
            fmt.Println("returning 404 to client")
        }

        // Detailed check — useful for logging or debugging
        var nfe *NotFoundError
        if errors.As(err, &nfe) {
            fmt.Printf("resource=%s id=%d\n", nfe.Resource, nfe.ID)
        }
        return
    }
    fmt.Println(u)
}

Frequently Asked Questions

Why does Go use errors as values instead of exceptions?
Go's designers believe that exceptional conditions are not exceptional — they're a normal part of program flow. Returning errors as values forces the caller to handle them explicitly at the call site, making error paths visible in the code rather than hidden in exception handlers.
What is error wrapping?
Error wrapping adds context to an error while preserving the original. Use fmt.Errorf with %w: return fmt.Errorf("opening config: %w", err). The wrapped error can be inspected later with errors.Is and errors.As.
What is the difference between errors.Is and errors.As?
errors.Is checks if an error (or any error in its chain) matches a specific sentinel error value. errors.As checks if any error in the chain matches a specific type and extracts it into a variable.