Context in Go From Simple Cancellation to Deadlines
A guide to context in Go: the Context interface, cancel trees, timeouts and deadlines, values, passing rules, HTTP and database use, and common mistakes.

Every server has the same problem: a request arrives, it fans out into database queries, calls to other services and goroutines, and then the client leaves. Nothing downstream knows. The query keeps running, the goroutines keep working, and the answer is thrown away. Multiply by thousands of requests an hour and the waste is a capacity problem. context is the standard library's answer to "tell everything involved in this request that it should stop", and it has become the shape of nearly every Go API that does I/O.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This guide covers the model, each constructor, the rules for passing and honoring contexts, and the mistakes. It leads to cancellation in web services, timeouts and deadlines, context values and context causes. Everything ran on Go 1.25.6.
What a Context is
context.Context is an interface with four methods:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}Done returns a channel that is closed when the context is cancelled or its deadline passes; a goroutine that wants to stop on cancellation selects on it. Err is nil while the context is live, and afterward says why it ended: context.Canceled or context.DeadlineExceeded. Deadline reports the time limit, if there is one. Value looks up request-scoped data by key. Contexts are immutable. You never change one; you derive a new one from it that adds a cancel, a deadline or a value.
Every program starts from a root: context.Background() is the empty root used in main, tests and top-level code, and context.TODO() marks a place where you have not yet decided which context to use. A server handler gets its root from the request: r.Context().
Cancellation
WithCancel derives a context and a function that cancels it:
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, name string, out chan<- string) {
for i := 1; ; i++ {
select {
case <-ctx.Done():
out <- fmt.Sprintf("%s stopped: %v", name, ctx.Err())
return
case <-time.After(10 * time.Millisecond):
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
out := make(chan string, 1)
go worker(ctx, "worker", out)
time.Sleep(35 * time.Millisecond)
cancel() // ask the worker to stop
fmt.Println(<-out)
}worker stopped: context canceledThe worker loops until its context ends, watching ctx.Done() in a select. The caller cancels after a while and the worker reports why it stopped. The cancel function can be called more than once safely, and after the first call it does nothing. This is the whole mechanism; everything else builds on it.
A tree of contexts
Contexts derived from one another form a tree. Cancelling a context cancels all its descendants, never its ancestors or siblings:
package main
import (
"context"
"fmt"
)
func main() {
root, cancelRoot := context.WithCancel(context.Background())
child, cancelChild := context.WithCancel(root)
grandchild, cancelGrand := context.WithCancel(child)
sibling, cancelSibling := context.WithCancel(root)
defer cancelRoot()
defer cancelGrand()
defer cancelSibling()
cancelChild() // cancel the middle of the tree
fmt.Println("root: ", root.Err())
fmt.Println("child: ", child.Err())
fmt.Println("grandchild:", grandchild.Err())
fmt.Println("sibling: ", sibling.Err())
}root: <nil>
child: context canceled
grandchild: context canceled
sibling: <nil>Cancelling child cancels grandchild, leaves root and sibling alone. This is why a request context is the right parent for everything a request does: when the request ends, everything below it is cancelled in one step. It is also why cancelling is cheap and safe to do eagerly: you never affect more than your own subtree.
Timeouts and deadlines
WithTimeout(parent, d) derives a context that cancels itself after a duration, and WithDeadline(parent, t) at an instant. Both are WithCancel plus a timer. The error on expiry is context.DeadlineExceeded:
package main
import (
"context"
"errors"
"fmt"
"time"
)
func slowQuery(ctx context.Context) (string, error) {
select {
case <-time.After(200 * time.Millisecond):
return "rows", nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err := slowQuery(ctx)
fmt.Println(err, errors.Is(err, context.DeadlineExceeded))
if dl, ok := ctx.Deadline(); ok {
fmt.Println("had a deadline:", !dl.IsZero())
}
}context deadline exceeded true
had a deadline: trueThe query would take 200 ms, the timeout is 50 ms, and the call returns context deadline exceeded as soon as the deadline passes. A child cannot extend its parent's deadline: if the parent expires in 1 second, a child created with a 10-second timeout still ends at 1 second, because it is cancelled when the parent is. The effective deadline of a context is the earliest in its chain. See WithTimeout versus WithDeadline for when to use each.
Always call cancel
WithCancel, WithTimeout and WithDeadline return a cancel function that you must call, even when the context will end by itself. Until it is called (or the parent ends), the derived context holds resources: a timer and an entry in its parent's list of children. Forgetting leaks them for the life of the parent. The convention is defer cancel() on the line after the call:
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()go vet checks it (the lostcancel analyzer reports a cancel function that is not used on all paths). Calling cancel early, when the work is done, frees things sooner; calling it too early, while goroutines still use the context, cancels their work, which is the cause of many mysterious context canceled errors (context canceled).
Values
WithValue(parent, key, val) derives a context that carries a value, retrievable with ctx.Value(key) in this context and its descendants:
package main
import (
"context"
"fmt"
)
type ctxKey string
const requestID ctxKey = "request-id"
func handle(ctx context.Context) {
fmt.Println("handling request", ctx.Value(requestID))
process(ctx)
}
func process(ctx context.Context) {
fmt.Println("processing for", ctx.Value(requestID))
}
func main() {
ctx := context.WithValue(context.Background(), requestID, "req-7f3a")
handle(ctx)
}handling request req-7f3a
processing for req-7f3aUse values for request-scoped data that crosses API boundaries and that every layer might want to log or propagate: a request ID, a trace span, the authenticated user. Do not use them to pass optional arguments to a function, because that hides the function's real signature and removes compile-time checking. Keys should be an unexported named type, as here, so no other package can collide with them (context values covers what belongs and what never should).
The rules
The package documentation gives a short set of conventions, and following them makes code predictable.
- Pass the context as the first parameter, named
ctx.func Fetch(ctx context.Context, url string) (...). Everyone knows where to look. - Do not store a context in a struct. A context belongs to a call, not an object. Store one only in a type that represents a request or operation for its lifetime.
- Never pass a nil context. Use
context.TODO()if you are unsure; it is a visible marker. - Use
context.Background()only at the top of a call tree:main, a test, an initialiser. - Derive, don't replace. Inside a request, derive from the incoming context so cancellation propagates. A fresh
context.Background()in the middle of a request detaches the work from the client, and it keeps running after the client has gone. - The same context can be shared by many goroutines. It is safe for concurrent use.
Honoring the context
A context cancels only work that watches it. Passing ctx to a function that never looks at it does nothing. The ways a function honors it:
- In a loop or any blocking step,
selectonctx.Done()alongside the real work. - Pass
ctxon to every call that accepts one: database calls (QueryContext,ExecContext), HTTP requests (http.NewRequestWithContext), other services. - After a blocking call returns, check
ctx.Err()if you do more work afterwards. - Return
ctx.Err(), wrapped if you like, so callers can tell cancellation from failure.
Here is a request context at work against a real net/http server: the handler notices the client leaving and stops.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"time"
)
func main() {
stopped := make(chan error, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(2 * time.Second):
fmt.Fprint(w, "done")
case <-r.Context().Done(): // the client disconnected
stopped <- r.Context().Err()
}
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL, nil)
_, err := http.DefaultClient.Do(req)
fmt.Println("client error is a timeout:", err != nil)
fmt.Println("server handler saw:", <-stopped)
}client error is a timeout: true
server handler saw: context canceledThe client gives up after 50 ms. The server's request context is cancelled when the connection closes, and the handler, selecting on it, stops instead of sleeping the full two seconds. Without the select on r.Context().Done(), the handler would carry on with work for a client that was gone.
Contexts in the standard library
You rarely implement a context-aware operation from scratch, because the standard library already takes one almost everywhere an operation can block. http.NewRequestWithContext and http.Server (via r.Context()) cover HTTP. database/sql has QueryContext, ExecContext, BeginTx and friends. net.Dialer.DialContext covers connections, and os/exec.CommandContext ties a subprocess to a context: when the context ends, the process is killed.
package main
import (
"context"
"fmt"
"os/exec"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
err := exec.CommandContext(ctx, "sleep", "5").Run()
fmt.Println("error:", err)
fmt.Println("returned in about", time.Since(start).Round(100*time.Millisecond), "not 5s")
}error: signal: killed
returned in about 100ms not 5sThe command would sleep for five seconds, and the context kills it after 100 ms: the call returns an error reporting that the process was killed. The same pattern applies to every ...Context method: pass the context, and cancellation reaches the operation without any cooperation from your own code. signal.NotifyContext goes the other way, producing a context that is cancelled when the process receives a signal, which is the clean way to wire shutdown into everything below it (signal.NotifyContext and graceful shutdown).
Contexts in tests
Since Go 1.24, testing.T has a Context() method that returns a context cancelled just before the test's cleanup functions run. It removes the habit of creating a context.Background() in every test and the leaks that follow when a test starts goroutines it forgets to stop:
package main
import (
"testing"
"time"
)
func TestWorkerStopsWithTest(t *testing.T) {
ctx := t.Context() // cancelled when the test finishes
stopped := make(chan struct{})
go func() {
<-ctx.Done()
close(stopped)
}()
t.Cleanup(func() {
select {
case <-stopped:
case <-time.After(time.Second):
t.Error("worker did not stop when the test context ended")
}
})
}=== RUN TestWorkerStopsWithTest
--- PASS: TestWorkerStopsWithTest (0.00s)
PASS
ok example.test/lab 1.259sThe worker watches the test's context and the cleanup function waits for it to stop, so a worker that ignored its context would fail the test. For timeouts in tests, derive from t.Context() with WithTimeout as usual. testing/synctest goes further and runs a test in a bubble with a fake clock, which makes timeout tests instant (testing with synctest).
Designing APIs that take a context
If you write a function that does I/O, blocks or loops for a while, give it a context. The signature is func Do(ctx context.Context, args...) (result, error), and the contract is: return promptly after ctx is done, with an error that wraps ctx.Err(). Adding a context to an existing API is a breaking change, so decide early; Go libraries typically add ...Context variants alongside the originals (Query and QueryContext) and keep the old ones for compatibility. Two things to avoid: a context-taking function that ignores the context (callers will assume it is cancellable), and a function that creates its own Background context internally where the caller should supply one.
For a long loop, check at a reasonable interval and not on every iteration if the iteration is tiny:
for i, item := range items {
if i%1000 == 0 && ctx.Err() != nil {
return ctx.Err()
}
process(item)
}ctx.Err() is cheap but not free (it takes a lock in some implementations), and checking once per thousand tiny iterations bounds the delay without paying on each.
What a context costs
A context is cheap to create: WithCancel allocates a small struct and registers with the parent. A deep chain, say five or six wrappers per request, is normal and costs nanoseconds. Value lookups walk the chain from the leaf up, so very deep chains with many values make lookups linear; this is a reason to keep the number of values small and to store a single struct of request data under one key when you have several. The cost that matters is not in the library: it is the cancellation that never happens because nothing watches the context. A cheap context protecting no work is worth nothing.
A realistic request, end to end
Putting the pieces together, a handler that fetches data from two backends in parallel under a shared deadline shows how the context flows. The incoming request context is the parent, a timeout bounds the whole operation, and an errgroup runs the two calls so that a failure of either cancels the other:
func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond)
defer cancel()
g, ctx := errgroup.WithContext(ctx)
var user User
var orders []Order
g.Go(func() (err error) { user, err = s.users.Get(ctx, id); return })
g.Go(func() (err error) { orders, err = s.orders.List(ctx, id); return })
if err := g.Wait(); err != nil {
s.writeError(w, r, err)
return
}
s.writeJSON(w, profileView(user, orders))
}Read the flow. The request context carries the client's connection; the timeout adds a budget of 800 ms; the errgroup adds a cancel on first failure; both backend calls receive the same derived context, so each can stop as soon as the budget is spent, the other call fails, or the client leaves. defer cancel() releases the timer when the handler returns. Nothing in the backends needs to know any of this: they take a ctx and honor it. That separation is what the package is for (errgroup and cancellation in web services walk through it with running code).
Contexts and goroutine leaks
Most goroutine leaks are a goroutine waiting for something that will never arrive: a send nobody receives, a receive nobody sends, a lock nobody releases. A context is the standard way to give every such wait an exit. If each blocking operation in a goroutine is a select that also watches ctx.Done(), then cancelling the context releases the goroutine wherever it is stuck, and a leak becomes impossible by construction rather than something to hunt later. The rule for new code is that no goroutine should be started without a context it can be stopped with, or an equivalent done channel, and no blocking call should be made without a way out (goroutine leaks and stopping goroutines build on this).
Common mistakes
Not calling cancel, leaking timers and parent entries. Starting a goroutine with a request context and returning, which cancels the goroutine when the handler finishes; detach with context.WithoutCancel if the work must outlive the request (AfterFunc and WithoutCancel). Ignoring the context in a function that takes one. Using values as hidden parameters. Creating a new Background inside a request. Comparing errors with == instead of errors.Is (errors and cancellation). Putting a context in a struct that outlives the request.
What to read next
The deadline-related behavior is in timeouts and deadlines; the web-service patterns, including database calls, are in cancellation in web services; the Go 1.20 and 1.21 additions are in context causes and AfterFunc and WithoutCancel. The original design is explained in the Go blog's Go Concurrency Patterns: Context, and the package reference is pkg.go.dev/context.


