Context

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27. A context.Context is how Go code says "stop working on this, the answer is no longer needed" across goroutines, packages and process boundaries, and how it carries a deadline and a handful of request-scoped values along the same path. Almost every blocking API in the standard library (net/http, database/sql, os/exec, net, log/slog) accepts one, and almost every server written in Go passes one through every layer. This page covers the whole package, how to honour cancellation in your own code, where contexts belong in an API, and how to test code that uses them.

It builds on Goroutines and the Scheduler, Channels and Select and Concurrency Patterns (done channels, pipelines and worker pools are the pre-context ancestors of everything here). The related pages are Sync and Atomics, Time and Timers, Error Handling (wrapping and errors.Is), net/http Clients and Servers, database/sql and Testing. Other languages solve the same problem differently: see CancellationToken in C# async and await, Kotlin coroutine context and cancellation and cancellation by dropping a future in Rust async/await and futures.

Every example was compiled, vetted with go vet and run with Go 1.27.2; the output shown is the real output (the compiler and vet messages are likewise copied from real runs). Examples whose output would otherwise depend on timing wait on channels instead of sleeping, or use timeouts with large safety margins, and print only facts that do not vary between runs. The go.mod of the example module is:

module example.com/shelf

go 1.27

Why context exists

A server request fans out: the HTTP handler calls the catalogue, which queries the database, which asks a cache, and each step may start goroutines. When the client disconnects, when the request has used up its time budget or when the process receives SIGTERM, every one of those goroutines should stop as soon as possible and release its resources. Before Go 1.7 every project invented its own mechanism, usually a done channel passed to every function (the pattern in Concurrency Patterns). Google’s internal context package standardised it, was published as golang.org/x/net/context and moved into the standard library in Go 1.7 (2016), and the rest of the standard library adopted it over the following releases.

A context carries exactly three things, and nothing else belongs in it:

  • a cancellation signal: a channel that is closed when the work should stop, and an error that says why;

  • an optional deadline: the point in time after which the work is pointless;

  • a small set of request-scoped values, such as a request ID, that cross API boundaries without being parameters.

Contexts are immutable. Every With… function returns a new context that wraps a parent, so the contexts of one program form a tree rooted in a context that is never cancelled. Cancelling a node cancels the whole subtree below it and nothing above it. The blog post Go Concurrency Patterns: Context introduced the package, and Contexts and structs explains the rules for where a context may live.

The Context interface

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

(This is the declaration from the package, shown as a fragment; it is not a runnable program.)

Method Meaning

Deadline()

The time at which the context is cancelled automatically, and ok == false if there is none.

Done()

A channel that is closed when the context is cancelled or its deadline passes. It may return nil for a context that can never be cancelled (Background), and receiving from a nil channel blocks forever, which is exactly the right behaviour inside a select. Successive calls return the same channel.

Err()

nil while the context is live. After Done is closed it returns context.Canceled (explicit cancellation) or context.DeadlineExceeded (the deadline passed); it always returns the same non-nil value afterwards.

Value(key)

The value associated with key in this context or the nearest ancestor that set it, or nil.

A Context is safe for simultaneous use by multiple goroutines. The two error values are variables, so compare with errors.Is, never with == on a wrapped error. DeadlineExceeded also implements Timeout() bool (returning true) and Temporary() bool, which is why code written against net.Error treats a deadline as a timeout, while Canceled has no Timeout method:

package main

import (
	"context"
	"errors"
	"fmt"
)

func main() {
	bg := context.Background()
	todo := context.TODO()
	fmt.Println(bg, todo)
	fmt.Println(bg.Err(), bg.Done() == nil)
	_, hasDeadline := bg.Deadline()
	fmt.Println("deadline:", hasDeadline)

	ctx, cancel := context.WithCancel(bg)
	fmt.Println(ctx)
	fmt.Println("before cancel:", ctx.Err())
	cancel()
	<-ctx.Done()
	fmt.Println("after cancel: ", ctx.Err())
	fmt.Println(errors.Is(ctx.Err(), context.Canceled))
	fmt.Printf("%v | %v\n", context.Canceled, context.DeadlineExceeded)

	var te interface{ Timeout() bool }
	fmt.Println(errors.As(context.DeadlineExceeded, &te) && te.Timeout())
	fmt.Println(errors.As(context.Canceled, &te))
}
context.Background context.TODO
<nil> true
deadline: false
context.Background.WithCancel
before cancel: <nil>
after cancel:  context canceled
true
context canceled | context deadline exceeded
true
false

Notice how the contexts print their own ancestry (context.Background.WithCancel): the String method of the standard contexts is a debugging aid and is worth using in logs while you learn the tree.

The package has a small API. Everything the package exports is in this table:

Name Purpose Since

Background(), TODO()

Root contexts that are never cancelled and carry no values or deadline.

1.7

WithCancel(parent)

Derived context plus a CancelFunc.

1.7

WithTimeout(parent, d), WithDeadline(parent, t)

Derived context that cancels itself after d or at time t, plus a CancelFunc.

1.7

WithValue(parent, key, val)

Derived context that carries one key and value.

1.7

WithCancelCause(parent), Cause(ctx)

Cancel with an arbitrary error (CancelCauseFunc) and read it back.

1.20

WithoutCancel(parent)

Derived context that keeps the values but is never cancelled and has no deadline.

1.21

AfterFunc(ctx, f)

Run f in its own goroutine after ctx is done; returns a stop function.

1.21

WithTimeoutCause(parent, d, cause), WithDeadlineCause(parent, t, cause)

Like the timeout constructors, with a custom cause when the deadline passes.

1.21

Canceled, DeadlineExceeded

The two Err values.

1.7

Root contexts: Background and TODO

Every tree needs a root. context.Background() is the empty, never-cancelled context. Use it in main, in init, in tests that do not need t.Context() and at the top of an incoming request when no better parent exists (servers get their contexts from the framework, see below). context.TODO() is identical in behaviour, but documents that the caller has not decided yet which context to use, for example while you are still threading a context parameter through a call chain, or when the surrounding function does not accept one yet. Linters and humans can search for TODO() to find the unfinished plumbing.

Never pass a nil context, even where a function appears to tolerate it: callers that do cannot be told apart from bugs, and some implementations call methods on the context immediately (the panic is shown in Panics, vet reports and compile errors). If you do not have a context, pass context.TODO(). Staticcheck check SA1012 reports a literal nil passed as a context.

Derived contexts form a tree

Each With… call attaches a new node below its parent. A node is cancelled when its own cancel function runs, when its own deadline passes, or when any ancestor is cancelled. Cancellation never flows upward:

flowchart TD BG["Background
never cancelled"] REQ["WithTimeout 30s
HTTP request"] IMP["WithCancel
import job"] DB["WithTimeout 2s
one query"] VAL["WithValue
request ID"] PAGE["WithCancel
page fetch"] OTHER["WithCancel
background sweeper"] BG --> REQ BG --> OTHER REQ --> VAL VAL --> IMP VAL --> DB IMP --> PAGE classDef cancelled fill:#fbe3e1,stroke:#b3261e,color:#222 class REQ,VAL,IMP,DB,PAGE cancelled

In the diagram the request context expired, so the request node, its value node, the import job, the query and the page fetch are all done at the same moment (the shaded nodes), whereas the sweeper hanging off Background is untouched. This is the guarantee that makes defer cancel() cheap and safe: cancelling a node can only affect the work you started below it.

package main

import (
	"context"
	"fmt"
	"sync"
)

// watch blocks until ctx is cancelled and then reports it.
func watch(name string, ctx context.Context, wg *sync.WaitGroup, out chan<- string) {
	defer wg.Done()
	<-ctx.Done()
	out <- fmt.Sprintf("%s: %v", name, ctx.Err())
}

func main() {
	root := context.Background()
	catalogCtx, cancelCatalog := context.WithCancel(root)
	defer cancelCatalog()
	importCtx, cancelImport := context.WithCancel(catalogCtx)
	defer cancelImport()
	pageCtx, cancelPage := context.WithCancel(importCtx)
	defer cancelPage()
	otherCtx, cancelOther := context.WithCancel(root)
	defer cancelOther()

	var wg sync.WaitGroup
	out := make(chan string, 4)
	wg.Add(2)
	go watch("import", importCtx, &wg, out)
	go watch("page", pageCtx, &wg, out)

	cancelCatalog() // cancels catalog, import and page, but not other
	wg.Wait()
	close(out)
	got := map[string]bool{}
	for s := range out {
		got[s] = true
	}
	fmt.Println(got["import: context canceled"], got["page: context canceled"])
	fmt.Println("catalog:", catalogCtx.Err())
	fmt.Println("other:  ", otherCtx.Err())

	// Cancelling a child never affects its parent.
	parent, cancelParent := context.WithCancel(root)
	defer cancelParent()
	child, cancelChild := context.WithCancel(parent)
	cancelChild()
	fmt.Println("child:", child.Err(), "| parent:", parent.Err())
}
true true
catalog: context canceled
other:   <nil>
child: context canceled | parent: <nil>

How this works internally matters for performance and for a few surprises. WithCancel, WithTimeout and WithDeadline create a cancelCtx that registers itself in its nearest cancellable ancestor’s set of children (a map guarded by a mutex), so cancelling the ancestor walks that set once and closes each child’s Done channel lazily, only when someone asks for it. WithValue creates a node that adds no cancellation machinery at all. If the parent is a context from your own type (a custom implementation of the interface), the package may have to start a goroutine to propagate cancellation, which is one reason to prefer the standard constructors, and to wrap a custom context rather than reimplement the tree.

Cancellation: WithCancel and the cancel function

WithCancel returns the derived context and a CancelFunc. The rules for the cancel function are strict, and the vet analyzer lostcancel enforces the most important one:

  • Always call it, even when the work finishes normally. Calling cancel releases the child’s registration in its parent and stops its timer; without it the child (and, for WithTimeout, its timer) stays alive until the parent is cancelled, which for a Background parent is never. The idiom is defer cancel() on the line right after the call.

  • It is idempotent and safe to call from multiple goroutines: the first call wins, later calls do nothing.

  • It does not wait for the work to stop. Cancellation is a request, not a kill; see Respecting cancellation in your own code.

  • Cancelling a child does not touch the parent, siblings or cousins.

When the cancel function is not used on all paths, vet says so:

package main

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

func lookup(ctx context.Context, fast bool) error {
	ctx, cancel := context.WithTimeout(ctx, time.Second)
	if fast {
		cancel()
		return nil
	}
	fmt.Println("looking up", ctx.Err())
	return nil
}

func main() {
	fmt.Println(lookup(context.Background(), false))
}
lostcancel/main.go:10:2: the cancel function is not used on all paths (possible context leak)
lostcancel/main.go:16:2: this return statement may be reached without using the cancel var defined on line 10

If the cancel function is not used at all, the compiler itself rejects the program, because it is an unused local variable:

package main

import (
	"context"
	"fmt"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	fmt.Println(ctx.Err())
}
# example.com/shelf/unusedcancel
unusedcancel/main.go:9:7: declared and not used: cancel

Assigning the function to _ silences the compiler but not lostcancel, which also reports a discarded cancel function ("the cancel function returned by context.WithCancel should be called, not discarded, to avoid a context leak").

WithCancelCause and Cause (Go 1.20)

Err() can only ever say "canceled" or "deadline exceeded", which is rarely enough for diagnosis: why was the import cancelled? WithCancelCause returns a CancelCauseFunc that takes an error, and context.Cause(ctx) returns it. Only the first call records a cause; later calls are ignored. Err() is unchanged (it still returns context.Canceled), so existing code that tests ctx.Err() keeps working, and Cause of a context that was never given a cause falls back to Err(). Children report the cause of the ancestor that cancelled them.

package main

import (
	"context"
	"errors"
	"fmt"
)

var errQuotaExceeded = errors.New("import quota exceeded")

func main() {
	ctx, cancel := context.WithCancelCause(context.Background())
	child, cancelChild := context.WithCancel(ctx)
	defer cancelChild()

	cancel(errQuotaExceeded)
	cancel(errors.New("ignored: only the first cause is kept"))

	fmt.Println("Err:  ", ctx.Err())
	fmt.Println("Cause:", context.Cause(ctx))
	fmt.Println("child Err:  ", child.Err())
	fmt.Println("child Cause:", context.Cause(child))
	fmt.Println(errors.Is(context.Cause(child), errQuotaExceeded))

	plain, cancelPlain := context.WithCancel(context.Background())
	cancelPlain()
	fmt.Println("plain Cause:", context.Cause(plain))
	fmt.Println("live Cause: ", context.Cause(context.Background()))

	// cancel(nil) records context.Canceled as the cause.
	c2, cancel2 := context.WithCancelCause(context.Background())
	cancel2(nil)
	fmt.Println(context.Cause(c2))
}
Err:   context canceled
Cause: import quota exceeded
child Err:   context canceled
child Cause: import quota exceeded
true
plain Cause: context canceled
live Cause:  <nil>
context canceled

Use Cause at the point where you report or log the failure, and use ctx.Err() (or errors.Is(err, context.Canceled)) where you decide whether to retry. The cause is for humans and for typed errors you want to recover with errors.As; it never changes which of the two sentinel errors the standard library returns.

Deadlines and timeouts

WithTimeout(parent, d) is shorthand for WithDeadline(parent, time.Now().Add(d)). The deadline is an absolute time (taken from the monotonic clock when created with WithTimeout), so a deadline already in the past produces a context that is cancelled before it is returned. A derived context can only make the deadline earlier: if the parent expires sooner, the child inherits the parent’s deadline, and child.Deadline() reports the parent’s time.

package main

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

var errSlowStore = errors.New("store did not answer within the budget")

func main() {
	parent, cancel := context.WithTimeout(context.Background(), time.Hour)
	defer cancel()

	// A child can only shorten, never extend, the parent's deadline.
	short, cancelShort := context.WithTimeout(parent, 50*time.Millisecond)
	defer cancelShort()
	long, cancelLong := context.WithTimeout(short, time.Hour)
	defer cancelLong()

	d1, _ := parent.Deadline()
	d2, _ := short.Deadline()
	d3, _ := long.Deadline()
	fmt.Println("short earlier than parent:", d2.Before(d1))
	fmt.Println("long keeps short's deadline:", d3.Equal(d2))

	<-long.Done()
	fmt.Println(long.Err(), errors.Is(long.Err(), context.DeadlineExceeded))
	fmt.Println("parent:", parent.Err())

	// WithTimeoutCause records why the deadline was set.
	c, cancelC := context.WithTimeoutCause(context.Background(), 20*time.Millisecond, errSlowStore)
	defer cancelC()
	<-c.Done()
	fmt.Println(c.Err(), "/", context.Cause(c))

	// WithDeadline takes an absolute time; a deadline in the past cancels at once.
	past, cancelPast := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
	defer cancelPast()
	fmt.Println(past.Err())

	// Calling cancel releases the timer early and leaves Canceled as the error.
	t, cancelT := context.WithTimeout(context.Background(), time.Hour)
	cancelT()
	fmt.Println(t.Err())
}
short earlier than parent: true
long keeps short's deadline: true
context deadline exceeded true
parent: <nil>
context deadline exceeded / store did not answer within the budget
context deadline exceeded
context canceled
Timeline of a parent with a 300 millisecond timeout and three children, showing that a child asking for one hour still ends at 300 milliseconds, a child asking for 100 milliseconds ends first, and a plain cancellable child inherits the parent deadline

Practical consequences:

  • Budgets nest. An overall request budget of 30 seconds with a 2-second budget per database call is expressed by deriving the call context from the request context; the call can never outlive the request.

  • Read the remaining time with ctx.Deadline() to decide whether it is worth starting expensive work, or to set a socket deadline (conn.SetDeadline(deadline)) in code that predates context.

  • Distinguish the two errors. context.DeadlineExceeded usually deserves a retry or a 504; context.Canceled usually means the caller gave up, and logging it as an error floods the logs when clients disconnect.

  • WithTimeoutCause and WithDeadlineCause (Go 1.21) set the cause reported by Cause when the timer fires, so "context deadline exceeded" can become "store did not answer within the budget". Err() is still DeadlineExceeded, as the last lines of the example above show. Calling cancel early reports the plain Canceled error instead.

  • time.After and time.NewTimer express "wait this long" inside one function; contexts express "the caller decided this long" across a call tree. Use a context deadline for the second and a timer for the first, see Time and Timers.

Go 1.23 changed the standard timer implementation (unreferenced timers are garbage-collected even if not stopped, and timer channels are unbuffered), which makes time.After inside a select less costly than it was, but not free in a hot loop; a time.NewTimer that you Stop remains the precise tool.

AfterFunc and WithoutCancel (Go 1.21)

context.AfterFunc(ctx, f) arranges for f to be called in its own goroutine after ctx is done (cancelled or timed out). It returns a stop function: calling stop before f has started prevents the call and returns true; if f already started (or stop was already called) it returns false, and it does not wait for f to finish, so use a sync.WaitGroup or channel when you must. Several AfterFunc calls on one context are independent. The classic use is to connect cancellation to APIs that have no context parameter: waking a sync.Cond, closing a connection to abort a blocking Read, or merging two contexts (the package documentation shows all three).

context.WithoutCancel(parent) returns a copy of the parent that keeps its values but is never cancelled, has no deadline and Err() == nil. It is the tool for work that must outlive the request that started it, such as writing an audit record after the client has disconnected, or running a cleanup that needs the request’s trace ID. It is also deliberately a little dangerous: the detached work now has no upper bound, so give it a new timeout of its own.

package main

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

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

	var wg sync.WaitGroup
	wg.Add(1)
	// AfterFunc runs f in its own goroutine once ctx is done.
	stop := context.AfterFunc(ctx, func() {
		defer wg.Done()
		fmt.Println("cleanup after:", ctx.Err())
	})
	cancel()
	wg.Wait()
	fmt.Println("stop after run returns:", stop())

	// stop prevents f from running when it is called first.
	ctx2, cancel2 := context.WithCancel(context.Background())
	stop2 := context.AfterFunc(ctx2, func() { fmt.Println("never printed") })
	fmt.Println("stopped before cancel:", stop2())
	cancel2()
	time.Sleep(50 * time.Millisecond)

	// WithoutCancel keeps values but drops cancellation and the deadline.
	type key struct{}
	parent, cancelP := context.WithTimeout(context.WithValue(context.Background(), key{}, "req-7"), time.Millisecond)
	defer cancelP()
	<-parent.Done()
	detached := context.WithoutCancel(parent)
	_, hasDeadline := detached.Deadline()
	fmt.Println("parent:", parent.Err())
	fmt.Println("detached:", detached.Err(), detached.Done() == nil, hasDeadline, detached.Value(key{}))
}
cleanup after: context canceled
stop after run returns: false
stopped before cancel: true
parent: context deadline exceeded
detached: <nil> true false req-7

The detached-audit pattern in a realistic shape, together with a first-error-wins fan-out written with only the standard library and sync.WaitGroup.Go (Go 1.25), which starts a goroutine and does the Add/Done bookkeeping for you:

package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"
)

type key struct{}

// audit must finish even if the request that triggered it is cancelled,
// so it detaches from the request's cancellation but keeps its values.
func audit(ctx context.Context, wg *sync.WaitGroup, action string) {
	detached := context.WithoutCancel(ctx)
	wg.Go(func() {
		bounded, cancel := context.WithTimeout(detached, 2*time.Second)
		defer cancel()
		select {
		case <-time.After(30 * time.Millisecond): // the slow write
			fmt.Printf("audited %q for %v (request ctx: %v)\n", action, bounded.Value(key{}), ctx.Err())
		case <-bounded.Done():
			fmt.Println("audit lost:", bounded.Err())
		}
	})
}

func main() {
	req, cancel := context.WithCancel(context.WithValue(context.Background(), key{}, "req-9"))
	var wg sync.WaitGroup
	audit(req, &wg, "lend 9780441172719")
	cancel() // the client disconnects right away
	wg.Wait()

	// First-error-wins fan-out using only the standard library.
	ctx, stop := context.WithCancelCause(context.Background())
	defer stop(nil)
	var mu sync.Mutex
	var failures []string
	var group sync.WaitGroup
	for i, isbn := range []string{"9780441172719", "bad-isbn", "9780140449136"} {
		group.Go(func() {
			if isbn == "bad-isbn" {
				stop(fmt.Errorf("import item %d: %w", i, errors.New("invalid ISBN")))
				return
			}
			select {
			case <-time.After(10 * time.Second):
			case <-ctx.Done():
				mu.Lock()
				failures = append(failures, isbn)
				mu.Unlock()
			}
		})
	}
	group.Wait()
	fmt.Println("cause:", context.Cause(ctx))
	fmt.Println("abandoned items:", len(failures))
}
audited "lend 9780441172719" for req-9 (request ctx: context canceled)
cause: import item 1: invalid ISBN
abandoned items: 2

The audit write completes although its request context was cancelled; the fan-out stops the two healthy workers the moment the third reports a bad ISBN, and Cause carries the reason. For production use the golang.org/x/sync/errgroup package (see below) packages this pattern.

Respecting cancellation in your own code

A context only signals; it cannot stop a goroutine. The goroutine has to look. Three habits cover almost every case:

  1. Every blocking channel operation in a goroutine that can be cancelled is a select that also listens on <-ctx.Done(). A bare ch <- v or <-ch that nobody will ever complete is the classic goroutine leak.

  2. Long loops that never block poll ctx.Err() (cheap, but not free) every so many iterations.

  3. Return the error, wrapped with context about where you stopped: fmt.Errorf("checksum stopped at byte %d: %w", i, err). Do not swallow it and do not replace it with a new sentinel; callers test it with errors.Is.

The producer below shows the first habit: its goroutine always ends, either because the data is exhausted or because the consumer cancelled. The consumer cancels after two books and `break`s.

package main

import (
	"context"
	"fmt"
)

type Book struct{ Title string }

// Books streams books from the source until it is exhausted or ctx is done.
// The goroutine always exits: either the source ends or ctx is cancelled.
func Books(ctx context.Context, titles []string) <-chan Book {
	out := make(chan Book)
	go func() {
		defer close(out)
		for _, t := range titles {
			select {
			case out <- Book{Title: t}:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	titles := []string{"Dune", "Emma", "Ulysses", "Hamlet", "Beloved"}
	n := 0
	for b := range Books(ctx, titles) {
		n++
		fmt.Println("got", b.Title)
		if n == 2 {
			cancel() // stop the producer; it releases its goroutine
			break
		}
	}
	fmt.Println("stopped after", n, "books:", ctx.Err())
}
got Dune
got Emma
stopped after 2 books: context canceled

CPU-bound code and sleeping are the two places where people forget. time.Sleep cannot be interrupted, so use a timer in a select; and polling ctx.Err() on every iteration of a tight loop is more expensive than doing the work, so poll every few hundred or thousand iterations:

package main

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

// checksum models CPU-bound work: it polls ctx.Err every 1024 iterations
// instead of on every one, because the check is cheap but not free.
func checksum(ctx context.Context, data []byte) (uint32, error) {
	var sum uint32
	for i, b := range data {
		if i%1024 == 0 {
			if err := ctx.Err(); err != nil {
				return 0, fmt.Errorf("checksum stopped at byte %d: %w", i, err)
			}
		}
		sum = sum*31 + uint32(b)
	}
	return sum, nil
}

// pause waits for d or until ctx is done, unlike time.Sleep which cannot be interrupted.
func pause(ctx context.Context, d time.Duration) error {
	t := time.NewTimer(d)
	defer t.Stop()
	select {
	case <-t.C:
		return nil
	case <-ctx.Done():
		return context.Cause(ctx)
	}
}

func main() {
	data := make([]byte, 1<<20)

	ctx, cancel := context.WithCancel(context.Background())
	sum, err := checksum(ctx, data)
	fmt.Println(sum, err)

	cancel()
	_, err = checksum(ctx, data)
	fmt.Println(err)

	short, cancelShort := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancelShort()
	start := time.Now()
	err = pause(short, time.Hour)
	fmt.Println(err, time.Since(start) < 5*time.Second)
}
0 <nil>
checksum stopped at byte 0: context canceled
context deadline exceeded true

The next example is the same idea at API level: the shelf Store interface takes a context in every method that can block, as the first parameter, named ctx, and the implementation checks it before doing work:

package main

import (
	"context"
	"errors"
	"fmt"
)

type ISBN string

type Book struct {
	ISBN  ISBN
	Title string
}

// Store is the shelf catalogue; every method that may block takes a Context
// as its first parameter, named ctx.
type Store interface {
	Get(ctx context.Context, isbn ISBN) (Book, error)
	Put(ctx context.Context, b Book) error
}

type memStore struct{ books map[ISBN]Book }

func (s *memStore) Get(ctx context.Context, isbn ISBN) (Book, error) {
	if err := ctx.Err(); err != nil {
		return Book{}, err
	}
	b, ok := s.books[isbn]
	if !ok {
		return Book{}, errors.New("not found")
	}
	return b, nil
}

func (s *memStore) Put(ctx context.Context, b Book) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	s.books[b.ISBN] = b
	return nil
}

func main() {
	var st Store = &memStore{books: map[ISBN]Book{}}
	ctx := context.Background()
	fmt.Println(st.Put(ctx, Book{"9780441172719", "Dune"}))
	b, err := st.Get(ctx, "9780441172719")
	fmt.Println(b, err)

	cctx, cancel := context.WithCancel(ctx)
	cancel()
	_, err = st.Get(cctx, "9780441172719")
	fmt.Println(err)
}
<nil>
{9780441172719 Dune} <nil>
context canceled

Goroutine leaks

A goroutine blocked forever on a channel is never garbage-collected: it keeps its stack and everything it references until the process exits. Contexts exist largely to prevent this. The first function below leaks one goroutine every time a caller stops reading; the second ties the producer to a context. The program counts goroutines with runtime.NumGoroutine and waits (with a generous five-second cap) for the count to settle, so its output does not depend on scheduling:

package main

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

// leaky starts a producer that blocks forever once the caller stops reading.
func leaky() <-chan int {
	ch := make(chan int)
	go func() {
		for i := 0; ; i++ {
			ch <- i
		}
	}()
	return ch
}

// fixed ties the producer's lifetime to ctx.
func fixed(ctx context.Context) <-chan int {
	ch := make(chan int)
	go func() {
		defer close(ch)
		for i := 0; ; i++ {
			select {
			case ch <- i:
			case <-ctx.Done():
				return
			}
		}
	}()
	return ch
}

// settle waits until the goroutine count reaches want (or gives up).
func settle(want int) int {
	deadline := time.Now().Add(5 * time.Second)
	for runtime.NumGoroutine() != want && time.Now().Before(deadline) {
		time.Sleep(5 * time.Millisecond)
	}
	return runtime.NumGoroutine()
}

func main() {
	base := runtime.NumGoroutine()

	ch := leaky()
	<-ch
	<-ch // the caller walks away after two values
	fmt.Println("leaky: extra goroutines:", settle(base)-base)

	ctx, cancel := context.WithCancel(context.Background())
	ch2 := fixed(ctx)
	<-ch2
	<-ch2
	cancel()
	fmt.Println("fixed: extra goroutines:", settle(base+1)-base-1)
}
leaky: extra goroutines: 1
fixed: extra goroutines: 0

In tests, leak checks are possible by comparing goroutine counts, as above. For running programs, Go 1.26 added an experimental goroutineleak profile (enabled with GOEXPERIMENT=goroutineleakprofile) that reports goroutines blocked on a channel, mutex or condition variable that nothing can ever unblock, and Go 1.27 made it generally available in runtime/pprof and as the /debug/pprof/goroutineleak endpoint of net/http/pprof. See Profiling, Tracing and Diagnostics.

Which APIs accept a context

Cancellation is cooperative, so it only reaches code that is written to listen. The table lists the standard-library entry points; a function that is missing from it (a plain sync.Mutex.Lock, time.Sleep, bufio.Scanner.Scan, a CPU loop) will not notice cancellation unless you add the checks above.

API What cancellation does

http.NewRequestWithContext, Request.WithContext

Cancels the in-flight client request, including reading the response body.

http.Server handlers: r.Context()

Cancelled when the client disconnects, the request is cancelled (HTTP/2) or the handler returns.

(*sql.DB).QueryContext, ExecContext, BeginTx, PingContext

Cancels the query and, for transactions, rolls back when the context is done.

(*net.Dialer).DialContext, net.Resolver lookups

Aborts a connection attempt or DNS lookup.

exec.CommandContext

Kills the process (or runs Cmd.Cancel) when the context is done.

signal.NotifyContext

Produces a context that is cancelled when a signal arrives.

log/slog: InfoContext, Handler.Enabled(ctx, level)

Passes the context to handlers so they can read values; it does not stop logging.

testing: T.Context, B.Context

Produces a context cancelled when the test ends (Go 1.24).

golang.org/x/sync/errgroup, golang.org/x/sync/semaphore

Groups and weighted semaphores that cancel or abandon waiting with a context.

Passing contexts

The conventions are short and are enforced by review and by linters rather than by the compiler:

  • Context is the first parameter and is named ctx. func (s *Store) Get(ctx context.Context, isbn ISBN) (Book, error). The revive and staticcheck linters check the position; go vet does not.

  • Do not store a Context in a struct. A context describes one call or one request, and a stored context makes the lifetime of an object depend on a value chosen elsewhere, so cancellation becomes invisible and a long-lived object ends up holding a dead or too-short-lived context. Pass it to each method that needs it. The blog post Contexts and structs explains the rule and its narrow exceptions: a type that really is the single-use representation of one request (the standard library’s http.Request is the example) may carry one, and an older API that cannot change its signatures may need to. For new code, take the parameter.

  • Do not pass nil. Use context.TODO() if you have to.

  • Use context values only for request-scoped data that crosses API boundaries, never for optional function parameters, handles, configuration or dependencies.

  • The same context may be passed to many goroutines. It is immutable and safe for concurrent use.

  • Do not wrap ctx.Err() in your own sentinel if you want callers to see cancellation; wrap with %w.

Adding a ctx to an interface method is a breaking change, so decide early: the cost of an unused parameter is nothing, and the cost of retrofitting it into a published interface is a new major version. That is why the Store of the running shelf scenario takes a context on every I/O method from the first release.

Panics, vet reports and compile errors

The panics happen in the arguments, not in cancellation: the package rejects programs that cannot work.

package main

import (
	"context"
	"fmt"
)

func try(name string, f func()) {
	defer func() { fmt.Printf("%s: %v\n", name, recover()) }()
	f()
}

func main() {
	try("nil parent", func() {
		_, cancel := context.WithCancel(nil)
		cancel()
	})
	try("nil key", func() { _ = context.WithValue(context.Background(), nil, "x") })
	try("slice key", func() { _ = context.WithValue(context.Background(), []int{1}, "x") })
}
nil parent: cannot create context from nil parent
nil key: nil key
slice key: key is not comparable

The go vet analyzer that applies here is lostcancel (shown above), plus unusedresult for calls whose results are dropped. Staticcheck adds SA1012 (a nil context) and SA1029 (a built-in type such as string used as a WithValue key).

Context values

WithValue(parent, key, val) returns a node holding one key-value pair. ctx.Value(key) walks from the current node toward the root and returns the first match, comparing keys with ==; the key must therefore be comparable, and a nil or non-comparable key panics (see above). Because the lookup is a linear walk, every value you add makes every Value call slower and every context a little harder to understand, so keep the number small.

Chain of four contexts: two WithValue nodes, a timeout node and Background, linked by parent arrows, with a lookup for the request ID key walking from the newest node to the second node where the key matches

The rules, in order of importance:

  1. Use an unexported key type defined in the package that owns the value. A key of type string or int from two packages would collide silently; a key of an unexported type cannot even be written by other packages, so no collision is possible, as the third lookup in the example below shows. An int constant of a private type, or the empty struct{} type, are the two common choices.

  2. Hide the key behind functions. The owning package exports a ContextWith…/With… function to store and a …FromContext/…From function to read, so the key type and the type assertion stay private and callers never see any.

  3. Store request-scoped data only: request and trace IDs, the authenticated principal, a deadline’s cause. Not database handles, loggers, configuration or optional parameters: those are visible dependencies, so pass them as arguments or fields (see the running Store).

  4. Treat values as read-only and return a copy; the tree is shared by many goroutines.

  5. Context values are not an alternative to parameters for control flow. If a function behaves differently depending on a context value, the behaviour is hidden from its signature and from tests.

package main

import (
	"context"
	"fmt"
	"log/slog"
	"os"
)

// Unexported key types make collisions with other packages impossible:
// even a string key "request-id" from another package is a different key.
type ctxKey int

const (
	requestIDKey ctxKey = iota
	memberKey
)

// WithRequestID returns a copy of ctx carrying the request ID.
func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, requestIDKey, id)
}

// RequestIDFrom extracts the request ID, if any.
func RequestIDFrom(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(requestIDKey).(string)
	return id, ok
}

// WithMember and MemberFrom follow the same pattern for a second key.
func WithMember(ctx context.Context, m string) context.Context {
	return context.WithValue(ctx, memberKey, m)
}

func MemberFrom(ctx context.Context) (string, bool) {
	m, ok := ctx.Value(memberKey).(string)
	return m, ok
}

func main() {
	ctx := WithRequestID(context.Background(), "req-42")
	ctx = WithMember(ctx, "ana")

	id, ok := RequestIDFrom(ctx)
	fmt.Println(id, ok)
	m, ok := MemberFrom(ctx)
	fmt.Println(m, ok)

	_, ok = RequestIDFrom(context.Background())
	fmt.Println("missing:", ok)

	// A string key from "another package" does not collide with ours.
	foreign := context.WithValue(ctx, "request-id", "someone-else")
	id, _ = RequestIDFrom(foreign)
	fmt.Println(id, foreign.Value("request-id"))

	// Context values are visible to the whole call tree below, so a logging
	// helper can use them without every function taking a logger parameter.
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey {
				return slog.Attr{}
			}
			return a
		},
	}))
	if id, ok := RequestIDFrom(ctx); ok {
		logger = logger.With("request_id", id)
	}
	logger.InfoContext(ctx, "lending book", "isbn", "9780441172719")
}
req-42 true
ana true
missing: false
req-42 someone-else
level=INFO msg="lending book" request_id=req-42 isbn=9780441172719

The slog calls ending the example use InfoContext, which passes the context to the handler; a custom handler can read the request ID from it, so loggers need no per-request construction. See Structured Logging with slog.

The String method of a derived context prints the keys and values it holds, handy in a debugger, and shows the chain that the picture above draws:

package main

import (
	"context"
	"fmt"
)

type key struct{}

func main() {
	ctx := context.Background()
	for i := range 3 {
		ctx = context.WithValue(ctx, i, fmt.Sprint("v", i))
	}
	ctx = context.WithValue(ctx, key{}, "deep")
	fmt.Println(ctx)
	fmt.Println(ctx.Value(0), ctx.Value(key{}), ctx.Value(99))
}
context.Background.WithValue(int, v0).WithValue(int, v1).WithValue(int, v2).WithValue(main.key, deep)
v0 deep <nil>

Contexts in net/http

On the server, Request.Context() returns a context created for each request. It is cancelled when the client’s connection closes, when an HTTP/2 request is cancelled, and when the ServeHTTP method returns. A handler that does slow work must pass r.Context() on (to the database, to downstream HTTP calls) so abandoned requests stop costing resources. Middleware adds values with r.WithContext(context.WithValue(...)) (which shallow-copies the request).

On the client, build requests with http.NewRequestWithContext; the same context cancels dialling, TLS, sending the request and reading the body. It composes with Client.Timeout (an overall limit per request, including body reading), which you should still set. Never use http.DefaultClient in production code, because it has no timeout; the example uses the test server’s client for brevity. The example starts a server whose handler waits for cancellation and reports what it saw through a channel (so the order of events is deterministic), makes a client call with a 100 millisecond timeout and shows both sides:

package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"
)

func main() {
	observed := make(chan error, 1)

	mux := http.NewServeMux()
	mux.HandleFunc("GET /books/{isbn}", func(w http.ResponseWriter, r *http.Request) {
		// r.Context() is cancelled when the client goes away, when the
		// request is cancelled (HTTP/2) or when ServeHTTP returns.
		select {
		case <-r.Context().Done():
			observed <- context.Cause(r.Context())
		case <-time.After(time.Minute):
			fmt.Fprintln(w, "slow answer")
		}
	})
	mux.HandleFunc("GET /fast/{isbn}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "book %s\n", r.PathValue("isbn"))
	})
	srv := httptest.NewServer(mux)
	defer srv.Close()

	// The client side: the request carries a context with a timeout.
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/books/9780441172719", nil)
	if err != nil {
		panic(err)
	}
	_, err = srv.Client().Do(req)
	fmt.Println("client error is deadline:", errors.Is(err, context.DeadlineExceeded))

	select {
	case cause := <-observed:
		fmt.Println("server saw:", cause)
	case <-time.After(10 * time.Second):
		fmt.Println("server never noticed")
	}

	// A normal request still works, and Response.Body must be closed.
	req2, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+"/fast/9780441172719", nil)
	resp, err := srv.Client().Do(req2)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	buf := make([]byte, 64)
	n, _ := resp.Body.Read(buf)
	fmt.Print(string(buf[:n]))
}
client error is deadline: true
server saw: context canceled
book 9780441172719

The server saw context canceled, not deadline exceeded: the server cannot know the client’s deadline, only that the connection went away. The sequence, as drawn:

sequenceDiagram participant C as Client participant S as Server participant H as Handler C->>S: GET /books/9780441172719 with a 100 ms context S->>H: ServeHTTP(w, r) with r.Context() H->>H: select on r.Context().Done() Note over C: deadline passes C--xS: connection closed S-->>H: r.Context() cancelled (Canceled) H->>H: returns, cleans up C->>C: Do returns DeadlineExceeded

http.Server also has BaseContext (the root of every request context; use it to attach a value or tie all requests to a server-wide context) and ConnContext (per connection). The Go 1.23 release notes added that a DNS error caused by a timeout or cancellation now wraps context.DeadlineExceeded or context.Canceled, so errors.Is works through the resolver as well:

package main

import (
	"context"
	"fmt"
	"net"
	"net/http"
	"net/http/httptest"
	"time"
)

type reqKey struct{}

func main() {
	var seen string
	mux := http.NewServeMux()
	mux.HandleFunc("GET /ping", func(w http.ResponseWriter, r *http.Request) {
		seen, _ = r.Context().Value(reqKey{}).(string)
		fmt.Fprintln(w, "pong")
	})
	srv := httptest.NewUnstartedServer(mux)
	// BaseContext sets the root of every request context; ConnContext runs per connection.
	srv.Config.BaseContext = func(_ net.Listener) context.Context {
		return context.WithValue(context.Background(), reqKey{}, "server-root")
	}
	srv.Start()
	defer srv.Close()

	c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	req, _ := http.NewRequestWithContext(c, http.MethodGet, srv.URL+"/ping", nil)
	resp, err := srv.Client().Do(req)
	if err != nil {
		panic(err)
	}
	resp.Body.Close()
	fmt.Println(resp.Status, seen)
}
200 OK server-root

Two related tools: http.TimeoutHandler (documentation) wraps a handler with a time limit that also cancels its request context, and Server.Shutdown(ctx) takes a context that bounds the graceful shutdown, discussed below. Go 1.23 added httptest.NewRequestWithContext for tests.

Contexts in database/sql

Every database/sql operation has a …Context variant (QueryContext, QueryRowContext, ExecContext, PrepareContext, PingContext, BeginTx, Conn); the non-context variants simply use context.Background() and are the wrong default in a service. A cancelled context aborts the query on the driver side where the driver supports it, and a transaction started with BeginTx is rolled back automatically when its context ends before Commit. Derive a shorter timeout per query from the request context so one slow query cannot consume the request’s whole budget. The example below is compiled and vetted but not run, because running it needs a driver and a database; the DSN, in a real program, comes from the environment.

package main

import (
	"context"
	"database/sql"
	"fmt"
	"time"
)

// Title looks up a title with a per-query budget derived from the caller's context.
// It is compiled and vetted, but not run: running it needs a database driver and a DSN.
func Title(ctx context.Context, db *sql.DB, isbn string) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	var title string
	err := db.QueryRowContext(ctx, "SELECT title FROM books WHERE isbn = $1", isbn).Scan(&title)
	if err != nil {
		return "", fmt.Errorf("title %s: %w", isbn, err)
	}
	return title, nil
}

func main() {
	var db *sql.DB // opened with sql.Open and a driver in a real program
	_ = db
	fmt.Println("see the function Title")
}
see the function Title

See database/sql for pooling, sql.ErrNoRows and transactions.

Contexts in os/exec and signal handling

exec.CommandContext(ctx, name, args...) kills the process with Process.Kill when the context is done. Two fields refine it (Go 1.20): Cmd.Cancel replaces the kill with your own action (for instance sending SIGINT first) and Cmd.WaitDelay bounds how long Wait waits for the process and its output pipes after cancellation, so a child that inherited a pipe cannot hang the parent forever. The example uses sleep and sends a signal, so it is for Unix-like systems (Linux, macOS); it was run on macOS:

package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer cancel()

	cmd := exec.CommandContext(ctx, "sleep", "30")
	cmd.WaitDelay = time.Second // how long Wait lets I/O drain after cancellation
	start := time.Now()
	err := cmd.Run()
	fmt.Println("error:", err)
	fmt.Println("deadline:", errors.Is(ctx.Err(), context.DeadlineExceeded))
	fmt.Println("fast:", time.Since(start) < 10*time.Second)

	// Cancel lets you choose a gentler signal than Kill.
	ctx2, cancel2 := context.WithCancel(context.Background())
	cmd2 := exec.CommandContext(ctx2, "sleep", "30")
	cmd2.Cancel = func() error {
		fmt.Println("custom cancel: sending interrupt")
		return cmd2.Process.Signal(os.Interrupt)
	}
	if err := cmd2.Start(); err != nil {
		panic(err)
	}
	cancel2()
	fmt.Println("wait:", cmd2.Wait())
}
error: signal: killed
deadline: true
fast: true
custom cancel: sending interrupt
wait: signal: interrupt

Source: os/exec: Cmd

signal.NotifyContext(parent, signals...) returns a context cancelled when the process receives one of the listed signals; call the returned stop function to restore default handling (so a second Ctrl+C kills a program that is stuck in shutdown). Since Go 1.26 it cancels the context with a cause naming the signal, so context.Cause(ctx) distinguishes interrupt signal received from terminated signal received. The example sends itself SIGINT so that it is deterministic (again for Unix-like systems; on Windows, Ctrl+C is delivered as os.Interrupt, so listen for that):

package main

import (
	"context"
	"fmt"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	// Simulate an operator pressing Ctrl+C: send ourselves SIGINT.
	go func() {
		time.Sleep(50 * time.Millisecond)
		syscall.Kill(syscall.Getpid(), syscall.SIGINT)
	}()

	select {
	case <-ctx.Done():
		fmt.Println("Err:  ", ctx.Err())
		fmt.Println("Cause:", context.Cause(ctx))
	case <-time.After(30 * time.Second):
		fmt.Println("no signal arrived")
	}

	// stop restores default behaviour: a second Ctrl+C now kills the process.
	stop()
	fmt.Println("shutdown complete")
}
Err:   context canceled
Cause: interrupt signal received
shutdown complete

Graceful shutdown of the shelf service

The signal context and Server.Shutdown are used together in every Go service. The subtle point, which the program below illustrates, is that the context passed to Shutdown must not be the already-cancelled signal context: derive the shutdown budget from context.WithoutCancel(ctx) so it has its own timer. The same program decides what request contexts derive from: context.WithoutCancel(ctx) as shown lets in-flight handlers finish during the shutdown window, while returning ctx itself would cancel them the moment the signal arrives.

package main

import (
	"context"
	"errors"
	"fmt"
	"net"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	})
	srv := &http.Server{
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
		// Request contexts survive the signal so in-flight handlers can finish
		// during Shutdown. Return ctx itself to cancel them as soon as the signal arrives.
		BaseContext: func(net.Listener) context.Context { return context.WithoutCancel(ctx) },
	}
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		panic(err)
	}

	serveErr := make(chan error, 1)
	go func() { serveErr <- srv.Serve(ln) }()

	// Simulate the operator: a SIGTERM from the orchestrator.
	go func() {
		time.Sleep(100 * time.Millisecond)
		syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
	}()

	<-ctx.Done()
	fmt.Println("shutting down:", context.Cause(ctx))

	// ctx is already cancelled, so the shutdown budget needs a fresh context.
	shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		fmt.Println("shutdown error:", err)
	}
	fmt.Println("Serve returned:", errors.Is(<-serveErr, http.ErrServerClosed))
}
shutting down: terminated signal received
Serve returned: true

errgroup and structured fan-out

golang.org/x/sync/errgroup is not part of the standard library; the version checked on 2026-10-18 is v0.24.0. Its Group runs functions in goroutines, waits for all of them, returns the first non-nil error and, when created with errgroup.WithContext, cancels the shared context as soon as one function fails or Wait returns. SetLimit(n) bounds concurrency (Go then blocks when the limit is reached; TryGo does not). This is the idiomatic replacement for the hand-written fan-out above. The fragment below needs the module (go get golang.org/x/sync@v0.24.0), so it was not compiled in the offline scratch module; it follows the package documentation:

// Fragment: needs golang.org/x/sync v0.24.0.
func ImportAll(ctx context.Context, isbns []string, fetch func(context.Context, string) error) error {
	g, ctx := errgroup.WithContext(ctx)
	g.SetLimit(8) // at most 8 fetches in flight
	for _, isbn := range isbns {
		g.Go(func() error {
			return fetch(ctx, isbn) // ctx is cancelled when any call returns an error
		})
	}
	return g.Wait() // first error, after every goroutine has returned
}

Note the loop variable: since Go 1.22 each iteration has its own isbn, so the closure needs no copy (see Functions, Closures and Defer). The worker-pool and pipeline shapes are in Concurrency Patterns.

Testing code that uses contexts

T.Context() and B.Context() (Go 1.24) return a context that is cancelled just before the test’s Cleanup functions run. Use it instead of context.Background() for goroutines started by the test: when the test returns, they are told to stop, and a cleanup function can then wait for them to finish. Time-based behaviour is tested with testing/synctest (experimental in 1.24, generally available since 1.25): inside a "bubble" the clock is fake and advances only when every goroutine is blocked, so a one-hour timeout takes no real time and the result cannot flake. Both are demonstrated below, for a Poll function that checks a condition every interval until it is true or the context is done. See Benchmarks, Fuzzing and synctest and Testing.

package watch

import (
	"context"
	"time"
)

// Poll calls check every interval until it reports true or ctx is done.
func Poll(ctx context.Context, interval time.Duration, check func() bool) error {
	t := time.NewTicker(interval)
	defer t.Stop()
	for {
		if check() {
			return nil
		}
		select {
		case <-t.C:
		case <-ctx.Done():
			return context.Cause(ctx)
		}
	}
}
package watch

import (
	"context"
	"errors"
	"sync"
	"testing"
	"testing/synctest"
	"time"
)

// t.Context is cancelled just before cleanup functions run, so goroutines
// started by the test can stop themselves without an explicit cancel.
func TestPollStopsWithTest(t *testing.T) {
	stopped := make(chan error, 1)
	go func() {
		stopped <- Poll(t.Context(), time.Millisecond, func() bool { return false })
	}()
	t.Cleanup(func() {
		// Cleanup runs after t.Context() was cancelled.
		select {
		case err := <-stopped:
			if !errors.Is(err, context.Canceled) {
				t.Errorf("got %v, want Canceled", err)
			}
		case <-time.After(10 * time.Second):
			t.Error("poller did not stop")
		}
	})
}

// synctest runs the test in a bubble with a fake clock, so a 55-minute
// timeout takes no real time and the result is deterministic.
func TestPollTimeout(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		ctx, cancel := context.WithTimeout(t.Context(), 55*time.Minute)
		defer cancel()

		var mu sync.Mutex
		calls := 0
		err := Poll(ctx, 10*time.Minute, func() bool {
			mu.Lock()
			defer mu.Unlock()
			calls++
			return false
		})
		if !errors.Is(err, context.DeadlineExceeded) {
			t.Fatalf("got %v, want DeadlineExceeded", err)
		}
		t.Logf("check ran %d times before the deadline", calls)
	})
}
=== RUN   TestPollStopsWithTest
--- PASS: TestPollStopsWithTest (0.00s)
=== RUN   TestPollTimeout
    watch_test.go:50: check ran 6 times before the deadline
--- PASS: TestPollTimeout (0.00s)
PASS
ok  	example.com/shelf/watch	0.058s

The check ran six times: at 0, 10, 20, 30, 40 and 50 minutes, and the 55 minute timeout then ended the loop. The timeout is deliberately not a multiple of the interval; if it were, the ticker and the deadline would become ready at the same instant, select would choose between them at random and the count would vary between runs.

Practical guidance for tests:

  • Give tests that depend on cancellation a large safety margin: wait up to seconds for something that should happen in microseconds, and fail only if the margin is exceeded. A short timeout in a test is a flake generator.

  • Prefer channels and sync.WaitGroup over time.Sleep to wait for goroutines, as the examples on this page do.

  • go test -timeout bounds the whole test binary (default ten minutes); -race finds data races around shared state accessed from cancelled goroutines.

Pitfalls

Pitfall What to do instead

Storing a context in a struct field

Pass it per call. Reserve the field for single-use request objects.

defer cancel() omitted

Always call cancel; lostcancel finds some cases, not all.

Using the request context for work that outlives the request

context.WithoutCancel(r.Context()) plus a new timeout; otherwise the work is cancelled as soon as the handler returns.

Using r.Context() after the handler has returned

It is already cancelled. Detach first, as in the audit example.

Ignoring ctx.Done() in a goroutine that blocks on a channel

select on both; otherwise the goroutine leaks.

Comparing err == context.Canceled on a wrapped error

errors.Is(err, context.Canceled).

Logging every Canceled as an error

Treat it as normal flow (client went away); alert on DeadlineExceeded rates instead.

Passing a bare string as a value key

An unexported key type, with accessor functions.

Context values as optional parameters or dependencies

Explicit parameters, options structs, struct fields.

Giving a child a longer timeout than its parent

It is silently clipped to the parent’s deadline: the parent owns the budget.

Reusing a timed-out context for a retry

Derive a new context per attempt from the parent; the timed-out one stays done forever.

Calling cancel and then using results as if the work finished

Cancellation means partial or no results; check ctx.Err() after the call and return it.

context.Background() inside library code that has a caller

Accept a ctx parameter; libraries should not decide the root.

Where the books are outdated

  • Bodner, Learning Go (1st edition), chapter 12 is the book’s context chapter. It teaches the right core (first parameter, Background and TODO, cancellation, timeouts and deadlines, unexported key types with ContextWith…/…FromContext helpers) and the idioms above remain correct. It was written against Go 1.15, so it predates WithCancelCause and Cause (1.20), WithoutCancel, AfterFunc and the …Cause timeout variants (1.21), exec.Cmd.Cancel/WaitDelay (1.20), T.Context (1.24), the cause attached by signal.NotifyContext (1.26) and testing/synctest (1.25). It also predates WaitGroup.Go (1.25) and the per-iteration loop variables of 1.22, which remove a copy of the loop variable that older examples need before starting goroutines.

  • Vivien, Learning Go Programming (2016) targets Go 1.6/1.7 and predates the package becoming part of the standard library: its concurrency chapter uses done channels and time.After for cancellation and timeouts, and its net/http chapter configures Transport.Dial instead of DialContext. The patterns still work, but new code should use contexts and Transport.DialContext.

  • Both books describe http.Client.Timeout and server timeouts correctly but were written before request contexts were commonplace in net/http handlers and before http.Server.BaseContext/ConnContext existed (Go 1.13).

  • Neither book covers deprecated-API replacements that appear around contexts: golang.org/x/net/context is now a thin alias for the standard package (use the standard one), and http.CloseNotifier was superseded by the request context.

Version notes

  • Go 1.7: context enters the standard library; net/http adds Request.Context.

  • Go 1.13: http.Server.BaseContext and ConnContext.

  • Go 1.20: WithCancelCause and Cause; exec.Cmd.Cancel and Cmd.WaitDelay.

  • Go 1.21: WithoutCancel, AfterFunc, WithDeadlineCause and WithTimeoutCause; contexts from Background and TODO may compare equal.

  • Go 1.22: per-iteration loop variables simplify goroutine closures.

  • Go 1.23: DNS errors caused by timeouts or cancellation wrap DeadlineExceeded and Canceled; httptest.NewRequestWithContext; reworked timers.

  • Go 1.24: T.Context and B.Context; testing/synctest experimental.

  • Go 1.25: testing/synctest generally available (synctest.Test); sync.WaitGroup.Go.

  • Go 1.26: signal.NotifyContext sets a cause naming the signal.

  • Go 1.27: the release notes list no changes to the context package; the goroutine leak profile used to find forgotten cancellations is generally available. Examples were checked with go 1.27 in go.mod.