Go (Golang) — Context and Error Handling: A Deep, Comprehensive Guide

The context package and error handling — two of the most important and most misunderstood topics in idiomatic Go.

🌱 Seedling·created: ·category:Golang

This document covers two of the most important — and most misunderstood — topics in idiomatic Go programming: the context package and error handling. It is written for developers who already know basic Go syntax and want to master these two areas deeply, including internals, patterns, pitfalls, and real-world examples.


Table of Contents

  1. Part I — The context Package
  2. Part II — Error Handling in Go
  3. Part III — Combining Context and Errors
  4. Appendix: Quick Reference Tables

Part I — The context Package

1. Why Context Exists

Before Go 1.7 (when context was moved into the standard library from golang.org/x/net/context), there was no standard way to:

  • Cancel long-running operations (e.g., abandon a database query if the client disconnected).
  • Propagate deadlines/timeouts through a call chain spanning multiple function calls, goroutines, and even network boundaries (RPC).
  • Carry request-scoped values (like trace IDs) across API boundaries without changing every function signature.

Every team invented its own ad-hoc solution: a done chan struct{}, a time.Timer, custom Cancellable interfaces, etc. This fragmentation made libraries incompatible with each other. context.Context unified this into one interface that every layer of a Go program — HTTP handlers, gRPC services, database drivers, goroutines — can understand and cooperate with.

The core idea: Context is not about data first; it’s about cancellation signaling first, and deadline propagation second. Carrying values is a distant third and is often overused.

2. The Context Interface

type Context interface {
    // Deadline returns the time when work done on behalf of this context
    // should be canceled. Deadline returns ok==false when no deadline is set.
    Deadline() (deadline time.Time, ok bool)

    // Done returns a channel that's closed when work done on behalf of this
    // context should be canceled. Done may return nil if this context can
    // never be canceled.
    Done() <-chan struct{}

    // Err returns a non-nil error explaining why Done was closed.
    // If Done is not yet closed, Err returns nil.
    // If Done is closed, Err returns Canceled or DeadlineExceeded.
    Err() error

    // Value returns the value associated with this context for key,
    // or nil if none.
    Value(key any) any
}

Key observations:

  • Context is an interface, not a struct. Different implementations exist (emptyCtx, cancelCtx, timerCtx, valueCtx).
  • It is designed to be read-only and immutable from the consumer’s point of view. You never mutate a context; you always derive a new one from a parent.
  • Done() returns a channel, not a boolean, so it can be used directly inside a select statement — this is idiomatic Go concurrency.
  • Err() tells you why the context was cancelled: either context.Canceled (explicit cancellation) or context.DeadlineExceeded (timeout/deadline hit).

Why a channel and not a callback?

Go favors composability via channels over callback registration. A <-chan struct{} can be selected alongside other channels (I/O, timers, other done signals) without any special-casing. This is a deliberate design choice that keeps context orthogonal to the rest of Go’s concurrency primitives.

3. The Root Contexts

ctx1 := context.Background()
ctx2 := context.TODO()
  • context.Background(): The root of every context tree. It’s never cancelled, has no deadline, and holds no values. Used in main(), in tests, and as the top-level context when initializing a request or a long-running process.
  • context.TODO(): Semantically identical to Background() at runtime, but signals intent: “I know this function should take a context, but I haven’t wired it through yet, or I’m not sure which context to use.” It’s a marker for refactoring — grep-able and meant to be temporary.

Rule of thumb: If you’re not sure which context to pass and there truly is no parent context, use TODO(). If you’re deliberately creating a top-level context (e.g., in main), use Background().

4. Cancellation: context.WithCancel

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

WithCancel derives a child context from a parent. It returns a new context and a cancel function. Calling cancel():

  1. Closes the Done() channel of the returned context.
  2. Sets Err() to context.Canceled.
  3. Propagates cancellation to all descendant contexts derived from it.
  4. Removes the child from the parent’s tracking structures (to avoid memory leaks).

Example

func worker(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("worker %d stopping: %v\n", id, ctx.Err())
            return
        default:
            // do some unit of work
            time.Sleep(200 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    for i := 1; i <= 3; i++ {
        go worker(ctx, i)
    }

    time.Sleep(1 * time.Second)
    cancel() // signal all workers to stop
    time.Sleep(500 * time.Millisecond)
}

Critical rule: always call cancel()

Even if the context finishes “naturally” (e.g., the operation completes successfully), you must call cancel() to release resources associated with the context — most importantly, to allow the Go runtime to garbage-collect the internal state and to stop any associated timer (in the case of WithTimeout/WithDeadline). The idiomatic pattern is:

ctx, cancel := context.WithCancel(parent)
defer cancel()

Forgetting this is one of the most common sources of goroutine and memory leaks in production Go services.

5. Timeouts and Deadlines

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
  • WithTimeout(parent, d) is literally implemented as WithDeadline(parent, time.Now().Add(d)).
  • WithDeadline cancels the context automatically when the wall-clock time reaches d, or when cancel() is called manually, or when the parent is cancelled — whichever happens first.
  • When the deadline is reached, ctx.Err() returns context.DeadlineExceeded.

Example: HTTP call with timeout

func fetchData(ctx context.Context, url string) ([]byte, error) {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("building request: %w", err)
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        // If the timeout fired, err will wrap context.DeadlineExceeded
        return nil, fmt.Errorf("performing request: %w", err)
    }
    defer resp.Body.Close()

    return io.ReadAll(resp.Body)
}

Deadline shrinking, never growing

An important, often-missed rule: a child context’s effective deadline can never be later than its parent’s. If a parent has a 5-second deadline and you call WithTimeout(parent, 10*time.Second), the resulting context will still respect the parent’s 5-second deadline. Go automatically takes the earlier of the two.

parent, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

child, cancel2 := context.WithTimeout(parent, 10*time.Second)
defer cancel2()
// child will still be cancelled after ~5 seconds, not 10.

6. Passing Values: context.WithValue

func WithValue(parent Context, key, val any) Context

This allows attaching request-scoped data — things like a request ID, an authenticated user, or a trace span — to a context so it can be read further down the call chain without changing every function signature.

The right way to use keys

Never use plain strings or built-in types as keys — they can collide across packages. Define an unexported custom type:

package requestctx

type ctxKey int

const (
    userIDKey ctxKey = iota
    requestIDKey
)

func WithUserID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, userIDKey, id)
}

func UserID(ctx context.Context) (string, bool) {
    id, ok := ctx.Value(userIDKey).(string)
    return id, ok
}

This pattern:

  • Prevents collisions with keys defined in other packages (since ctxKey is unexported and package-scoped).
  • Provides type-safe accessor functions instead of forcing callers to do raw type assertions everywhere.
  • Keeps the “key namespace” private to the owning package.

What WithValue should — and should NOT — be used for

Appropriate uses:

  • Request-scoped metadata: trace/span IDs, request IDs, deadlines-related data.
  • Cross-cutting concerns that must flow through layers you don’t control (e.g., middleware in an HTTP framework attaching an authenticated user).

Inappropriate uses (a very common anti-pattern):

  • Passing optional function parameters via context (“context as a God object”).
  • Passing a *sql.DB, a logger, or other dependencies that should be explicit constructor/function arguments.
  • Passing business-logic data that functions actually operate on (that data should be an explicit parameter, for readability and type safety).

Rule of thumb from the Go team: “Use context Values only for request-scoped data that transits process and API boundaries, not for passing optional parameters to functions.”

7. Context Propagation Rules

The idiomatic conventions (documented directly in the context package docs) are:

  1. Context should be the first parameter of a function, conventionally named ctx:
    func DoSomething(ctx context.Context, arg Arg) error
  2. Never store a Context inside a struct. Instead, pass it explicitly to each method that needs it. (Rare exceptions exist, e.g., in generated code or where the struct itself represents a single request lifecycle — but this remains an exception, not the rule.)
  3. Never pass nil as a Context, even if a function permits it. Use context.TODO() if you’re unsure of which context to use.
  4. The context passed to a function should be the first thing checked in cases where you’re managing complex cancellation logic, but you don’t need to check ctx.Err() at the top of every trivial function — it’s mainly relevant before starting expensive work.
  5. A Context is safe for concurrent use by multiple goroutines simultaneously. You may pass the same context to many goroutines.

8. Context Trees and How Cancellation Propagates

Every call to WithCancel, WithTimeout, WithDeadline, or WithValue produces a child context that holds a reference to its parent. Internally, cancellable contexts (cancelCtx) register themselves with their parent so that:

  • If the parent is cancelled, all descendants are cancelled too (cascading downward).
  • If a child is cancelled, only that child and its descendants are cancelled — siblings and the parent are unaffected.
Background()
   └── WithCancel (A)
         ├── WithTimeout (B)
         │      └── WithValue (C)
         └── WithCancel (D)

If A is cancelled → B, C, and D are all cancelled. If D is cancelled → only D is cancelled; A, B, C remain unaffected.

This tree structure is what makes context so powerful for coordinating shutdown across an entire subsystem: cancel one context at the root of a request, and every goroutine, every downstream RPC call, and every DB query tied to that request can observe the cancellation and unwind cleanly.

9. Common Pitfalls with Context

Pitfall 1: Forgetting to call cancel()

// BAD — leaks the internal timer and context state
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)

// GOOD
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

Pitfall 2: Storing Context in a struct field

// BAD
type Service struct {
    ctx context.Context
}

// GOOD — pass ctx explicitly per call
type Service struct{}
func (s *Service) DoWork(ctx context.Context) error { ... }

Pitfall 3: Using context values for essential parameters

// BAD — hides an important parameter, no compiler safety
ctx = context.WithValue(ctx, "userID", id)
func Process(ctx context.Context) { ... } // userID buried inside ctx

// GOOD
func Process(ctx context.Context, userID string) { ... }

Pitfall 4: Not respecting ctx.Done() in long loops

// BAD — ignores cancellation entirely
for i := 0; i < 1_000_000; i++ {
    heavyWork(i)
}

// GOOD
for i := 0; i < 1_000_000; i++ {
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
    }
    heavyWork(i)
}

Pitfall 5: Using string keys instead of typed unexported keys

Leads to accidental collisions between unrelated packages using the same string key like "userID".

Pitfall 6: Creating a new Background() instead of deriving from the incoming context

// BAD — breaks the entire cancellation chain and drops trace/values
func Handler(w http.ResponseWriter, r *http.Request) {
    ctx := context.Background() // wrong! should use r.Context()
    doWork(ctx)
}

// GOOD
func Handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    doWork(ctx)
}

Pitfall 7: Leaking goroutines that never observe ctx.Done()

If a goroutine performs a blocking operation (e.g., reading from a channel that’s never written to) and never selects on ctx.Done(), it can leak forever, even after the operation is logically “abandoned.”

10. Context in HTTP Servers and Clients

Server side

Every incoming *http.Request automatically carries a context, accessible via r.Context(). This context is cancelled automatically by the net/http server when:

  • The client disconnects.
  • The ResponseWriter’s underlying connection closes.
  • (In HTTP/2) the stream is cancelled.
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    result, err := doExpensiveQuery(ctx)
    if err != nil {
        if errors.Is(err, context.Canceled) {
            // client went away; typically nothing to write back
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(result)
}

You can also derive a stricter timeout from the request’s context using middleware:

func withTimeout(next http.Handler, d time.Duration) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), d)
        defer cancel()
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Client side

http.NewRequestWithContext attaches a context to an outgoing request. If the context is cancelled or its deadline passes, the in-flight HTTP request is aborted and http.Client.Do returns an error wrapping the context’s error.

11. Context with Goroutines and errgroup

A very common pattern: fan out several goroutines, and if any one fails, cancel the rest. The golang.org/x/sync/errgroup package builds this on top of context:

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

func fetchAll(ctx context.Context, urls []string) ([][]byte, error) {
    g, ctx := errgroup.WithContext(ctx)
    results := make([][]byte, len(urls))

    for i, url := range urls {
        i, url := i, url // capture loop variables (pre-Go 1.22 idiom)
        g.Go(func() error {
            data, err := fetchData(ctx, url)
            if err != nil {
                return err // cancels ctx for all other goroutines in the group
            }
            results[i] = data
            return nil
        })
    }

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

Here, errgroup.WithContext returns a derived context that is automatically cancelled the moment any goroutine returns a non-nil error — every other in-flight goroutine can observe this via ctx.Done() and abandon its work early, saving resources.

12. Best Practices Checklist for Context

  • ✅ Always accept context.Context as the first parameter, named ctx.
  • ✅ Always call the cancel function returned by WithCancel/WithTimeout/WithDeadline, typically via defer.
  • ✅ Derive contexts from the incoming context (r.Context(), parent ctx) — never fabricate a fresh Background() mid-chain.
  • ✅ Use unexported custom key types for WithValue.
  • ✅ Reserve WithValue for cross-cutting, request-scoped metadata only.
  • ✅ Check ctx.Done() inside long loops or before/after expensive operations.
  • ✅ Never store a Context in a struct field (pass explicitly).
  • ✅ Never pass nil context — use context.TODO().
  • ✅ Remember: a context is immutable — deriving a new context does not modify the parent.
  • ✅ Treat context.Canceled and context.DeadlineExceeded as expected, “normal” outcomes to be handled gracefully, not necessarily logged as errors.

Part II — Error Handling in Go

1. Philosophy: Errors Are Values

Go deliberately does not use exceptions for regular error handling (it has panic/recover, but that’s reserved for truly exceptional, unrecoverable situations — see below). Instead, Go treats errors as ordinary values returned from functions, most idiomatically as the last return value:

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

The famous quote from Rob Pike (co-creator of Go): “Errors are values, and they can be programmed.” This means you can:

  • Wrap, inspect, compare, and transform errors just like any other value.
  • Build custom logic around them (retry policies, error classification, etc.) using ordinary Go control flow — no special exception-handling syntax needed.

This forces the caller to explicitly acknowledge and handle each possible failure point:

result, err := Divide(10, 0)
if err != nil {
    // handle error — the compiler doesn't force this, but idiom does
    log.Fatal(err)
}
fmt.Println(result)

While this leads to more verbose code (the famous if err != nil pattern repeated often), it makes control flow explicit and traceable — there is no “invisible” jump like a thrown exception that might be caught several stack frames away.

2. The error Interface

type error interface {
    Error() string
}

That’s it — remarkably small. Any type that implements a method Error() string satisfies the error interface. This simplicity is intentional: it means any type — a struct, a string wrapper, an integer code, even a function type — can be an error, as long as it can describe itself as a string.

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: 404, Msg: "not found"}
fmt.Println(err) // error 404: not found

Nil interface gotcha

A very common bug: returning a typed nil pointer as an error interface value results in a non-nil interface, because the interface carries both a type and a value, and only the value is nil.

func mayFail() *MyError {
    return nil // no error occurred
}

func doSomething() error {
    var err *MyError = mayFail()
    return err // DANGER: this returns a non-nil error interface!
}

func main() {
    if err := doSomething(); err != nil {
        fmt.Println("Got an error:", err) // prints, even though there was no real error!
    }
}

Why: err in doSomething()’s return has dynamic type *MyError and dynamic value nil. When compared to the literal nil interface (type=nil, value=nil), they are NOT equal, because the type portion differs.

Fix: Return the interface error directly and explicitly return nil when there’s no error:

func doSomething() error {
    if err := mayFail(); err != nil {
        return err
    }
    return nil
}

3. Creating Errors

errors.New

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

Creates a simple, static error whose message never changes.

fmt.Errorf

err := fmt.Errorf("failed to process user %d: %v", userID, cause)

Allows building a formatted error message — but critically, fmt.Errorf also supports the %w verb (explained in section 6) to wrap an underlying error while preserving programmatic access to it.

4. Sentinel Errors

A sentinel error is a specific, pre-declared error value that callers can compare against directly:

package sql

var ErrNoRows = errors.New("sql: no rows in result set")

Usage:

row := db.QueryRow("SELECT ...")
var name string
err := row.Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
    // handle "not found" case specifically
}

Guidelines for sentinel errors:

  • Name them with an Err prefix: ErrNotFound, ErrPermission, io.EOF (an exception to naming, for historical reasons).
  • Declare them as package-level var, not const (since error is an interface, and interfaces can’t be constants).
  • Use sparingly — sentinel errors create tight coupling between packages (the caller must import your package just to compare against your specific error value). Prefer them mainly for truly universal, well-known conditions (e.g., io.EOF, sql.ErrNoRows, context.Canceled).

5. Custom Error Types

When you need to carry structured data alongside the error (not just a string), define a custom type:

type ValidationError struct {
    Field string
    Msg   string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on field %q: %s", e.Field, e.Msg)
}

Callers can extract the structured data using errors.As (see section 7):

var verr *ValidationError
if errors.As(err, &verr) {
    fmt.Println("Problem field:", verr.Field)
}

Should the receiver be a pointer or value?

Convention: implement Error() on the pointer receiver (*ValidationError) when the error type has mutable or larger internal state, or when you want identity comparison (==) between two errors to mean “the same occurrence.” Use a value receiver for small, purely immutable error types. Most real-world code uses pointer receivers for custom error structs.

6. Error Wrapping (%w) and Unwrapping

Introduced in Go 1.13, error wrapping lets you attach context to an error while preserving the ability to programmatically inspect the original underlying error.

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

Here, %w wraps err (e.g., an *fs.PathError) inside a new error, while still letting the caller unwrap it. Contrast with %v, which would only preserve the message, losing the original error’s identity and type.

The Unwrap() method

Wrapping works because fmt.Errorf("...: %w", err) returns a value implementing:

type wrapError struct {
    msg string
    err error
}

func (e *wrapError) Error() string { return e.msg }
func (e *wrapError) Unwrap() error { return e.err }

Any custom error type can support wrapping simply by implementing Unwrap() error:

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string { return fmt.Sprintf("query %q failed: %v", e.Query, e.Err) }
func (e *QueryError) Unwrap() error { return e.Err }

Wrapping multiple errors (%w with more than one, Go 1.20+)

Since Go 1.20, fmt.Errorf supports multiple %w verbs:

err := fmt.Errorf("multiple failures: %w and %w", err1, err2)

This creates an error whose Unwrap() returns []error{err1, err2} (implementing the interface{ Unwrap() []error } shape introduced alongside errors.Join).

7. errors.Is vs errors.As

These two functions, both introduced in Go 1.13, are the primary tools for inspecting wrapped error chains.

errors.Is — “is this error (or does it wrap) this specific value?”

func Is(err, target error) bool

Walks the chain of wrapped errors (via repeated Unwrap() calls) and checks whether any of them is == to target, OR implements Is(error) bool and reports a match.

if errors.Is(err, sql.ErrNoRows) {
    // matches even if err = fmt.Errorf("query failed: %w", sql.ErrNoRows)
}

if errors.Is(err, context.DeadlineExceeded) {
    // handle timeout specifically
}

Use errors.Is for sentinel error comparisons.

errors.As — “does this error chain contain an error of this type, and if so, give it to me?”

func As(err error, target any) bool

Walks the chain and finds the first error whose concrete type matches target (a pointer to the desired type), assigning it if found.

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

Use errors.As when you need to extract structured fields from a specific custom error type, regardless of how deeply it’s wrapped.

Rule of thumb

SituationUse
Comparing against a known, specific error value (sentinel)errors.Is
Extracting a specific error type’s fieldserrors.As
Simple equality check on unwrapped errors (rare, discouraged)== (only when you’re sure there’s no wrapping involved)

Implementing custom Is/As behavior

You can customize matching logic by implementing:

func (e *MyError) Is(target error) bool {
    t, ok := target.(*MyError)
    if !ok {
        return false
    }
    return e.Code == t.Code // match by code, ignore message
}

8. Multi-Error Handling: errors.Join

Introduced in Go 1.20, errors.Join combines multiple errors into a single error value:

func Join(errs ...error) error
err1 := errors.New("disk full")
err2 := errors.New("network unreachable")
combined := errors.Join(err1, err2)

fmt.Println(combined)
// disk full
// network unreachable

fmt.Println(errors.Is(combined, err1)) // true
fmt.Println(errors.Is(combined, err2)) // true

This is extremely useful for scenarios like validating multiple fields and wanting to report all failures at once, rather than stopping at the first one:

func validate(u User) error {
    var errs []error
    if u.Name == "" {
        errs = append(errs, errors.New("name is required"))
    }
    if u.Age < 0 {
        errs = append(errs, errors.New("age must be non-negative"))
    }
    return errors.Join(errs...) // returns nil if errs is empty
}

Note: errors.Join(nil, nil) returns nil. errors.Join skips nil entries automatically, and if all entries are nil, the joined result itself is nil.

9. Panic and Recover

panic and recover are not a substitute for normal error handling. They are reserved for:

  • Truly unrecoverable programming errors (e.g., index out of range, nil pointer dereference, failed invariant/assertion).
  • Situations where continuing execution would be unsafe or meaningless.
  • Framework-level “fail fast during initialization” scenarios.

Basic mechanics

func safeDivide(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered from panic: %v", r)
        }
    }()
    result = a / b // panics if b == 0 (integer division by zero)
    return
}
  • panic(v any) immediately stops normal execution of the current function, runs any deferred functions in the current goroutine (in LIFO order), and propagates up the call stack until either a recover() is called inside a deferred function, or the program crashes with a stack trace.
  • recover() only has an effect when called directly inside a deferred function. Calling it elsewhere returns nil and does nothing.

When (not) to use panic

Appropriate:

func mustCompile(pattern string) *regexp.Regexp {
    re, err := regexp.Compile(pattern)
    if err != nil {
        panic(err) // programmer error: pattern is hardcoded and known-bad
    }
    return re
}

This mirrors patterns like regexp.MustCompile, template.Must — used for package-level initialization where failure indicates a bug, not a runtime condition to recover from.

Inappropriate:

// BAD — using panic for ordinary, expected failure conditions
func GetUser(id string) *User {
    user, ok := db[id]
    if !ok {
        panic("user not found") // should return an error instead!
    }
    return user
}

Recovering in goroutines

A recover() in one goroutine cannot catch a panic from another goroutine. Each goroutine that might panic needs its own deferred recover:

func worker(id int) {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("worker %d recovered: %v", id, r)
        }
    }()
    doRiskyWork()
}

go worker(1) // if this panics without a defer/recover of its own, it crashes the whole program

Important: An unrecovered panic in any goroutine crashes the entire program, not just that goroutine. This is a deliberate Go design decision — a panic usually indicates a broken invariant, and Go prefers a loud crash over silent corruption.

10. Error Handling Patterns in Practice

Pattern: Adding context while propagating up the stack

func ReadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("read config file %q: %w", path, err)
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parse config file %q: %w", path, err)
    }
    return &cfg, nil
}

Each layer adds a little context (“what was I trying to do when this failed”) without discarding the original error’s identity, thanks to %w.

Pattern: Classifying errors for control flow (e.g., HTTP status mapping)

var (
    ErrNotFound     = errors.New("resource not found")
    ErrUnauthorized = errors.New("unauthorized")
)

func handleError(w http.ResponseWriter, err error) {
    switch {
    case errors.Is(err, ErrNotFound):
        http.Error(w, err.Error(), http.StatusNotFound)
    case errors.Is(err, ErrUnauthorized):
        http.Error(w, err.Error(), http.StatusUnauthorized)
    default:
        http.Error(w, "internal server error", http.StatusInternalServerError)
    }
}

Pattern: Retrying on transient errors

type TemporaryError struct{ Err error }

func (e *TemporaryError) Error() string { return e.Err.Error() }
func (e *TemporaryError) Unwrap() error { return e.Err }

func isTemporary(err error) bool {
    var t *TemporaryError
    return errors.As(err, &t)
}

func doWithRetry(ctx context.Context, fn func() error) error {
    var err error
    for attempt := 0; attempt < 3; attempt++ {
        if err = fn(); err == nil {
            return nil
        }
        if !isTemporary(err) {
            return err
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(time.Duration(attempt+1) * 200 * time.Millisecond):
        }
    }
    return fmt.Errorf("all retries failed: %w", err)
}

Pattern: Defer-based cleanup with error handling

func processFile(path string) (err error) {
    f, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("open %q: %w", path, err)
    }
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = fmt.Errorf("close %q: %w", path, cerr)
        }
    }()

    // ... process f ...
    return nil
}

This pattern ensures that a failure to close the file is not silently dropped, but also doesn’t override a more important earlier error.

11. Logging vs Returning Errors

A key discipline in Go codebases: don’t both log AND return the same error at every layer. This causes duplicate, noisy logs (the same root cause logged 5 times as it bubbles up through 5 layers).

Guideline:

  • Low-level/library code: return errors (possibly wrapped with %w), don’t log.
  • High-level/boundary code (e.g., the top of an HTTP handler, a main() function, a message-queue consumer’s entry point): log the final error once, with full context, and decide what to do (retry, respond with an HTTP status, alert, etc.).
// library layer — just return
func fetchUser(ctx context.Context, id string) (*User, error) {
    u, err := db.Query(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("fetchUser(%s): %w", id, err)
    }
    return u, nil
}

// boundary layer — log once, here
func handleGetUser(w http.ResponseWriter, r *http.Request) {
    u, err := fetchUser(r.Context(), r.PathValue("id"))
    if err != nil {
        log.Printf("handleGetUser failed: %v", err)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(u)
}

12. Common Anti-Patterns

Anti-pattern: Swallowing errors

// BAD
data, _ := os.ReadFile("config.json")

Silently discarding an error hides real failures and leads to confusing downstream bugs (e.g., data being nil).

Anti-pattern: Comparing wrapped errors with ==

// BAD — breaks the moment the error is wrapped with fmt.Errorf("...: %w", err)
if err == sql.ErrNoRows { ... }

// GOOD
if errors.Is(err, sql.ErrNoRows) { ... }

Anti-pattern: Over-wrapping / redundant context

// BAD — repetitive, unhelpful context at every layer
return fmt.Errorf("error: %w", fmt.Errorf("error: %w", fmt.Errorf("failed: %w", err)))

Each wrap should add new, useful information (what was being attempted), not just repeat generic words like “error occurred.”

Anti-pattern: Using panic for expected, recoverable conditions

Already covered in section 9 — reserve panic for programmer errors and unrecoverable states, not for things like “user input invalid” or “record not found.”

Anti-pattern: Returning naked/ungrouped errors from public APIs without stable sentinel values

If your package’s callers need to programmatically distinguish error cases, expose sentinel errors or typed errors — don’t force them to string-match err.Error().

Anti-pattern: Ignoring the “nil interface holding a nil pointer” trap

Covered in section 2 — always be careful when returning a concrete pointer type through an error-typed return.

13. Best Practices Checklist for Errors

  • ✅ Return errors as the last return value; check them immediately with if err != nil.
  • ✅ Use fmt.Errorf("...: %w", err) to wrap while preserving the error chain.
  • ✅ Use errors.Is for sentinel comparisons, errors.As for extracting typed errors.
  • ✅ Define sentinel errors (ErrXxx) for well-known, universal conditions only.
  • ✅ Define custom error structs when you need structured data alongside the message.
  • ✅ Log errors once, at the boundary layer — not at every layer they pass through.
  • ✅ Reserve panic/recover for programmer errors and unrecoverable situations, not routine failures.
  • ✅ Never discard an error silently (_ = err) without a deliberate, documented reason.
  • ✅ Add meaningful context when wrapping — say what you were doing, not just “error.”
  • ✅ Watch out for the typed-nil-in-interface trap when returning concrete error types.
  • ✅ For multiple independent errors, use errors.Join rather than only reporting the first one.

Part III — Combining Context and Errors

Context and error handling intersect constantly in real Go systems. A few essential integration points:

Detecting cancellation vs. deadline

result, err := doWork(ctx)
if err != nil {
    switch {
    case errors.Is(err, context.Canceled):
        // caller gave up / client disconnected — usually not a "real" error to alert on
    case errors.Is(err, context.DeadlineExceeded):
        // operation took too long — may want to alert, retry, or return HTTP 504
    default:
        // some other failure
    }
}

Propagating context cancellation as part of your own error chain

When a function performs work that respects ctx, and it returns early due to cancellation, it’s idiomatic to simply return ctx.Err() (or wrap it):

func longOperation(ctx context.Context) error {
    for i := 0; i < 100; i++ {
        select {
        case <-ctx.Done():
            return fmt.Errorf("longOperation interrupted: %w", ctx.Err())
        default:
        }
        // do a chunk of work
    }
    return nil
}

This lets the caller use errors.Is(err, context.Canceled) regardless of how deeply the cancellation happened inside a chain of wrapped calls.

Timeouts as a form of “expected” errors

Just like context.Canceled typically shouldn’t be treated as a crash-worthy error, a context.DeadlineExceeded is often a normal, expected outcome under load — treat it as a value to branch on, not necessarily something requiring a stack trace.


Appendix: Quick Reference Tables

Context functions

FunctionPurpose
context.Background()Root context, never cancelled, no values
context.TODO()Placeholder root context, signals “not yet decided”
context.WithCancel(parent)Manual cancellation
context.WithTimeout(parent, d)Cancels after duration d
context.WithDeadline(parent, t)Cancels at wall-clock time t
context.WithValue(parent, key, val)Attaches request-scoped data
context.Cause(ctx) (Go 1.21+)Returns the underlying cause, even through WithCancelCause
context.WithCancelCause(parent) (Go 1.21+)Cancel with a custom error reason

Error functions

FunctionPurpose
errors.New(msg)Create a simple static error
fmt.Errorf(format, ..., err) with %wWrap an error while formatting a message
errors.Is(err, target)Check if err matches a sentinel, anywhere in the chain
errors.As(err, &target)Extract a specific error type from the chain
errors.Unwrap(err)Manually unwrap one level
errors.Join(errs...) (Go 1.20+)Combine multiple errors into one

Panic/recover quick reference

ConceptBehavior
panic(v)Stops normal flow, runs deferred functions, propagates up
recover()Only effective inside a deferred function; stops the panic
Unrecovered panicCrashes the entire program (all goroutines)
Cross-goroutine recoverNot possible; each goroutine needs its own recover

End of document.

This note is part of the Digital Garden — a collection of connected, evolving thoughts.