Skip to content

Error Handling

Scope: Go's errors-as-values model: the error type, the if err != nil pattern, wrapping, and custom errors. No exceptions exist — this is the whole system. All examples were compiled and run.

The core idea

In Go, errors are values, not exceptions. A function that can fail returns an error as its last return value. The caller checks it. There's no throwing, no catching, no invisible control flow — failure handling is right there in the code, every time.

The pattern you'll write a thousand times

f, err := os.Open("data.txt")
if err != nil {
    log.Fatal(err)
}
defer f.Close()

That's it. Call, check err, handle it. It looks repetitive at first; after a week it becomes invisible, and you'll miss it in languages that hide failures.

What error actually is

type error interface {
    Error() string
}

error is a built-in interface with one method. Anything with an Error() string method is an error. Creating one:

import "errors"

err := errors.New("something went wrong")

Or with formatting:

import "fmt"

err := fmt.Errorf("user %s not found", name)

Wrapping: adding context as errors travel up

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

%w (not %v) wraps the original error inside the new one. Wrapping preserves the chain: callers can see your message ("loadConfig config.yaml") and still unwrap down to the root cause ("file not found"). Always use %w when adding context.

Checking the chain:

if errors.Is(err, os.ErrNotExist) {
    fmt.Println("file's missing — using defaults")
}

errors.Is looks through the whole wrap chain for a match.

Custom error types

When callers need to distinguish kinds of failure programmatically, define a type:

type NotFoundError struct {
    Resource string
}

func (e *NotFoundError) Error() string {
    return "not found: " + e.Resource
}

Callers then check with errors.As:

var nf *NotFoundError
if errors.As(err, &nf) {
    fmt.Println("missing:", nf.Resource)
}

Most programs never need this. Start with fmt.Errorf and errors.Is; reach for custom types when callers must branch on the error kind.

panic: the (rare) escape hatch

panic("unreachable")

panic crashes the program. Legitimate uses: impossible situations ("this can't happen"), and quick prototypes. Go's panic is like Rust's — for bugs, not for expected failures like bad input or missing files. Those get returned as error.

Libraries should essentially never panic on external input.

The decision table

Situation Do this
Function can fail Return error as the last value
Checking a call if err != nil immediately
Adding context fmt.Errorf("...: %w", err)
Caller must distinguish kinds Custom error type + errors.As
Impossible situation / prototype panic

Check

  • [ ] You can write the if err != nil pattern from memory
  • [ ] You know the difference between %w and %v in Errorf
  • [ ] You can say when panic is appropriate and when it isn't

Next: Troubleshooting.