Context

A context.Context carries a cancellation signal and an optional deadline through your program, so that work running in many functions and goroutines can be stopped together. In this lesson you’ll cancel goroutines with WithCancel, put time limits on work with WithTimeout and WithDeadline, check ctx.Done() and ctx.Err(), and learn the conventions every Go API follows.

It builds directly on the done-channel pattern from Select, so read that first if for { select { … } } looks unfamiliar.

Why context exists

Picture a web server handling a request. The handler queries a database, calls two other services in goroutines, and renders a page. Now the user closes the browser tab. All that work is wasted, and it would be nice to stop it. Or one of the services hangs, and you want the whole request to give up after two seconds.

You could pass a done channel to every function, plus a deadline, plus some way to cancel only part of the work. The context package bundles all of that into one value with a standard interface, so every library speaks the same language. Database drivers, HTTP clients and servers all accept a context. When it’s cancelled, they stop.

The parts you’ll use most:

  • ctx.Done() returns a channel that’s closed when the context is cancelled or its deadline passes.
  • ctx.Err() returns nil while the context is active, and afterwards says why it ended: context.Canceled or context.DeadlineExceeded.
  • Functions like context.WithCancel and context.WithTimeout create a new child context from a parent.

context.Background: where it starts

Every context tree has a root. context.Background() returns an empty context that is never cancelled and has no deadline. You create it at the top of your program, in main, or in a test, and derive everything else from it. Its sibling context.TODO() is identical, but signals “I haven’t figured out which context belongs here yet” to anyone reading the code.

Inside an HTTP server you don’t create the root yourself: every request already comes with a context that’s cancelled when the client goes away. You’ll use it in An HTTP server.

WithCancel: stop goroutines on demand

context.WithCancel(parent) returns a new context and a cancel function. Calling cancel closes the context’s Done channel. Here’s the number generator from the select lesson, now stopped with a context instead of a hand-made done channel:

package main

import (
	"context"
	"fmt"
)

func count(ctx context.Context) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for i := 1; ; i++ {
			select {
			case out <- i:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

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

	for n := range count(ctx) {
		fmt.Println(n)
		if n == 3 {
			break
		}
	}

	cancel()
	fmt.Println("err:", ctx.Err())
}Code language: Go (go)
1
2
3
err: context canceledCode language: plaintext (plaintext)

After cancel(), the generator’s select sees ctx.Done() closed and returns, and ctx.Err() reports context canceled. Calling cancel more than once is harmless, which is why I can both call it explicitly and defer it. The defer cancel() right after creating the context is a habit you should always follow: it releases the context’s resources no matter how the function returns.

WithTimeout and WithDeadline: time limits

context.WithTimeout(parent, d) is a context that cancels itself after d. A function that respects contexts waits for its work or ctx.Done(), whichever comes first, and returns ctx.Err() if the context won:

package main

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

// slowQuery pretends to be a database query that takes 100ms.
func slowQuery(ctx context.Context) (string, error) {
	select {
	case <-time.After(100 * time.Millisecond):
		return "42 rows", nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

func main() {
	for _, limit := range []time.Duration{time.Second, 20 * time.Millisecond} {
		ctx, cancel := context.WithTimeout(context.Background(), limit)
		result, err := slowQuery(ctx)
		cancel()

		switch {
		case errors.Is(err, context.DeadlineExceeded):
			fmt.Println("limit", limit, "- gave up:", err)
		case err != nil:
			fmt.Println("limit", limit, "- failed:", err)
		default:
			fmt.Println("limit", limit, "- got", result)
		}
	}
}Code language: Go (go)
limit 1s - got 42 rows
limit 20ms - gave up: context deadline exceededCode language: plaintext (plaintext)

Check the error with errors.Is, not ==, because functions often wrap the context’s error with more information. Note that I call cancel() as soon as I’m done with each context, even though the timeout would eventually fire on its own. Inside a loop, defer would wait until main returns, so an explicit call is cleaner.

context.WithDeadline is the same thing with an absolute time instead of a duration. WithTimeout(ctx, d) is just shorthand for WithDeadline(ctx, time.Now().Add(d)). Any function can ask a context for its deadline:

package main

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

func main() {
	deadline := time.Now().Add(2 * time.Second)
	ctx, cancel := context.WithDeadline(context.Background(), deadline)
	defer cancel()

	if d, ok := ctx.Deadline(); ok {
		fmt.Println("time left:", time.Until(d).Round(time.Second))
	}

	_, ok := context.Background().Deadline()
	fmt.Println("background has a deadline:", ok)
}Code language: Go (go)
time left: 2s
background has a deadline: falseCode language: plaintext (plaintext)

That’s useful when a function wants to decide whether there’s enough time left to even try something expensive.

Always call cancel

Every With… function that returns a cancel expects you to call it. Until you do (or the parent is cancelled, or the deadline passes), the context stays registered with its parent. Throwing cancel away is such a common mistake that go vet checks for it:

package main

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

func main() {
	ctx, _ := context.WithTimeout(context.Background(), time.Second)
	fmt.Println(ctx.Err())
}Code language: Go (go)
main.go:10:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leakCode language: plaintext (plaintext)

Cancellation flows down the tree

Contexts form a tree. When a parent is cancelled, all of its children are cancelled too, but cancelling a child never affects its parent. That’s what lets a request-wide context stop everything, while one sub-task can still have its own shorter timeout.

package main

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

func main() {
	parent, cancelParent := context.WithCancel(context.Background())
	defer cancelParent()

	child, cancelChild := context.WithTimeout(parent, time.Hour)
	defer cancelChild()

	other, cancelOther := context.WithCancel(parent)
	cancelOther()

	fmt.Println("after cancelling other:")
	fmt.Println("  parent:", parent.Err())
	fmt.Println("  other: ", other.Err())

	cancelParent()
	<-child.Done()

	fmt.Println("after cancelling parent:")
	fmt.Println("  parent:", parent.Err())
	fmt.Println("  child: ", child.Err())
}Code language: Go (go)
after cancelling other:
  parent: <nil>
  other:  context canceled
after cancelling parent:
  parent: context canceled
  child:  context canceledCode language: plaintext (plaintext)

Cancelling other left parent alone. Cancelling parent immediately ended child, even though its own one-hour timeout was far away.

Conventions: ctx goes first

The Go community follows a few rules for contexts, and the standard library sticks to them. Following them makes your code fit in with everything else:

  • Pass the context as the first parameter, named ctx: func Fetch(ctx context.Context, url string) ([]byte, error).
  • Pass it explicitly to every function that needs it. Don’t store a context in a struct field; a context belongs to one operation, not to a long-lived object.
  • Never pass nil. If you don’t have a context, use context.TODO().
  • Accept a context, don’t create a root. Library functions take a ctx from their caller rather than calling context.Background() themselves, so the caller stays in control of cancellation.

You’ll see this pattern all over the standard library, for example in http.NewRequestWithContext and exec.CommandContext.

Values: only for request-scoped data

A context can also carry values. context.WithValue(parent, key, value) returns a child that holds one key-value pair, and ctx.Value(key) looks it up, walking up the tree if needed. Use an unexported key type so your keys can’t collide with another package’s:

package main

import (
	"context"
	"fmt"
)

type requestIDKey struct{}

func withRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, id)
}

func requestID(ctx context.Context) string {
	id, ok := ctx.Value(requestIDKey{}).(string)
	if !ok {
		return "unknown"
	}
	return id
}

func handle(ctx context.Context, user string) {
	fmt.Printf("[%s] loading profile for %s\n", requestID(ctx), user)
}

func main() {
	ctx := withRequestID(context.Background(), "req-7f3a")
	handle(ctx, "erik")
	handle(context.Background(), "guest")
}Code language: Go (go)
[req-7f3a] loading profile for erik
[unknown] loading profile for guestCode language: plaintext (plaintext)

Value returns any, so I use a comma-ok type assertion and fall back to "unknown". Small helper functions like withRequestID and requestID keep the key private to one package.

Context values are for data that belongs to the request and crosses API boundaries: a request ID, a trace ID, the authenticated user. They are not a way to pass optional parameters or dependencies like a database handle or a logger configuration. Those belong in regular function parameters or struct fields, where the compiler can check them.

Cancelling a group of goroutines cleanly

Putting it together: a pool of workers that run until a time limit, then all stop, and main waits until every one of them has really returned before it moves on. This is the shape of most background processing in Go.

package main

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

func worker(ctx context.Context, id int) string {
	ticker := time.NewTicker(10 * time.Millisecond)
	defer ticker.Stop()

	jobs := 0
	for {
		select {
		case <-ticker.C:
			jobs++ // pretend to process one job
		case <-ctx.Done():
			return fmt.Sprintf("worker %d stopped (%v), did some jobs: %t", id, ctx.Err(), jobs > 0)
		}
	}
}

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

	reports := make([]string, 3)
	var wg sync.WaitGroup
	for i := range reports {
		wg.Go(func() {
			reports[i] = worker(ctx, i)
		})
	}
	wg.Wait()

	for _, r := range reports {
		fmt.Println(r)
	}
	fmt.Println("all workers stopped")
}Code language: Go (go)
worker 0 stopped (context deadline exceeded), did some jobs: true
worker 1 stopped (context deadline exceeded), did some jobs: true
worker 2 stopped (context deadline exceeded), did some jobs: true
all workers stoppedCode language: plaintext (plaintext)

The pieces each have one job. The context says when to stop. Each worker checks ctx.Done() in its select, so it notices quickly. The WaitGroup makes sure main doesn’t continue (or exit) while a worker is still cleaning up. Swap WithTimeout for WithCancel and call cancel() from anywhere, say when the user presses Ctrl+C, and the same code shuts down on demand.

One thing a context can’t do is interrupt code that never looks at it. A long calculation in a tight loop keeps running after cancellation unless it checks ctx.Err() now and then. Cancellation in Go is cooperative.

Practice

1. A countdown with a time limit. Write countdown(ctx context.Context, from int) error that prints from, from-1, … down to 1, waiting 40ms before each number, and returns nil when it reaches the end. If the context is done first, it stops and returns ctx.Err(). Call it with from = 5 and a 100ms timeout, and print the returned error.

Show a solution
package main

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

func countdown(ctx context.Context, from int) error {
	for i := from; i > 0; i-- {
		select {
		case <-time.After(40 * time.Millisecond):
			fmt.Println(i)
		case <-ctx.Done():
			return ctx.Err()
		}
	}
	return nil
}

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

	err := countdown(ctx, 5)
	fmt.Println("countdown ended:", err)
}Code language: Go (go)
5
4
countdown ended: context deadline exceededCode language: plaintext (plaintext)

2. First answer wins, the rest stop. Start three goroutines named "fast", "medium" and "slow" that pretend to work for 20ms, 60ms and 100ms. Each one either sends its name on a buffered results channel or, if the shared context is cancelled first, records that it was cancelled. In main, take the first result, call cancel(), wait for all goroutines, and print the winner and what happened to each goroutine.

Show a solution
package main

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

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

	names := []string{"fast", "medium", "slow"}
	delays := []time.Duration{20 * time.Millisecond, 60 * time.Millisecond, 100 * time.Millisecond}
	status := make([]string, len(names))
	results := make(chan string, len(names))

	var wg sync.WaitGroup
	for i, name := range names {
		wg.Go(func() {
			select {
			case <-time.After(delays[i]):
				status[i] = "finished"
				results <- name
			case <-ctx.Done():
				status[i] = "cancelled"
			}
		})
	}

	winner := <-results
	cancel()
	wg.Wait()

	fmt.Println("winner:", winner)
	for i, name := range names {
		fmt.Println(name, status[i])
	}
}Code language: Go (go)
winner: fast
fast finished
medium cancelled
slow cancelledCode language: plaintext (plaintext)

Key takeaways

  • A context carries cancellation and deadlines across functions and goroutines. Start from context.Background() and derive children.
  • WithCancel, WithTimeout and WithDeadline return a cancel function. Always call it, usually with defer cancel().
  • Watch ctx.Done() in a select, and return ctx.Err() (context.Canceled or context.DeadlineExceeded) when it fires. Check it with errors.Is.
  • Cancelling a parent cancels all its children, never the other way around.
  • ctx context.Context is the first parameter; don’t store it in structs and never pass nil.
  • Use context values only for request-scoped data like request IDs, with an unexported key type.