---
title: 'The Context Package'
source: 'https://academia.sh/en/courses/go-concurrency/context-package'
course: Concurrency
language: en
updated: '2026-09-14T10:59:31+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures that a single cancellation reaches every goroutine waiting on the context at the same time, and that all of them stop in the same class'
    - 'Shows how a request-scoped value is carried through the context, and that this does not stand in for explicit function parameters'
    - 'Proves by running it that a goroutine that never checks cancellation completes its work to the end, even if the context has already been cancelled'
---

# The Context Package

A single cancellation reaches all four goroutines waiting on ctx.Done() at the same time; all of them stop in the 'cancelled' class and ctx.Err() carries the same fixed reason. What a request-scoped value (context.WithValue) is for, and what it is not for, are measured separately. Limiting measurement: a context does not force anyone to stop, it only notifies — a goroutine that never checks ctx.Done() completes its own work to the end, even though the context has already been cancelled.

The previous lesson measured a leak and left it unsolved: when a consumer exited a pipeline
early, the stages ahead of it had no way at all to know about it. It was not enough for the
stages to listen to their own input channels — a signal was needed that came from outside and
reached **all** stages at the same time. This lesson measures the tool that carries that signal:
the context package (the `context` package). A context being cancelled reaches every goroutine
waiting on it, however many there are, through a single event — unlike a channel, there is no
"how many receivers" question here at all, because a context's cancellation signal works like a
**broadcast**, not to a single receiver but to all of them at once.

The `context.Context` type the context package introduces is not, on its own, a new mechanism —
underneath it is only a channel (the one `Done()` returns) and an error value (the one `Err()`
returns). What is new is that this pair can be derived in a **tree** shape: when a new context is
derived from a context (with calls like `context.WithCancel`, `context.WithTimeout`), cancelling
the parent context automatically cancels every child context too. This lesson starts with a
single-layer tree (a single context derived from `context.Background()`); the tree itself is not
this lesson's subject, but how many waiters a single node's cancellation reaches is measured here
in clear terms.

## One Cancellation, Every Waiter in the Chain

The measurement below starts four goroutines; each one waits on `ctx.Done()`. After a single
`cancel()` call, all four send their report — all in the same class: **cancelled**.

```go
// cancelspreads.go — a single cancellation reaches every goroutine waiting on the context at the same time
package main

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

type report struct {
	id     int
	reason string
}

func waiter(id int, ctx context.Context, reports chan<- report, group *sync.WaitGroup) {
	defer group.Done()
	<-ctx.Done()
	reports <- report{id: id, reason: "cancelled"}
}

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

	reports := make(chan report, waiterCount)
	var group sync.WaitGroup
	for id := 1; id <= waiterCount; id++ {
		group.Add(1)
		go waiter(id, ctx, reports, &group)
	}

	cancel()
	group.Wait()
	close(reports)

	records := make([]report, 0, waiterCount)
	for r := range reports {
		records = append(records, r)
	}
	sort.Slice(records, func(i, j int) bool { return records[i].id < records[j].id })

	fmt.Println("-- one cancellation, four waiters --")
	for _, r := range records {
		fmt.Printf("waiter %d  reason=%s\n", r.id, r.reason)
	}
	fmt.Println("ctx.Err():", ctx.Err())
}
```

```
-- one cancellation, four waiters --
waiter 1  reason=cancelled
waiter 2  reason=cancelled
waiter 3  reason=cancelled
waiter 4  reason=cancelled
ctx.Err(): context canceled
```

**CP15.** What `context.WithCancel` returns as `ctx.Done()` is a **channel**, but not an
ordinary one: when `cancel()` is called, this channel **closes**, and the "a receive from a
closed channel does not block" behavior seen repeatedly in the previous lessons applies here too.
All four goroutines were waiting on the `<-ctx.Done()` line; the moment the channel closes, all
four are freed at the same time. Closing belonged to a **single** side in every previous
lesson too — a producer, a stage, or a third party (like the one in the Fan-Out and Fan-In lesson
who knew when all producers had finished); it is the same here too. `cancel` itself wraps a
single close call; even more than one `cancel` call closes the channel only once. This is the
context package's counterpart to the Fan-Out and Fan-In lesson's "closing a channel is an atomic
operation" observation. This, too, is a deliberate design decision: it is safe to call `cancel`
more than once, even from more than one goroutine — the caller does not need to track its own
state wondering "did I already cancel this," because the second and later calls silently do
nothing at all.

Sorting the four reports by `id` (`sort.Slice`) comes from the same need seen in the previous
lessons' fan-in channels: four goroutines can write to the `reports` channel at the same time,
and which one arrives first depends on timing. The difference is that this time sorting does not
affect the **result** — all four are already in the same class (cancelled), sorting only makes
the output readable and repeatable.

**CP16.** All four stopping in the same class (**cancelled**) is the plainest form, in this
context, of the four-class measure established in the course's first lesson (on its own, what it
waited for arrived, cancelled, never stopped): none of them finished its own body, none was
waiting for another goroutine's step, all four stopped as the direct result of a single
cancellation event. The text `ctx.Err()` returns is fixed too — "context canceled" — a
version-independent, fixed string defined by the language's own standard library; a caller
wanting to tell which context was cancelled for which reason can read this text.

These four were, in the previous lessons, the "cancelled" class's **only** example (two waiters
in the shared definition) — here that same class is raised fourfold and the result does not
change, because a channel closing shows the same behavior no matter how many receivers there are.
This scale-independence is the context package's real strength: on a web server, the
cancellation of a single request can reach dozens of goroutines handling that request (a database
query, a few outside calls, a cache read) through the same `ctx.Done()` channel, without needing
to warn any of them one by one.

## Carrying a Request-Scoped Value

The context package's second job is not spreading cancellation, it is carrying a **request-scoped
value** through the call chain — for example, carrying a request ID down to the deepest call,
without changing the signature of every function in the chain. This is a separate need that none
of the previous lessons' channel arrangements meets: channels **stream** data, while context
values carry it **alongside** the call chain. In all three of the worker pool, fan-out/fan-in, and
pipeline measurements, data always flowed through an explicit parameter or an explicit channel;
what `context.WithValue` solves is different — carrying a request ID down to the deepest point in
the chain, without adding it separately to the signature of every function handling that request.

Carrying a value works independently of spreading cancellation: the measurement below first sets
up a value-carrying arrangement, then (in the same program, with a separate context) sets up the
limiting measurement showing that cancellation does not stop anyone.

```go
// valueignore.go — carrying a request-scoped value, and a goroutine that never checks cancellation
package main

import (
	"context"
	"fmt"
)

type keyType string

const requestKey keyType = "request-id"

func readDeep(ctx context.Context) string {
	value, ok := ctx.Value(requestKey).(string)
	if !ok {
		return "none"
	}
	return value
}

func main() {
	ctx := context.WithValue(context.Background(), requestKey, "request-42")
	fmt.Println("request id read by the inner function:", readDeep(ctx))

	ctx2, cancel := context.WithCancel(context.Background())
	cancel() // this goroutine has not started yet, but cancellation already happened

	steps := make(chan int, 3)
	go func() {
		// ctx2.Done() is never checked; this goroutine has no idea it was cancelled.
		for step := 1; step <= 3; step++ {
			steps <- step
		}
		close(steps)
	}()

	completedSteps := 0
	for range steps {
		completedSteps++
	}
	fmt.Println("was the context already cancelled while the goroutine ran:", ctx2.Err() != nil)
	fmt.Printf("completed step count: %d / 3\n", completedSteps)
}
```

```
request id read by the inner function: request-42
was the context already cancelled while the goroutine ran: true
completed step count: 3 / 3
```

**CP17.** `requestKey` is not an ordinary string, it is a constant of its own defined
`keyType` type — this prevents another package, at compile time, from using the same string
value as its own key and accidentally writing into the same context slot. `readDeep` reads what
`ctx.Value` returns with a **type assertion** (`.(string)`) and separates out, with the second
return value (`ok`), the possibility that the value was never found at all — this is the context
value's counterpart to the two-value-return access pattern in the dictionary (from **M09/K01**).

What this way of carrying a value **is not** is also part of the measurement: `context.WithValue`
exists not to carry a **required** dependency or parameter a function needs, but for
request-specific, header-like data (a request ID, a trace token) that has to pass through
**every** layer of a call chain, most of which do not use it. The `readDeep` function does not
crash if it cannot find the request ID (`ok` is `false`) — it just returns "none," because a
context value is treated as **optional**. A value that is mandatory for a function to work (a
database connection, a piece of configuration) should be passed directly as a parameter, not
through the context; otherwise the function's signature gives up saying what it needs at all.

This distinction's practical consequence cannot be seen by looking at a function's signature like
`readDeep`'s: the signature `func readDeep(ctx context.Context) string` never says the function
needs a request ID at all — it only takes a context, and what is inside that context stays
unknown until call time. This is an openness similar to the `nil` map and `nil` pointer behaviors
in the Go Fundamentals course: the type system does not check **which** keys a context carries, it
only reports the absence with `nil`/`ok=false` when a key is asked for. The only way to know
which context keys a function reads is to read the source — the signature makes no promise on
this at all.

## A Context Does Not Stop Anyone, It Only Notifies

The second measurement proves this directly. `ctx2` was already cancelled **before** the goroutine
even started — the `cancel()` call runs before the `go func() {...}()` line. This ordering is
deliberate: the measurement's purpose is to eliminate entirely a timing debate like "the
goroutine started while cancellation had not happened yet, then cancellation could not catch up."
The context is already cancelled before the goroutine even exists; despite this, the goroutine
that gets started never checks `ctx2.Done()` at all, it just produces its three steps in order
and closes its channel.

**CP18.** The result is clear: `ctx2.Err() != nil` is `true` — the context was **already**
cancelled by the time the goroutine started running — yet the `completed step count` is still
`3 / 3`. Since the goroutine never looks at whether the context was cancelled at all, it kept
running **completely unaware** of the cancellation and finished its work in full. This is the
exact answer to the question left open at the end of the previous lesson: the context package
does not force anyone to stop, it is not a mechanism that **cuts off** a goroutine. All it does is
notify everyone who asks, once cancellation happens — those listening to `ctx.Done()` take this
notice and make their own decision, those not listening notice nothing at all.

This behavior might look like a defect at first glance — why does the context not stop a goroutine
by itself? The answer lies in Go's overall design: the language's runtime offers no tool at all
for **forcibly** stopping a goroutine from the outside (there is no call in Go resembling cutting
off a thread from outside). A goroutine can only stop by its own choice, on its own code path, by
reaching a `return` or the end of the function. The context package works within this
constraint: since it offers no forced stopping, the only tool it has is **notification** —
telling a goroutine that thinks it should stop, and leaving the decision to it. This is the
clearest example of the principle seen repeatedly in the previous lessons — "the decision to stop
always depends on a check point in the goroutine's own body": the check point here is a
`case <-ctx.Done()` branch inside a `select`, and if that branch was never written in the source,
the cancellation itself carries no meaning at all.

This also draws the boundary of the fix for the pipeline leak measured in the previous lesson:
adding a `select` branch that checks `ctx.Done()` to a stage makes that stage **aware of
cancellation** — but this addition has to be made separately in every stage. If the `square`
stage, say, had written a two-branch `select` instead of a plain send to its output — one
sending the value, the other waiting on `ctx.Done()` — cancelling the context could have freed
this stage too once the consumer exited early. But this change has to be made separately in
**every** stage of the chain; the context itself never automatically stops any goroutine, what
spreads is only **information**, and turning that information into action stays each goroutine's
own responsibility.

This nature of a cancellation context explains why a `context.Context` value does not work like a
lock or an interrupt: it is only a closing channel, and whoever is listening to that closing
channel is the only one who reacts. This also sets the context package on the same footing as
every mechanism in the previous lessons — a channel, a wait group, a `select`, all rest on the
same principle: concurrency in Go is not built by managing a goroutine from the outside, it is
built by a network of channels through which goroutines **notify** each other.

## Summary

- A context being cancelled reaches every goroutine waiting on `ctx.Done()`, however many there
  are, at the same time; all of them stop in the same class (cancelled).
- `ctx.Err()` returns a fixed text defined by the language's standard library; it can be read to
  tell which context was cancelled.
- `context.WithValue` is for optional, header-like data carried between layers; it does not
  stand in for a function's required dependencies.
- A context does not force anyone to stop, it only notifies — a goroutine that never checks
  `ctx.Done()` completes its work to the end even if the context has been cancelled.
- Making a goroutine responsive to cancellation requires it to add a `ctx.Done()` check to its
  own loop; this does not happen automatically.
- Go's runtime offers no tool for forcibly stopping a goroutine from the outside; the context
  works within this constraint, and the only tool it has is notification, not force.

## Next Step

In this lesson, exactly **when** the context itself was cancelled was already known in advance —
`cancel()` was called explicitly, and the waiters were waiting for that call. But the most common
reason a context gets cancelled in real systems is not someone making a deliberate decision, it is
**a duration running out**: waiting for a network call or a database query to respond within a
reasonable time, and giving up if it does not. The next lesson measures this special case: when a
`select` statement picks a branch based on whether it received a result or a cancellation,
a timeout becomes a form of this same choice — what is measured is not the elapsed duration, but
which branch was chosen; this lesson measured an already-known cancellation, the next lesson
measures a cancellation that has **not yet happened** (a timeout) with the same `select`
mechanism.
