Passing context through layers

Always pass the `context.Context` as the first argument to every function in your call chain, from the entry point down to the database or external service calls.

The request that never ends

A user clicks a button on your dashboard. The server accepts the HTTP request, spawns a goroutine, and starts querying three different databases. Two seconds later, the user loses patience and closes the browser tab. The connection drops. Your server, however, keeps running. It finishes the third query, processes the result, writes to a cache, and finally returns a response to nowhere. You just burned CPU cycles, memory, and database connections for a request that nobody cares about anymore.

This happens when you treat a request as a fire-and-forget event. Go gives you a tool to stop this waste. The context.Context type acts as a cancellation signal and a request-scoped data carrier. It travels from the entry point of your application all the way down to the slowest database call. When the client disconnects or a deadline expires, the context closes a channel. Every function holding that context can check the channel and bail out immediately.

How context actually works

Think of a context like a walkie-talkie channel shared by a temporary team. The team leader holds the master radio. When the leader says abort, every team member hears it at the exact same time. They drop their current task and report back. The context interface in Go only has four methods: Deadline, Done, Err, and Value. You rarely call them directly. Instead, you pass the context down, and the standard library or your own code checks ctx.Done() to see if the abort signal has arrived.

The Deadline method returns a time after which the context will be cancelled. The Done method returns a channel that closes when the context is cancelled or times out. The Err method explains why the context was cancelled. The Value method retrieves request-scoped metadata. Together, they form a lightweight, immutable tree. You never modify an existing context. You derive a new one from it. context.WithTimeout or context.WithCancel returns a fresh context that inherits the parent's signals but adds its own deadline or cancel function. When you call the cancel function, it closes the Done channel for that context and all its children. The signal flows downward. The cleanup flows upward.

A minimal chain

Here is the simplest way to see cancellation in action. The parent goroutine waits two seconds, then cancels. The child goroutine blocks on a simulated slow operation but checks the context first.

package main

import (
    "context"
    "fmt"
    "time"
)

// slowTask simulates a long-running operation that respects cancellation.
func slowTask(ctx context.Context) error {
    // Block until either the work finishes or the context cancels.
    select {
    case <-time.After(5 * time.Second):
        return nil
    case <-ctx.Done():
        // Return the specific error that triggered the cancellation.
        return ctx.Err()
    }
}

func main() {
    // Create a cancellable context for this scope.
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // Always clean up to free underlying resources.

    go func() {
        // Run the task in a separate goroutine.
        if err := slowTask(ctx); err != nil {
            fmt.Println("Task stopped:", err)
        }
    }()

    // Simulate a client timeout or user abort.
    time.Sleep(2 * time.Second)
    cancel() // Send the abort signal to all children.
    time.Sleep(100 * time.Millisecond) // Wait for goroutine to exit.
}

The select statement is the engine here. It waits on multiple channels simultaneously. When cancel() runs, it closes the channel behind ctx.Done(). The select picks that case immediately, returns context.Canceled, and the goroutine exits. Without the select, the goroutine would sleep for the full five seconds regardless of what happened upstream.

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

Walking through the cancellation signal

When you call context.Background() at the top of your program, you get a root context that never cancels. It has no deadline, its Done channel is nil, and it holds no values. You use it as the foundation for everything else. When you call context.WithCancel or context.WithTimeout, the standard library allocates a new struct that holds a Done channel and a reference to the parent context. It also starts a timer if a deadline was provided.

The moment you invoke the returned cancel function, the library closes the Done channel. Closing a channel is a broadcast. Every goroutine blocked on <-ctx.Done() wakes up instantly. The select statement picks the closed channel case and executes the cleanup logic. The ctx.Err() call then returns either context.Canceled or context.DeadlineExceeded. These are sentinel errors that you can check with errors.Is to distinguish between a manual abort and a timeout.

If you forget to check ctx.Done() in a loop or a blocking call, the goroutine keeps running. The cancellation signal arrives, the channel closes, but nobody is listening. The goroutine leaks. It holds onto memory, file descriptors, or database connections until the process terminates. The worst goroutine bug is the one that never logs. Always pair long-running operations with a select that listens to the context.

The realistic layer pattern

Web applications usually split logic into handlers, services, and repositories. The context must cross every boundary. The convention is strict: context.Context is always the first parameter, and it is almost always named ctx. This makes it easy to spot in signatures and keeps the codebase consistent. You do not store context in struct fields. Structs are long-lived. Contexts are request-scoped. Storing a context in a struct creates a memory leak because the struct outlives the request.

Here is how a typical HTTP handler passes the context down to a business logic layer.

package main

import (
    "context"
    "encoding/json"
    "net/http"
)

// Handler receives the request and extracts the context.
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    // r.Context() is automatically tied to the incoming request lifecycle.
    ctx := r.Context()

    // Derive a new context with a trace ID for logging.
    ctx = context.WithValue(ctx, "traceID", r.Header.Get("X-Trace-ID"))

    user, err := h.service.GetUser(ctx, r.URL.Query().Get("id"))
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(user)
}

// Service performs business logic and delegates to the repository.
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
    // Add a local deadline so the service doesn't wait forever.
    ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
    defer cancel() // Frees the timeout resources immediately.

    return s.repo.FindByID(ctx, id)
}

Notice the defer cancel() in the service layer. It is a Go community convention to call the cancel function immediately after deriving a context with a timeout or cancel function. This closes the Done channel and releases the underlying timer. If you skip the defer, the timer keeps running until the garbage collector runs, which could be minutes or hours later. The compiler will not catch this. You have to remember it.

The repository layer handles the actual I/O. Database drivers and HTTP clients in the standard library already know how to read a context.

package main

import (
    "context"
    "database/sql"
)

// Repository executes the actual database call.
func (r *Repository) FindByID(ctx context.Context, id string) (*User, error) {
    var user User
    // QueryRowContext checks ctx.Done() while waiting for the DB.
    err := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id).Scan(&user)
    if err != nil {
        return nil, err
    }
    return &user, nil
}

The QueryRowContext method polls the context's Done channel while waiting for the database driver to return. If the context cancels before the query finishes, the driver aborts the statement and returns an error. You do not need to write custom cancellation logic for standard library I/O. You just pass the context down.

Retrieving values and avoiding traps

The context.WithValue function attaches a key-value pair to a context. You retrieve it later with ctx.Value(key). This is useful for request-scoped metadata like trace IDs, user IDs, or tenant identifiers. It is not a replacement for function arguments. Do not pass configuration, database handles, or large structs through context. The context is meant to be lightweight and transient.

When you call ctx.Value, it walks up the chain of derived contexts until it finds the key. If it reaches the root without finding it, it returns nil. You should always type-assert the result and handle the missing case gracefully.

// Extract the trace ID safely in any downstream function.
traceID, ok := ctx.Value("traceID").(string)
if !ok {
    traceID = "unknown"
}
// Use traceID for structured logging or distributed tracing.

A common runtime panic happens when developers forget to check if a context is already cancelled before starting a long operation. If you call a blocking function without a select on ctx.Done(), the goroutine hangs. Another trap is passing a pointer to a context. The context.Context type is an interface. Interfaces are already reference types. Passing *context.Context adds an unnecessary layer of indirection and violates the standard library's design. The compiler will not stop you, but your linter will complain, and other Go developers will question your judgment.

If you try to pass a context to a function that expects a different type, the compiler rejects it with cannot use ctx (variable of struct type context.Context) as string value in argument. Contexts are not generic containers. They are strictly typed interfaces. If you forget to import the context package, you get undefined: context. If you derive a context but never call its cancel function, you get a silent memory leak. The tooling will not save you from logical mistakes. You have to design the flow carefully.

When to reach for context

Context is powerful, but it is not a catch-all for every piece of state. Use it for the right job.

Use a context when you need to propagate cancellation signals or deadlines across function boundaries. Use a context when you need to carry request-scoped metadata like trace IDs or authentication tokens. Use explicit function parameters when you are passing configuration, options, or domain data that does not change during the request. Use a channel when you need to communicate between goroutines or coordinate work. Use a plain function with no context parameter when the operation is fast, pure, and cannot be cancelled.

Trust the convention. First parameter, named ctx, derived carefully, cancelled promptly.

Where to go next