Dev Skills

Context

Cancelling work that nobody is waiting for any more.

Topic 42 of 98 · 10 min to read · 45 min to build
After this topic you can
  • Write a function that takes ctx first and returns ctx.Err() when it is cancelled.
  • Explain why cancelling a context does nothing unless your code checks ctx.Done().
  • Predict whether a child context made with context.WithoutCancel stops when its parent is cancelled.

What is context used for in Go?

context.Context is how a Go program says "stop, nobody needs this any more". In a typical service it is the first parameter of every function that does I/O: database queries, HTTP calls, RPCs.

context.go
func fetch(ctx context.Context, url string) error {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel() // ALWAYS, even on success — or you leak the timer
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req) // aborts when ctx is done
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
func work(ctx context.Context) error {
select {
case <-time.After(5 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err() // context.Canceled or context.DeadlineExceeded
}
}
Contexts form a tree. `WithTimeout(ctx, ...)` makes a child of the caller's context: cancelling the parent cancels every child, but cancelling a child never touches the parent. A child deadline can only be shorter than its parent's, never longer.

What does context.WithoutCancel do?

It gives you a context that keeps the parent’s values but is never cancelled. Sometimes work must outlive the request that started it, such as writing an audit log after the response is sent. If you pass r.Context(), the log write is cancelled the moment the client disconnects. context.WithoutCancel(ctx) (Go 1.21) gives you a child that keeps every value (request ID, user ID) but is never cancelled and has no deadline.

press Run — this executes on the Go Playground
package main
import (
"context"
"fmt"
)
type reqIDKey struct{}
func main() {
req, cancel := context.WithCancel(context.WithValue(context.Background(), reqIDKey{}, "req-7"))
bg := context.WithoutCancel(req) // same values, no cancellation
cancel() // the request is over
fmt.Println("request ctx:", req.Err())
fmt.Println("background ctx:", bg.Err())
fmt.Println("still has request id:", bg.Value(reqIDKey{}))
}
Give the background work its own timeout with `context.WithTimeout(bg, ...)`, or nothing will ever stop it.

What does context.AfterFunc do?

context.AfterFunc(ctx, f) (Go 1.21) runs f in its own goroutine once ctx is done. It returns a stop function: calling stop() before then cancels the plan, and it returns true if it stopped f from running. Use it to clean up, such as closing a connection, without writing a goroutine that waits on ctx.Done().

press Run — this executes on the Go Playground
package main
import (
"context"
"fmt"
"sync"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
context.AfterFunc(ctx, func() {
defer wg.Done()
fmt.Println("ctx done, closing connection")
})
ctx2, cancel2 := context.WithCancel(context.Background())
stop := context.AfterFunc(ctx2, func() { fmt.Println("never printed") })
fmt.Println("stopped before it ran:", stop())
cancel2()
cancel()
wg.Wait() // f runs in its own goroutine, so wait for it
}
`stop()` returns false if `f` has already started, so you know whether the cleanup happened.

The context rules

  • First parameter, always, named `ctx`.
  • Never store a Context in a struct. It belongs to one call, not to an object.
  • Always call `cancel`. defer cancel() on the line after you create it.
  • Cancelling does not stop anything by itself. It closes ctx.Done(), and your code has to check that. Anything that blocks should select on it.
  • context.Value is for request-scoped data crossing API boundaries, such as a request ID or a user ID. Not for dependencies or config. If the function needs it to work, it is a parameter.

Try it yourself

press Run — this executes on the Go Playground
package main
import (
"context"
"fmt"
"time"
)
type ctxKey struct{} // unexported, so nobody else can collide with it
func work(ctx context.Context, name string, d time.Duration) {
select {
case <-time.After(d):
fmt.Println(name, "finished")
case <-ctx.Done():
fmt.Println(name, "stopped:", ctx.Err()) // you must check this
}
}
func main() {
// 1. a deadline
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
defer cancel() // always, even on the success path
work(ctx, "slow job", 200*time.Millisecond)
// 2. cancelling by hand
ctx2, cancel2 := context.WithCancel(context.Background())
go func() {
time.Sleep(20 * time.Millisecond)
cancel2()
}()
work(ctx2, "cancelled job", time.Second)
// 3. a request-scoped value
ctx3 := context.WithValue(context.Background(), ctxKey{}, int64(42))
if id, ok := ctx3.Value(ctxKey{}).(int64); ok {
fmt.Println("user id from context:", id)
}
}
A deadline that fires, a cancel that stops work early, and a value carried through.
The short version
  • First parameter, always, named ctx. Never stored in a struct.
  • Always defer cancel(), even on the success path.
  • Cancelling does not stop anything by itself. Your code must select on ctx.Done().
  • context.Value is for request-scoped identity, not for dependencies or config.
Remember: In a handler, derive from r.Context() so a disconnecting client stops your database query too.
Build this before moving on
Give a slow function a one-second timeout and make it return context.DeadlineExceeded.
Coding challenge · checked by tests
Write SleepCtx(ctx, d): wait for d, or stop early if ctx is done. Return nil if the time passed, or ctx.Err() if the context ended first. The hidden tests time you: a cancelled context must make a 2-second sleep return almost at once. Example: with a context that times out after 5ms, SleepCtx(ctx, 2*time.Second) returns context.DeadlineExceeded after about 5ms.

Tab indents. Press Esc, then Tab, to leave the editor.

Then try these in your own editor
  1. 1Forget defer cancel() and see whether go vet catches it.
  2. 2Store a request id in a context with an unexported key type, and read it back.
  3. 3Write a loop that ignores ctx.Done() and prove cancellation does nothing.
Check yourselfTake the Concurrency test