Cancellation with context

When a user closes their browser tab, the server should stop the database query it started on their behalf. When a request has a two-second budget, every call it makes should respect that deadline. The context package carries this kind of signal — cancellation, deadlines and a few request-scoped values — through your program. This lesson covers creating contexts, cancelling and timing out work, writing functions that respect a context, and the conventions around them.

What a context is

A context.Context is passed down through function calls. Its Done() method returns a channel that's closed when the work should stop, and Err() says why. Contexts form a tree: cancelling a parent cancels all its children.

FunctionCreates a context that…
context.Background()Is never cancelled: the root, used in main and tests
context.WithCancel(parent)Is cancelled when you call the returned cancel function
context.WithTimeout(parent, d)Is cancelled automatically after duration d
context.WithDeadline(parent, t)Is cancelled automatically at time t
context.WithValue(parent, key, val)Carries a request-scoped value

Respecting cancellation

A function that does slow or repeated work should watch ctx.Done() and stop when it closes, returning ctx.Err():

GO
package main

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

func processItems(ctx context.Context, n int) (int, error) {
	done := 0
	for range n {
		select {
		case <-ctx.Done():
			return done, ctx.Err() // stop early
		case <-time.After(50 * time.Millisecond): // one item's worth of work
			done++
		}
	}
	return done, nil
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		time.Sleep(125 * time.Millisecond)
		cancel() // e.g. the user pressed "stop"
	}()

	n, err := processItems(ctx, 10)
	fmt.Println("processed", n, "items, error:", err)
}
Output
processed 2 items, error: context canceled

Timeouts

WithTimeout cancels the context automatically. Always call the returned cancel function when you're done (usually with defer) — it releases the timer even if the work finishes early:

GO
package main

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

func slowQuery(ctx context.Context, d time.Duration) (string, error) {
	select {
	case <-time.After(d):
		return "42 rows", nil
	case <-ctx.Done():
		return "", fmt.Errorf("query: %w", ctx.Err())
	}
}

func main() {
	for _, d := range []time.Duration{20 * time.Millisecond, 300 * time.Millisecond} {
		ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
		res, err := slowQuery(ctx, d)
		cancel()

		switch {
		case errors.Is(err, context.DeadlineExceeded):
			fmt.Println("too slow:", err)
		case err != nil:
			fmt.Println("failed:", err)
		default:
			fmt.Println("result:", res)
		}
	}
}
Output
result: 42 rows
too slow: query: context deadline exceeded

ctx.Err() is context.Canceled after a manual cancel and context.DeadlineExceeded after a timeout, so callers can tell the two apart with errors.Is. go vet warns if a cancel function is never called.

Contexts in the standard library

Most standard library operations that can block accept a context: http.NewRequestWithContext, db.QueryContext, net.Dialer.DialContext, exec.CommandContext. In an HTTP server, each request has a context — r.Context() — that's cancelled when the client disconnects. Pass it on, and abandoned requests stop using resources automatically.

Cancelling a whole group of goroutines

Because cancelling a parent cancels every child, one cancel call can stop many goroutines. Here the first worker to find the target cancels the rest:

GO
package main

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

func search(ctx context.Context, from, to, target int, found func(int)) {
	for i := from; i < to; i++ {
		select {
		case <-ctx.Done():
			return
		default:
		}
		if i == target {
			found(i)
			return
		}
		time.Sleep(time.Millisecond)
	}
}

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

	var stopped atomic.Int32
	var wg sync.WaitGroup
	for w := range 4 {
		wg.Go(func() {
			search(ctx, w*1000, (w+1)*1000, 2050, func(n int) {
				fmt.Println("worker", w, "found", n)
				cancel() // stop all the others
			})
			stopped.Add(1)
		})
	}
	wg.Wait()
	fmt.Println(stopped.Load(), "workers stopped; reason:", ctx.Err())
}
Output
worker 2 found 2050
4 workers stopped; reason: context canceled

Cancellation causes

WithCancelCause and WithTimeoutCause let you record why a context was cancelled; context.Cause(ctx) retrieves it:

GO
package main

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

var ErrQuotaExceeded = errors.New("daily quota exceeded")

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

	fmt.Println(ctx.Err())
	fmt.Println(context.Cause(ctx))
}
Output
context canceled
daily quota exceeded

Values: use sparingly

WithValue attaches a value — a request ID, the authenticated user — for code further down to read. Use your own unexported key type so packages can't collide, and only use it for request-scoped data that passes through APIs, never for ordinary function parameters:

GO
package main

import (
	"context"
	"fmt"
)

type ctxKey int

const requestIDKey ctxKey = 0

func handle(ctx context.Context) {
	logLine(ctx, "loading cart")
}

func logLine(ctx context.Context, msg string) {
	id, _ := ctx.Value(requestIDKey).(string)
	fmt.Printf("[req %s] %s\n", id, msg)
}

func main() {
	ctx := context.WithValue(context.Background(), requestIDKey, "a1b2c3")
	handle(ctx)
}
Output
[req a1b2c3] loading cart

Conventions

  • Pass the context as the first parameter, named ctx: func Fetch(ctx context.Context, url string).
  • Don't store a context in a struct; pass it to each call that needs it.
  • Never pass a nil context. If you're unsure which to use, use context.TODO() and come back to it.
  • Call cancel functions, typically with defer cancel() right after creating the context.

Try it yourself

Download three "files" with a total time budget of 150 ms. Which ones finish? Change the budget and see.

Try it yourself · main.go
Edit the code, copy it into main.go in a new folder and run it with go run main.go (Go 1.27 or later). How to install Go
Output
downloaded a.txt
downloaded b.txt
failed: c.txt: context deadline exceeded
Next: working with files and directories.