Skip to content
gohacks.dev
Concurrency

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.

12 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

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:

Go
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:

main.goGo
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)
}
Text
worker stopped: context canceled

The 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:

main.goGo
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())
}
Text
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:

main.goGo
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())
	}
}
Text
context deadline exceeded true
had a deadline: true

The 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:

Go
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:

main.goGo
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)
}
Text
handling request req-7f3a
processing for req-7f3a

Use 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.

  1. Pass the context as the first parameter, named ctx. func Fetch(ctx context.Context, url string) (...). Everyone knows where to look.
  2. 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.
  3. Never pass a nil context. Use context.TODO() if you are unsure; it is a visible marker.
  4. Use context.Background() only at the top of a call tree: main, a test, an initialiser.
  5. 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.
  6. 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, select on ctx.Done() alongside the real work.
  • Pass ctx on 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.

main.goGo
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)
}
Text
client error is a timeout: true
server handler saw: context canceled

The 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.

main.goGo
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")
}
Text
error: signal: killed
returned in about 100ms not 5s

The 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:

ctx_test.goGo
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")
		}
	})
}
Text
=== RUN   TestWorkerStopsWithTest
--- PASS: TestWorkerStopsWithTest (0.00s)
PASS
ok  	example.test/lab	1.259s

The 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:

Go
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:

Go
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.

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.

Taggedcontext
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.