# Go Context

> Guides Go context.Context discipline — pass it as the first parameter named ctx and never store it in a struct, derive children with WithCancel/WithTimeout/WithDeadline and ALWAYS defer cancel() (go vet's lostcancel flags a missing one), check ctx.Done()/ctx.Err() in loops and before expensive work, start from context.Background() at entry points and context.TODO() when you don't have one yet, attach reasons with WithCancelCause/Cause and decouple with WithoutCancel/AfterFunc (1.20/1.21), and carry only request-scoped values behind an unexported key type. Auto-invokes when writing or editing functions taking context.Context, WithCancel/WithTimeout/WithValue, ctx.Done()/Err(), or on "where should ctx go" / "is this context leaking" requests. The propagation-and-cancellation depth behind the policy root's "never start a goroutine you can't stop."

- Skill: `ctoth/go-context` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-context`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-context/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-context

---


# Go Context

> "Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx."
> — [context package docs](https://pkg.go.dev/context)

> "At Google, we require that Go programmers pass a `Context` parameter as the first argument to every function on the call path between incoming and outgoing requests."
> — [Go Concurrency Patterns: Context](https://go.dev/blog/context)

A `context.Context` carries a deadline, a cancellation signal, and request-scoped values across API and process boundaries. It is plumbing for *cancellation and propagation*, not a bag for parameters. This skill owns where `ctx` goes, how you derive and cancel children, and what values may ride along; it routes goroutine *lifetime* to `go-concurrency-goroutines`, `select` *mechanics* to `go-channels-select`, and the `Canceled`/`DeadlineExceeded` *sentinels* to `go-error-handling`.

---

## 1. `ctx` Is the First Parameter, Never a Struct Field

The Context flows *down* the call tree as an explicit argument: "The Context should be the first parameter, typically named ctx" ([context docs](https://pkg.go.dev/context)). The Google style guide is categorical: "Do not add a context member to a struct type. Instead, add a context parameter to each method on the type that needs to pass it along" and "When passed to a function or method, `context.Context` is always the first parameter" ([Google Style Guide — Decisions](https://google.github.io/styleguide/go/decisions#contexts)).

```go
// WRONG — ctx stored in a struct: obscures lifetime, freezes one scope for all calls
type Fetcher struct {
	ctx    context.Context // the request that built this Fetcher now binds every call
	client *http.Client
}
func (f *Fetcher) Get(url string) (*Response, error) { /* uses f.ctx */ }

// RIGHT — ctx is the first parameter of each call that needs it
type Fetcher struct {
	client *http.Client
}
func (f *Fetcher) Get(ctx context.Context, url string) (*Response, error) { /* uses ctx */ }
```

Storing `ctx` in a field "obscure[s] lifetime to the callers, or worse intermingle[s] two scopes together in unpredictable ways" — it "prevents the callers ... from specifying a deadline, requesting cancellation, and attaching metadata on a per-call basis" ([Contexts and structs](https://go.dev/blog/context-and-structs)). The only sanctioned exception is *retrofitting* an existing API for backwards compatibility, and even then "first consider duplicating your functions" ([Contexts and structs](https://go.dev/blog/context-and-structs)). The `containedctx` linter (`go-tooling-and-static-analysis`) finds a `context.Context` smuggled into a struct.

---

## 2. Where Contexts Come From: `Background` and `TODO`

You do not invent a context mid-call-chain — you thread the caller's. A *root* context is created only at an entry point: `context.Background()` "returns a non-nil, empty Context. It is never canceled, has no values, and has no deadline. It is typically used by the main function, initialization, and tests, and as the top-level Context for incoming requests" ([context docs](https://pkg.go.dev/context)). When you genuinely don't have one yet, use `context.TODO()` — "Code should use context.TODO when it's unclear which Context to use or it is not yet available" ([context docs](https://pkg.go.dev/context)). And never substitute `nil`: "Do not pass a nil Context, even if a function permits it. Pass context.TODO if you are unsure" ([context docs](https://pkg.go.dev/context)).

```go
// WRONG — minting a fresh root deep in a handler discards the request's deadline/cancellation
func (s *Server) handle(req *Request) error {
	return s.process(context.Background(), req) // the caller's ctx is now unreachable
}

// RIGHT — accept and thread the caller's ctx; Background() lives only at main/init/test entry
func (s *Server) handle(ctx context.Context, req *Request) error {
	return s.process(ctx, req)
}
```

`context.Background()` "should primarily be used in entrypoint functions (like `main` or `init`), not in library code mid-callchain" ([Google Style Guide — Decisions](https://google.github.io/styleguide/go/decisions#contexts)).

---

## 3. Derive a Child, and ALWAYS `defer cancel()`

To add cancellation or a deadline, derive a child with `WithCancel`, `WithTimeout`, or `WithDeadline`. Each returns a `cancel` function you are obligated to call — on *every* path, including the success path and the timeout variants. "Failing to call the CancelFunc leaks the child and its children until the parent is canceled" ([context docs](https://pkg.go.dev/context)). The idiom is `defer cancel()` on the line after the derivation:

```go
// WRONG — no cancel(): the timer/goroutine behind the deadline leaks until parent dies
ctx, _ := context.WithTimeout(parent, 2*time.Second)
return doWork(ctx)

// RIGHT — derive, defer cancel immediately, then use ctx
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
return doWork(ctx)
```

Calling `cancel` even after the work succeeds is correct and required — it releases the resources early and is a no-op if cancellation already fired. **`go vet` enforces this:** its `lostcancel` analyzer reports *"the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak"* when you drop it into `_` or let it go out of scope. The RIGHT snippet above passes `go vet` clean; the WRONG one does not. Wiring `go vet` into CI is owned by `go-tooling-and-static-analysis`.

---

## 4. Check `Done()` / `Err()` in Loops and Before Expensive Work

Holding a context does nothing unless you *observe* it. In a long loop or before a costly step, check for cancellation and return the context's error. `Done()` "returns a channel that acts as a cancellation signal to functions running on behalf of the Context: when the channel is closed, the functions should abandon their work and return" ([Go blog: Context](https://go.dev/blog/context)).

```go
// RIGHT — abandon work the moment the caller cancels or the deadline passes
func process(ctx context.Context, items []Item) error {
	for _, it := range items {
		select {
		case <-ctx.Done():
			return ctx.Err() // Canceled or DeadlineExceeded
		default:
		}
		if err := handle(ctx, it); err != nil { // pass ctx onward, too
			return err
		}
	}
	return nil
}
```

A `Context` "is safe for simultaneous use by multiple goroutines. Code can pass a single Context to any number of goroutines and cancel that Context to signal all of them" ([Go blog: Context](https://go.dev/blog/context)) — one cancel fans out to every worker watching `Done()`. The `select`-on-`Done()` *mechanics* (default cases, nil-channel tricks, combining channels) are owned by `go-channels-select`; *who stops the goroutine* is owned by `go-concurrency-goroutines`.

---

## 5. Attach a Reason: `WithCancelCause` / `Cause` and `WithDeadlineCause`

A bare cancel tells a worker *to* stop but not *why*. `WithCancelCause` (Go 1.20) "returns a CancelCauseFunc that takes an error as the cancellation cause", and `context.Cause(ctx)` "returns the non-nil error explaining why a context was canceled" ([context docs](https://pkg.go.dev/context)). `ctx.Err()` still returns the plain `Canceled`/`DeadlineExceeded` sentinel; `Cause` returns the richer reason.

```go
ctx, cancel := context.WithCancelCause(parent)
// ... somewhere a worker fails fatally:
cancel(fmt.Errorf("upstream feed closed: %w", err))
// ... another goroutine sees the reason:
<-ctx.Done()
log.Printf("stopping: %v", context.Cause(ctx)) // the rich cause, not just "context canceled"
```

For deadlines, `WithDeadlineCause` and `WithTimeoutCause` (Go 1.21) "provide a way to set a context cancellation cause when a deadline or timer expires. The cause may be retrieved with the Cause function" ([Go 1.21 release notes](https://go.dev/doc/go1.21)). Gate these on the module's `go` directive (`go-version-feature-map`).

---

## 6. Decouple with `WithoutCancel` and `AfterFunc` (Go 1.21)

Sometimes a child task must outlive the request that triggered it (a fire-and-forget cleanup or audit write). `WithoutCancel` (Go 1.21) "returns a copy of a context that is not canceled when the original context is canceled" ([Go 1.21 release notes](https://go.dev/doc/go1.21)) — it keeps the values but drops the deadline and cancellation. And `AfterFunc` "registers a function to run after a context has been cancelled" ([Go 1.21 release notes](https://go.dev/doc/go1.21)); it "calls f in its own goroutine after ctx is canceled" and returns a stop function to deregister it ([context docs](https://pkg.go.dev/context)). Reach for these deliberately — the default is that a child *does* honor its parent's cancellation.

---

## 7. Context Values: Request-Scoped Data Only, Behind an Unexported Key

`WithValue` is the most-abused part of the package. The rule: "Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions" ([context docs](https://pkg.go.dev/context)). Good values are request IDs, auth tokens, and trace spans — things that cross boundaries. A function's actual inputs are *parameters*, not smuggled context values. And the key must be an **unexported type**, not a string: "packages should define keys as an unexported type to avoid collisions" ([context docs](https://pkg.go.dev/context)).

```go
// WRONG — string key collides across packages; using ctx to pass a required arg
ctx = context.WithValue(ctx, "user", u)     // any other pkg's "user" key clobbers this
ctx = context.WithValue(ctx, "pageSize", 50) // pageSize is a parameter, not request scope

// RIGHT — unexported key type, request-scoped value, typed accessors
type userKey struct{}
func WithUser(ctx context.Context, u *User) context.Context {
	return context.WithValue(ctx, userKey{}, u)
}
func UserFrom(ctx context.Context) (*User, bool) {
	u, ok := ctx.Value(userKey{}).(*User)
	return u, ok
}
```

The unexported `userKey{}` type is unreachable from other packages, so two libraries can each stash a "user" without colliding. Expose typed `WithUser`/`UserFrom` helpers rather than letting callers touch raw keys.

---

## 8. `Canceled` and `DeadlineExceeded` Are Sentinels — Match With `errors.Is`

A cancelled context's `Err()` is one of two sentinel values: `Canceled` is "the error returned by Context.Err when the context is canceled for some reason other than its deadline passing", and `DeadlineExceeded` is "the error returned by Context.Err when the context is canceled due to its deadline passing" ([context docs](https://pkg.go.dev/context)). Because they are sentinels, you match them through a wrap chain with `errors.Is`, never by string:

```go
if err := callBackend(ctx); err != nil {
	if errors.Is(err, context.DeadlineExceeded) {
		return fmt.Errorf("backend timed out: %w", err)
	}
	if errors.Is(err, context.Canceled) {
		return err // caller went away; nothing to add
	}
	return fmt.Errorf("backend: %w", err)
}
```

The full `%w`-wrapping and `errors.Is`/`As` discipline is owned by `go-error-handling`; this skill only notes that these two values are sentinels and must be matched as such.

---

## 9. Who Suffers When Context Is Done Badly

The victim is never the author at write time:

- The **on-call engineer** paged at 3am: a `WithTimeout` whose `cancel` was dropped leaked a timer and a goroutine per request, and under load the process exhausted memory — exactly the backlog the blog warns of, where "your process could backlog and exhaust its resources (like memory)" ([Contexts and structs](https://go.dev/blog/context-and-structs)). `go vet`'s lostcancel would have caught it in CI.
- The **teammate** who can't add a per-call deadline because `ctx` was frozen into a struct field at construction time, so every call inherits one request's cancellation scope.
- The **next maintainer** who debugs a value that silently vanished because two packages used the string key `"user"` and one overwrote the other — the collision an unexported key type exists to prevent.

Per-call cancellation, an always-called `cancel`, and an unexported key are empathy for whoever runs this code in production.

---

## 10. Rules and Sources

| Rule | Source |
|---|---|
| `ctx` is the first parameter, named `ctx`; never a struct field | "Do not store Contexts inside a struct type ... The Context should be the first parameter, typically named ctx" ([context docs](https://pkg.go.dev/context)); "Do not add a context member to a struct type" ([Google](https://google.github.io/styleguide/go/decisions#contexts)) |
| Root only at entry points; thread the caller's ctx; never pass `nil` | "typically used by the main function, initialization, and tests"; "Do not pass a nil Context ... Pass context.TODO if you are unsure" ([context docs](https://pkg.go.dev/context)) |
| Derive a child and ALWAYS `defer cancel()` | "Failing to call the CancelFunc leaks the child and its children until the parent is canceled" ([context docs](https://pkg.go.dev/context)) |
| Check `Done()`/`Err()` in loops and before costly work | "when the channel is closed, the functions should abandon their work and return" ([Go blog](https://go.dev/blog/context)) |
| Attach a reason with `WithCancelCause`/`Cause` (1.20), `WithDeadlineCause` (1.21) | "returns the non-nil error explaining why a context was canceled" ([context docs](https://pkg.go.dev/context)); ([Go 1.21](https://go.dev/doc/go1.21)) |
| `WithoutCancel` / `AfterFunc` (1.21) to decouple deliberately | "a copy of a context that is not canceled when the original is canceled"; "registers a function to run after a context has been cancelled" ([Go 1.21](https://go.dev/doc/go1.21)) |
| Values for request-scoped data only, behind an unexported key | "only for request-scoped data that transits processes and APIs, not for passing optional parameters"; "define keys as an unexported type to avoid collisions" ([context docs](https://pkg.go.dev/context)) |
| `Canceled`/`DeadlineExceeded` are sentinels — match with `errors.Is` | "the error returned by Context.Err when the context is canceled" ([context docs](https://pkg.go.dev/context)) |

---

## 11. Routing to Related Skills

- `go-idiomatic-discipline` — the policy root; this skill is the cancellation-and-propagation depth behind "never start a goroutine you can't stop."
- `go-concurrency-goroutines` — *who* stops the goroutine; a `ctx` is how you stop it, but goroutine ownership/lifetime lives there.
- `go-channels-select` — the `select`-on-`Done()` mechanics: default cases, nil-channel disabling, combining a `Done()` channel with others.
- `go-error-handling` — `context.Canceled`/`DeadlineExceeded` as sentinels; `%w` wrapping and `errors.Is`/`As` depth.
- `go-tooling-and-static-analysis` — `go vet`'s `lostcancel` (missing `cancel()`) and the `containedctx` linter (ctx in a struct) in CI.
- `go-version-feature-map` — which of `WithCancelCause` (1.20), `WithoutCancel`/`AfterFunc`/`WithDeadlineCause` (1.21) the module's `go` directive permits.

---

## 12. Reference Files

High-frequency context anti-patterns in LLM-generated Go, each with wrong/right code and citations:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

