Why Context Should Be the First Parameter in Go Functions

Context must be the first parameter in Go functions to prevent accidental omission and ensure consistent cancellation and timeout propagation.

The invisible thread

A web server receives a request. That request needs to query a database, fetch a cached value, and call a third-party payment API. The client closes the browser tab two seconds later. Without a coordination mechanism, the database query keeps running. The cache lookup finishes and writes to memory. The payment API call completes and charges the card. You just wasted CPU cycles, database connections, and possibly money.

Go solves this with context.Context. The convention of placing it as the first parameter is not a language feature. It is a structural agreement that shapes how Go code is written, read, and analyzed. Position zero is where the cancellation signal lives. When every function in a call chain respects that position, the entire stack can shut down cleanly the moment a deadline passes or a client disconnects.

Context is plumbing. Run it through every long-lived call site.

What context actually is

context.Context is an interface with four methods: Deadline, Done, Err, and Value. It carries deadlines, cancellation signals, and request-scoped key-value pairs. Under the hood, an interface in Go is a two-word struct containing a pointer to the type information and a pointer to the underlying data. Passing a context is cheap. You are passing two pointers, not copying a large struct.

Think of context like a circuit breaker wired through a series of relay stations. Each station (function) receives the breaker, does its work, and passes it to the next station. If the main switch trips, every station sees the open circuit immediately and stops processing. The breaker itself is lightweight. The signal it carries is what coordinates the shutdown.

The standard library provides three ways to create a context. context.Background() and context.TODO() return empty root contexts. context.WithCancel, context.WithTimeout, and context.WithDeadline derive a new context from an existing one, attaching a Done channel that closes when the signal fires. Derivation creates a tree. Canceling a parent automatically cancels all children.

Context is a tree, not a flat list. Derive from the parent, never replace it.

The first parameter rule

The Go compiler does not force context.Context into the first slot. You can technically put it anywhere. The community convention exists because static analysis tools, IDE autocomplete, and human readers all scan function signatures from left to right. Position zero is the only place that guarantees visibility.

Here is the simplest signature that follows the convention:

// FetchData retrieves a record using the provided context for cancellation.
func FetchData(ctx context.Context, id int) (string, error) {
    // check cancellation before starting work
    select {
    case <-ctx.Done():
        return "", ctx.Err()
    default:
    }
    // simulate I/O that respects the deadline
    // in real code this would be a database or HTTP call
    return "record-" + strconv.Itoa(id), nil
}

The select block at the top is a guard. It checks the Done channel before committing to any work. If the channel is already closed, ctx.Err() returns context.Canceled or context.DeadlineExceeded. The default case prevents the select from blocking if the context is still active. This pattern ensures you never start a long operation when the caller has already given up.

When you call this function, the context flows naturally:

// main demonstrates passing context through a simple call chain.
func main() {
    // create a root context for the top-level operation
    ctx := context.Background()
    // derive a timeout so the call cannot hang indefinitely
    ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
    // always defer cancel to release resources when the scope ends
    defer cancel()

    // pass the derived context as the first argument
    result, err := FetchData(ctx, 42)
    if err != nil {
        // handle the error and exit
        return
    }
    // use the result
    _ = result
}

The defer cancel() call is mandatory. It closes the Done channel and releases the timer or goroutine backing the derived context. Forgetting it leaks resources. The compiler will not stop you. You get a silent memory leak that grows until the process restarts.

Goroutines are cheap. Context cleanup is not optional.

How the chain holds together

Real code rarely calls one function. It chains them. Each step must accept the context first, derive a new one if needed, and pass it downstream. This creates a predictable flow that tooling can trace.

Here is a realistic request handler that chains three operations:

// HandleOrder processes a purchase request with strict timeout boundaries.
func HandleOrder(ctx context.Context, w http.ResponseWriter, r *http.Request) {
    // extract order ID from the request path
    id := chi.URLParam(r, "id")
    
    // derive a short timeout for the database lookup
    dbCtx, dbCancel := context.WithTimeout(ctx, 200*time.Millisecond)
    defer dbCancel()
    
    // query the database with the derived context
    item, err := db.GetItem(dbCtx, id)
    if err != nil {
        http.Error(w, "database timeout", http.StatusServiceUnavailable)
        return
    }
    
    // derive a separate timeout for the payment gateway
    payCtx, payCancel := context.WithTimeout(ctx, 1*time.Second)
    defer payCancel()
    
    // charge the card using the payment context
    receipt, err := gateway.Charge(payCtx, item.Price)
    if err != nil {
        http.Error(w, "payment failed", http.StatusPaymentRequired)
        return
    }
    
    // write the success response
    w.WriteHeader(http.StatusOK)
    fmt.Fprint(w, receipt.ID)
}

Notice how each derived context is created from the incoming ctx, not from context.Background(). This preserves the parent's cancellation signal. If the client disconnects, ctx.Done() closes, which automatically closes dbCtx.Done() and payCtx.Done(). The database driver and payment SDK both see the signal and abort their network calls.

Static analysis tools rely on this pattern. Linters like staticcheck and golangci-lint scan the first parameter of every function. If they see a function that performs I/O but lacks a context.Context in position zero, they emit a warning. The warning looks like SA1012: missing context.Context parameter. The tooling ecosystem treats position zero as the contract boundary. Breaking it makes your code invisible to automated checks.

The convention also shapes how you name the receiver and parameters. The context parameter is almost always named ctx. Functions that accept it should respect cancellation and deadlines. This naming is not enforced by the compiler, but it is universal in the standard library and third-party packages. Consistency reduces cognitive load when reading unfamiliar code.

Trust the convention. Let the linter enforce it.

When things go wrong

Context bugs rarely crash the program. They leak goroutines, hang requests, or waste downstream resources. The compiler will not catch a missing context parameter. You get a clean build. The failure happens at runtime when a request times out but the goroutine keeps running.

If you forget to pass context to a goroutine, the goroutine inherits no cancellation signal. It waits on a channel that never closes. The compiler rejects this pattern only if you use an untyped constant where a typed value is expected, but it will happily compile a goroutine that ignores context entirely. You might see a runtime panic later with runtime error: invalid memory address or nil pointer dereference if you accidentally pass nil instead of a valid context. The context package methods panic on nil receivers. Always use context.Background() or context.TODO() as a fallback.

Another common mistake is creating a new context without deriving from the parent:

// BAD: breaks the cancellation tree
func process(ctx context.Context) {
    newCtx := context.Background() // discards parent deadline
    db.Query(newCtx, "SELECT 1")
}

This code compiles fine. The parent deadline is ignored. The database query runs until it finishes or hits its own internal timeout. The original caller's deadline becomes meaningless. The fix is simple: derive from ctx.

Error handling around context follows the same verbose pattern the rest of Go uses. You check ctx.Err() and return it. The boilerplate is intentional. It makes the unhappy path visible. You do not wrap it with extra text unless you add domain-specific information. The standard library already formats context.Canceled and context.DeadlineExceeded clearly.

The worst goroutine bug is the one that never logs. Always attach context to background workers.

The decision matrix

Use context.Context as the first parameter when you are writing a function that participates in a request lifecycle or long-running operation. Use a derived context with context.WithTimeout when you need to bound the execution time of a specific step. Use context.WithValue sparingly when you must pass request-scoped metadata that cannot be expressed as a function parameter. Skip context entirely when the function is pure, stateless, and operates on in-memory data without I/O or goroutines. Use a dedicated configuration struct instead of context values when the data is global to the service rather than scoped to a single request.

Context is a signal, not a storage bucket. Keep values out of it.

Where to go next