Polyglot
Languages Go error handling
Go § error-handling

Error handling

Go does not have exceptions. Errors are values — instances of the built-in error interface — returned from functions as the last return value. The conventional pattern is if err != nil { return err } propagation; the errors and fmt packages admit error wrapping, type-based discrimination via errors.Is and errors.As, and structured error chains. For unrecoverable conditions, panic unwinds the goroutine; recover (used inside a defer) admits intercepting the panic. The combination — errors as values, explicit propagation, wrapping with %w, panic/recover for genuinely exceptional cases — is the substance of Go’s error story.

The error interface

The built-in error is an interface:

type error interface {
    Error() string
}

Any type with an Error() string method satisfies error:

type MyError struct {
    Code int
    Msg  string
}

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

var err error = &MyError{Code: 42, Msg: "not found"}
fmt.Println(err)                                  // "error 42: not found"

The nil value of any error type indicates “no error”:

var err error                                     // err == nil
if err == nil {
    fmt.Println("no error")
}

The conventional pattern

Functions that may fail return an error as the last return value:

func parseInt(s string) (int, error) {
    n, err := strconv.Atoi(s)
    if err != nil {
        return 0, fmt.Errorf("parse %q: %w", s, err)
    }
    return n, nil
}

n, err := parseInt("42")
if err != nil {
    return err
}
fmt.Println(n)

The conventional Go discipline:

  • Check the error immediately — the next line after the function call.
  • Return early on error, propagating up the call stack.
  • Wrap errors with context as they propagate.
  • Use nil for the success case.

The pattern is one of Go’s most distinctive idioms; it produces explicit, traceable error handling at the cost of substantial verbosity.

Creating errors

The errors.New and fmt.Errorf functions are the conventional constructors:

import "errors"

err := errors.New("something went wrong")
err := fmt.Errorf("invalid input: %s", input)
err := fmt.Errorf("operation failed: %w", inner)  // %w wraps

The %w verb (since Go 1.13) wraps the inner error, admitting later inspection with errors.Is and errors.As.

Sentinel errors

A sentinel error is a package-level variable representing a specific error condition:

package mypkg

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

func Lookup(key string) (Value, error) {
    /* ... */
    return Value{}, ErrNotFound
}

Callers compare with errors.Is:

v, err := mypkg.Lookup("key")
if errors.Is(err, mypkg.ErrNotFound) {
    return defaultValue
}
if err != nil {
    return err
}

The conventional naming is ErrXxx for exported sentinels.

Error types

For richer error information, define a struct type:

type ParseError struct {
    Line   int
    Column int
    Input  string
    Reason string
}

func (e *ParseError) Error() string {
    return fmt.Sprintf("parse error at %d:%d: %s (input: %q)",
        e.Line, e.Column, e.Reason, e.Input)
}

Callers extract the error via errors.As:

err := parse(input)
var pe *ParseError
if errors.As(err, &pe) {
    fmt.Printf("error at line %d\n", pe.Line)
}

The pattern admits structured error data while preserving error-chain traversal.

Error wrapping with %w

Wrapping admits adding context without losing the original error:

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

    var c Config
    if err := json.Unmarshal(data, &c); err != nil {
        return fmt.Errorf("parse config: %w", err)
    }

    return nil
}

err := loadConfig("/etc/myapp.conf")
// err.Error() = "read config /etc/myapp.conf: open /etc/myapp.conf: no such file or directory"

The wrapped chain admits inspection:

if errors.Is(err, os.ErrNotExist) {
    fmt.Println("file does not exist")
}

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    fmt.Printf("path: %s\n", pathErr.Path)
}

errors.Is and errors.As

The two principal inspection functions:

errors.Is

Checks whether the error chain contains a specific value (sentinel):

if errors.Is(err, ErrNotFound) {
    /* ... */
}

if errors.Is(err, io.EOF) {
    /* end of stream */
}

The function walks the chain (via Unwrap()) until it finds a match or reaches the bottom. The mechanism replaces the pre-1.13 pattern of if err == ErrNotFound, which would fail when the error was wrapped.

errors.As

Extracts the first error in the chain matching a type:

var netErr *net.OpError
if errors.As(err, &netErr) {
    fmt.Println("network error:", netErr.Op)
}

var pathErr *fs.PathError
if errors.As(err, &pathErr) {
    fmt.Println("path:", pathErr.Path)
}

The target must be a non-nil pointer to a type implementing error. On match, the target is set to the matching error.

Custom unwrapping

Custom error types may implement Unwrap() error to participate in the chain:

type MyError struct {
    Inner error
    Msg   string
}

func (e *MyError) Error() string { return e.Msg }
func (e *MyError) Unwrap() error { return e.Inner }

The errors.Is and errors.As functions walk the chain via Unwrap. For multi-error wrapping (since Go 1.20), Unwrap() []error admits a slice of wrapped errors:

type MultiError struct {
    Errs []error
}

func (m *MultiError) Error() string { /* ... */ }
func (m *MultiError) Unwrap() []error { return m.Errs }

The errors.Is and errors.As traverse all branches.

errors.Join

Since Go 1.20, errors.Join combines multiple errors:

errs := []error{
    parse(line1),
    parse(line2),
    parse(line3),
}

combined := errors.Join(errs...)
if combined != nil {
    return combined
}

The combined error’s Error() returns each error joined by newlines; errors.Is and errors.As traverse all the joined errors.

panic and recover

For genuinely unrecoverable conditions, panic unwinds the goroutine:

func mustParse(s string) int {
    n, err := strconv.Atoi(s)
    if err != nil {
        panic(fmt.Sprintf("invalid: %s", s))
    }
    return n
}

The panic propagates up the call stack, running deferred functions along the way; if it reaches the goroutine’s top, the program terminates with a stack trace.

recover (used inside a deferred function) admits intercepting a panic:

func safeCall() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic: %v", r)
        }
    }()

    riskyOperation()
    return nil
}

The conventional uses of panic:

  • Genuinely unrecoverable conditions — invariant violations, programmer bugs.
  • MustXxx functionsregexp.MustCompile, template.Must.
  • Initialisation failuresinit() functions panic on critical setup failures.

The conventional uses of recover:

  • HTTP handlers — recover panics so one failed request does not crash the server.
  • Worker goroutines — recover panics in spawned goroutines to keep the program alive.
  • FFI boundaries — recover before crossing into C code.

The discipline: panic for bugs, errors for failures. The conventional Go style returns errors for any condition the caller could plausibly handle.

Common patterns

Simple propagation

func process() error {
    if err := step1(); err != nil {
        return err
    }
    if err := step2(); err != nil {
        return err
    }
    if err := step3(); err != nil {
        return err
    }
    return nil
}

Wrapping at boundaries

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

    var c Config
    if err := json.Unmarshal(data, &c); err != nil {
        return nil, fmt.Errorf("loadConfig: parse: %w", err)
    }

    return &c, nil
}

The convention is to wrap at function boundaries with the function name, producing chains like:

loadConfig: parse: invalid character '}' looking for beginning of value

Sentinel-based dispatch

result, err := lookup(key)
switch {
case err == nil:
    return result, nil
case errors.Is(err, ErrNotFound):
    return defaultValue, nil
case errors.Is(err, ErrPermission):
    return zero, fmt.Errorf("access denied: %w", err)
default:
    return zero, fmt.Errorf("unexpected: %w", err)
}

Type-based dispatch

err := operation()

var netErr *net.OpError
if errors.As(err, &netErr) {
    return retryAfter(netErr.Timeout)
}

var validationErr *ValidationError
if errors.As(err, &validationErr) {
    return reportFieldError(validationErr.Field)
}

if err != nil {
    return fmt.Errorf("unknown: %w", err)
}

MustXxx for setup

var matchURL = regexp.MustCompile(`^https?://[^\s]+$`)
var template = template.Must(template.ParseFiles("layout.tmpl"))

The pattern is conventional for setup that should not fail; failures here are programmer errors.

Recovery in HTTP handlers

func recoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("panic: %v\n%s", rec, debug.Stack())
                http.Error(w, "internal server error", 500)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

defer for cleanup

func processFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("open: %w", err)
    }
    defer f.Close()                              // always closes

    return process(f)
}

The defer runs even if the function returns early via return. It does not run for os.Exit or external termination.

Multiple errors

func validateAll(items []Item) error {
    var errs []error
    for i, item := range items {
        if err := validate(item); err != nil {
            errs = append(errs, fmt.Errorf("item %d: %w", i, err))
        }
    }
    return errors.Join(errs...)                   // nil if errs is empty
}

Error with context (custom type)

type RequestError struct {
    Method string
    URL    string
    Err    error
}

func (e *RequestError) Error() string {
    return fmt.Sprintf("%s %s: %v", e.Method, e.URL, e.Err)
}

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

func get(url string) error {
    /* ... */
    return &RequestError{Method: "GET", URL: url, Err: ioErr}
}

The form admits structured errors with rich context preserved through wrapping.

Don’t ignore errors

// Bad:
result, _ := compute()                            // discarding error!

// Better — explicitly handle:
result, err := compute()
if err != nil {
    log.Printf("compute failed: %v", err)
    return defaultValue
}

The blank-identifier discard is sometimes warranted (e.g., Close() in defer where the result is unused), but routine usage is a code smell.

A note on the absence of try/catch

Go does not have try/catch. The principal trade-offs:

  • Verbosity — every error must be checked explicitly.
  • Visibility — error paths are immediately visible at the call site.
  • Stability — errors do not unwind the stack arbitrarily.
  • Predictability — control flow is explicit.

The conventional Go style accepts the verbosity in exchange for the visibility. Periodic proposals for syntactic conveniences (a “check” expression analogous to Rust’s ?) have not been accepted as of Go 1.24.

A note on the conventional discipline

The contemporary Go error-handling advice:

  • Return errors as the last value.
  • Check errors immediately — the next line.
  • Wrap with %w when adding context to propagated errors.
  • Use sentinels (ErrXxx) for static error values; use types for structured information.
  • Use errors.Is and errors.As — never compare errors with == after Go 1.13.
  • Use panic only for unrecoverable conditions (invariants, init failures).
  • Use recover at boundaries (HTTP handlers, worker goroutines).
  • Use defer for cleanupClose, Unlock.
  • Use errors.Join (Go 1.20+) for multi-error aggregation.

The combination — errors as values, explicit propagation, structured wrapping, sentinel and type-based discrimination, panic/recover for the truly exceptional — is the substance of Go’s error model. The discipline produces explicit, traceable error handling at the cost of verbosity.