A Go context tells every goroutine working on a job when to stop. Learn cancellation, timeouts and request values, then build a worker pool, a pipeline, fan-out, an errgroup and a semaphore that never leak.
Starting a goroutine is easy. Stopping it at the right time is the hard part. A user closes the browser tab, a database call takes too long, or one of five parallel requests fails. In each case some goroutines should give up, and something has to tell them.
In Go, that something is context.Context. This post covers cancellation, timeouts and request-scoped values first, then five concurrency patterns built on them. It assumes you’ve met goroutines, channels, select and sync.WaitGroup in the three parts before this one. Every program below was run on Go 1.26, and its output is pasted from the run.
A context is a signal that says stop
A context is a value you pass to a function so the function can find out when to stop working. The smallest useful one comes from context.WithCancel:
package main
import (
"context"
"fmt"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
fmt.Println("before:", ctx.Err())
cancel()
<-ctx.Done() // closed now, so this returns at once
fmt.Println("after:", ctx.Err())
cancel() // calling it again does nothing
fmt.Println("again:", ctx.Err())
}
It prints:
before: <nil>
after: context canceled
again: context canceled
context.Background() is the empty root context. It never ends and carries nothing. WithCancel wraps it and hands back two things: a new context, and a cancel function that ends it.
Two methods tell you whether a context has ended. ctx.Done() returns a channel that gets closed when the context ends. ctx.Err() returns nil while it’s still live, and the reason once it isn’t. Calling cancel twice is safe. The second call does nothing.
Explain it like I’m ten
Picture a manager starting a big job. She hands a walkie-talkie to each worker on the job. Some workers hire helpers, and they hand their helpers walkie-talkies on the same channel.
When the manager says “stop” into her walkie-talkie, everyone holding one from that chain hears it. The workers put their tools down.
A timeout is an alarm clock taped to a walkie-talkie. When it rings, it says “stop” for you, even if nobody pressed the button.
The precise version
Contexts form a tree. Every With… function takes a parent and returns a child. When a context is cancelled, Go closes its Done channel and then cancels all of its children, and their children, all the way down.
Cancellation only travels down. Cancelling a child never touches its parent, and never touches its siblings. The cancel function you get back also releases the child’s link to its parent, which is why you always call it, usually with defer cancel().
Where the analogy breaks: a worker with a walkie-talkie can’t help hearing it. A goroutine can. Closing Done doesn’t stop anything by itself. Your code must check ctx.Done() or ctx.Err() and return. A goroutine that never looks just keeps running.
Timeouts and deadlines
A timeout is the most common reason a context ends, and context.WithTimeout sets one up in a single call. The function below respects cancellation. It waits for its work or for ctx.Done(), whichever comes first:
package main
import (
"context"
"errors"
"fmt"
"time"
)
// work pretends to do a job that takes d, but gives up if ctx ends first.
func work(ctx context.Context, d time.Duration) error {
select {
case <-time.After(d):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
err := work(ctx, 2*time.Second)
fmt.Println("slow job:", err)
fmt.Println(errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled))
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel2()
fmt.Println("quick job:", work(ctx2, time.Millisecond))
past := time.Now().Add(-time.Minute)
ctx3, cancel3 := context.WithDeadline(context.Background(), past)
defer cancel3()
fmt.Println("deadline already gone:", work(ctx3, time.Millisecond))
}
It prints:
slow job: context deadline exceeded
true false
quick job: <nil>
deadline already gone: context deadline exceeded
The first job needs 2 seconds and gets 20 milliseconds, so the context wins. ctx.Err() returns context.DeadlineExceeded. The margins are wide on purpose. A 100-fold gap means the outcome never depends on how busy the machine is.
Compare errors with errors.Is, as the part on errors showed. context.Canceled means someone called cancel. context.DeadlineExceeded means the clock ran out. Code that retries often treats them differently: a timeout may be worth another try, but a cancellation means the caller has already left.
WithDeadline takes a point in time instead of a duration. WithTimeout(ctx, d) is exactly WithDeadline(ctx, time.Now().Add(d)). A deadline that has already passed gives you a context that is over before your function starts.
time.After in a select used to be a small memory trap, because the timer stayed alive until it fired. Since Go 1.23, a timer that nothing refers to can be garbage collected, so this pattern is fine now.
Cancellation flows down the tree
The tree matters most when several goroutines share a job. In this program, root has two children. A has a 20ms timeout and two workers under it, A1 and A2. B is a separate child with its own worker. Watch what the timeout on A reaches, and what it doesn’t:
A context tree with a timeout on one branch. When A’s 20ms timer fires, A ends and the signal travels down to its workers A1 and A2, which stop with DeadlineExceeded. B, a sibling, and root, the parent, keep running. Only when main cancels root does the signal reach B.
Here are those steps in words, in case the animation doesn’t play for you:
root,A,A1,A2andBare all live. Each worker sits in aselectloop, doing small units of work.A‘s 20ms timer fires.Aends, andA.Err()returnscontext deadline exceeded.- The signal travels down.
A1andA2see theirDonechannels close, and both stop with the same error. Bandrootare untouched. Cancellation went down fromA, not up torootand not sideways toB.maincallscancelRoot(). Now the signal travels down fromroottoB, which stops withcontext canceled.
Here is the program the animation follows:
package main
import (
"context"
"fmt"
"time"
)
// worker does small units of work until its context ends.
func worker(ctx context.Context, name string, report chan<- string) {
tick := time.NewTicker(time.Millisecond)
defer tick.Stop()
for {
select {
case <-ctx.Done():
report <- name + " stopped: " + ctx.Err().Error()
return
case <-tick.C:
// one small unit of work
}
}
}
func main() {
root, cancelRoot := context.WithCancel(context.Background())
defer cancelRoot()
a, cancelA := context.WithTimeout(root, 20*time.Millisecond)
defer cancelA()
a1, cancelA1 := context.WithCancel(a)
defer cancelA1()
a2, cancelA2 := context.WithCancel(a)
defer cancelA2()
b, cancelB := context.WithCancel(root)
defer cancelB()
repA1 := make(chan string)
repA2 := make(chan string)
repB := make(chan string)
go worker(a1, "A1", repA1)
go worker(a2, "A2", repA2)
go worker(b, "B", repB)
fmt.Println(<-repA1)
fmt.Println(<-repA2)
fmt.Println("A:", a.Err())
fmt.Println("B:", b.Err())
fmt.Println("root:", root.Err())
cancelRoot()
fmt.Println(<-repB)
}
It prints:
A1 stopped: context deadline exceeded
A2 stopped: context deadline exceeded
A: context deadline exceeded
B: <nil>
root: <nil>
B stopped: context canceled
Look at the order of events in main. It waits for both A workers to report, then checks B and root. Both are still nil, even though a whole branch of the tree has ended beside them. Only cancelRoot() reaches B.
Each worker is the shape to copy. It’s a for loop around a select, with one case for work and one case for ctx.Done(). As long as every blocking step sits inside a select like this, the goroutine can always hear “stop”.
Where ctx goes: first parameter, never a struct field
The standard library and almost all Go code agree on one convention. A function that can be cancelled takes a context.Context as its first parameter, named ctx:
func (s *Store) LoadUser(ctx context.Context, id int) (User, error)
Don’t store a context in a struct. A context belongs to one call, such as one HTTP request, but a struct usually outlives many calls. A stored context ends up being the wrong one: cancelled too early for the next request, or never cancelled at all. Passing it explicitly also makes it obvious which functions can be stopped.
Two more rules. Never pass a nil context. If you don’t have one yet, use context.TODO(), which behaves like Background() but marks the spot for later. And always call the cancel function. go vet checks that second rule for you. Given ctx, _ := context.WithTimeout(context.Background(), time.Second), it reports:
$ go vet .
main.go:10:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak
Why it stopped: Cause and AfterFunc
ctx.Err() only ever says “canceled” or “deadline exceeded”, which is often too little to debug with. Go 1.20 added WithCancelCause, and Go 1.21 added WithTimeoutCause and AfterFunc:
package main
import (
"context"
"errors"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
cleaned := make(chan struct{})
context.AfterFunc(ctx, func() {
close(cleaned) // runs in its own goroutine once ctx ends
})
cancel(errors.New("server shutting down"))
<-cleaned
fmt.Println("cleanup ran")
fmt.Println("Err: ", ctx.Err())
fmt.Println("Cause:", context.Cause(ctx))
slow := errors.New("payment provider took too long")
ctx2, cancel2 := context.WithTimeoutCause(context.Background(), 20*time.Millisecond, slow)
defer cancel2()
<-ctx2.Done()
fmt.Println("Err: ", ctx2.Err())
fmt.Println("Cause:", context.Cause(ctx2))
}
It prints:
cleanup ran
Err: context canceled
Cause: server shutting down
Err: context deadline exceeded
Cause: payment provider took too long
cancel from WithCancelCause takes an error. ctx.Err() still returns context.Canceled, so existing checks keep working, and context.Cause(ctx) returns your error. WithTimeoutCause does the same for a timeout. In a log line, “payment provider took too long” is much more useful than “context deadline exceeded”.
context.AfterFunc registers a function to run once the context ends. It runs in its own goroutine, which is why main waits on the cleaned channel before printing. Printing from inside the callback would race with main‘s own output. AfterFunc returns a stop function that unregisters the callback if it hasn’t run yet.
Request-scoped values
A context can also carry values, and context.WithValue is how data like a request ID travels through a call chain without being added to every function signature:
package main
import (
"context"
"fmt"
)
// An unexported key type: no other package can make a key equal to this one.
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 "no-request-id"
}
return id
}
func loadUser(ctx context.Context, userID int) {
fmt.Printf("[%s] loading user %d\n", requestID(ctx), userID)
}
func main() {
ctx := withRequestID(context.Background(), "req-7f3a")
loadUser(ctx, 42)
loadUser(context.Background(), 43)
}
It prints:
[req-7f3a] loading user 42
[no-request-id] loading user 43
The key is a private, empty struct type. Two packages that both use the string "id" as a key would overwrite each other. A key of an unexported type can’t collide with anyone else’s key. ctx.Value returns any, so you check the type with comma-ok and handle the missing case.
Use values only for request-scoped data. That means things that describe the request and cross API boundaries: a request ID, a trace ID, the authenticated user. Don’t use them for a database handle, a logger or a config flag. Those are real dependencies, and they belong in parameters or struct fields where the compiler can see them. Value is untyped, and it’s found by walking up the tree one parent at a time, so hidden dependencies there fail at run time rather than at build time.
Worker pool
A worker pool runs a fixed number of goroutines that all take jobs from one channel. It caps how much work happens at once, however many jobs arrive:
package main
import (
"cmp"
"fmt"
"slices"
"sync"
)
type result struct {
job, square int
}
func worker(jobs <-chan int, results chan<- result) {
for j := range jobs {
results <- result{job: j, square: j * j}
}
}
func main() {
jobs := make(chan int)
results := make(chan result)
var wg sync.WaitGroup
for range 3 {
wg.Go(func() { worker(jobs, results) })
}
go func() {
for j := range 9 {
jobs <- j + 1
}
close(jobs) // workers' range loops end
}()
go func() {
wg.Wait()
close(results) // main's range loop ends
}()
var all []result
for r := range results {
all = append(all, r)
}
slices.SortFunc(all, func(x, y result) int { return cmp.Compare(x.job, y.job) })
fmt.Println(len(all), "results")
fmt.Println(all)
}
It prints:
9 results
[{1 1} {2 4} {3 9} {4 16} {5 25} {6 36} {7 49} {8 64} {9 81}]
Three workers share the jobs channel. Each job goes to exactly one of them. Two closes make the whole thing finish. Closing jobs ends each worker’s range loop. A separate goroutine waits for all workers, then closes results, which ends the loop in main.
The workers finish jobs in whatever order the scheduler picks, so the results arrive in a different order on every run. Each result carries its job number, and sorting by it makes the output the same every time. Printing results as they arrive would not be.
Pipeline
A pipeline is a chain of stages joined by channels, where each stage is a goroutine that reads from one channel and writes to the next. This one has three: a generator, a squarer and a summer. The generator never stops on its own, so the context is the only way to shut it down:
package main
import (
"context"
"fmt"
)
// naturals sends 1, 2, 3, ... until ctx ends. It never stops on its own.
func naturals(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := 1; ; n++ {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
}
// square sends the square of every value it receives, until in closes or ctx ends.
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
// sumFirst adds up the first k values from in.
func sumFirst(in <-chan int, k int) int {
total := 0
for range k {
total += <-in
}
return total
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
squares := square(ctx, naturals(ctx))
fmt.Println("sum of first 5 squares:", sumFirst(squares, 5))
cancel()
for range squares {
// drain until square closes its channel
}
fmt.Println("both stages stopped")
}
It prints:
sum of first 5 squares: 55
both stages stopped
sumFirst takes five values and returns, leaving two goroutines with nobody reading their output. Without ctx, both would block on out <- … forever. With it, cancel() gives each stage’s select a second way out. They return, and their defer close(out) runs.
The empty for range squares loop proves it. It only ends when square closes its channel, and square only closes it after it has stopped. If cancellation didn’t work, the program would hang there instead of printing its last line.
Fan-out and fan-in
Fan-out means several goroutines read from the same channel to share the work. Fan-in means merging their separate output channels back into one:
package main
import (
"context"
"fmt"
"slices"
"sync"
)
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
// merge copies every value from every input onto one channel.
func merge(ctx context.Context, inputs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, in := range inputs {
wg.Go(func() {
for v := range in {
select {
case out <- v:
case <-ctx.Done():
return
}
}
})
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
src := make(chan int)
go func() {
defer close(src)
for n := range 10 {
src <- n + 1
}
}()
// fan out: three workers read the same channel
w1 := square(ctx, src)
w2 := square(ctx, src)
w3 := square(ctx, src)
// fan in: merge their outputs
var got []int
for v := range merge(ctx, w1, w2, w3) {
got = append(got, v)
}
slices.Sort(got)
fmt.Println(got)
}
It prints:
[1 4 9 16 25 36 49 64 81 100]
Three square stages read from src, so the ten numbers are split between them. merge starts one goroutine per input, copies values onto out, and closes out once every input is drained. That’s the same WaitGroup then close shape as the worker pool.
The merged values arrive interleaved in no fixed order, so main sorts them before printing. Every stage also takes ctx. Here the program runs to the end and nothing gets cancelled, but if main stopped reading early, the deferred cancel would release every goroutine.
An errgroup, built from WaitGroup
A common need is to run several tasks at once, stop them all when one fails, and return that first error. The errgroup package does this, but it lives in golang.org/x/sync, not the standard library. The idea is small enough to build from sync.WaitGroup, sync.Once and a context:
package main
import (
"context"
"fmt"
"sync"
"time"
)
// Group runs tasks in goroutines, keeps the first error,
// and cancels the shared context when that error happens.
type Group struct {
wg sync.WaitGroup
once sync.Once
err error
cancel context.CancelCauseFunc
}
func WithContext(parent context.Context) (*Group, context.Context) {
ctx, cancel := context.WithCancelCause(parent)
return &Group{cancel: cancel}, ctx
}
func (g *Group) Go(task func() error) {
g.wg.Go(func() {
if err := task(); err != nil {
g.once.Do(func() {
g.err = err
g.cancel(err)
})
}
})
}
func (g *Group) Wait() error {
g.wg.Wait()
g.cancel(g.err) // release the context even if nothing failed
return g.err
}
func main() {
g, ctx := WithContext(context.Background())
status := make([]string, 3)
for i := range 3 {
g.Go(func() error {
if i == 1 {
status[i] = "failed"
return fmt.Errorf("fetch %d: connection refused", i)
}
select {
case <-time.After(2 * time.Second):
status[i] = "finished"
return nil
case <-ctx.Done():
status[i] = "stopped early"
return ctx.Err()
}
})
}
err := g.Wait()
fmt.Printf("%q\n", status)
fmt.Println("first error:", err)
fmt.Println("cause:", context.Cause(ctx))
}
It prints:
["stopped early" "failed" "stopped early"]
first error: fetch 1: connection refused
cause: fetch 1: connection refused
Task 1 fails at once. once.Do records its error and cancels the shared context with that error as the cause. Tasks 0 and 2 are waiting on a 2-second job, see ctx.Done() close, and stop early.
Those two also return an error, context.Canceled. It never replaces the real one. sync.Once runs its function exactly once, and any other call waits until that first run has finished. By the time a cancelled task reaches once.Do, the first error is already stored. So “first error” here means the error that caused the cancellation, every time.
Each task writes to its own element of status, so no mutex is needed. main reads the slice only after Wait returns.
Bounded concurrency with a semaphore
Sometimes you have many tasks but must only run a few at once, because an API has a rate limit or a database has ten connections. A buffered channel makes a simple semaphore, with one slot for each unit of its capacity:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
const limit = 2
sem := make(chan struct{}, limit)
var (
mu sync.Mutex
running int
peak int
wg sync.WaitGroup
)
results := make([]int, 6)
for i := range 6 {
sem <- struct{}{} // take a slot; blocks while two are busy
wg.Go(func() {
defer func() { <-sem }() // give the slot back
mu.Lock()
running++
peak = max(peak, running)
mu.Unlock()
time.Sleep(5 * time.Millisecond) // pretend to call a slow service
results[i] = i * 10
mu.Lock()
running--
mu.Unlock()
})
}
wg.Wait()
fmt.Println(results)
fmt.Println("never more than", limit, "at once:", peak <= limit)
}
It prints:
[0 10 20 30 40 50]
never more than 2 at once: true
Sending into sem takes a slot. With a capacity of 2, the third send blocks until a running task finishes and receives from sem to free its slot. Taking the slot before wg.Go means there are never more than two goroutines at all, not six goroutines with four of them waiting.
The program prints peak <= limit, not peak. Whether two tasks actually overlapped depends on scheduling. That’s likely, but it isn’t guaranteed. The limit, though, holds on every run. To make the wait cancellable, take the slot in a select with a case <-ctx.Done():.
Every goroutine needs a way to stop
A goroutine blocked forever on a channel is a leak. It holds its stack and everything it points to, and nothing will ever free it. Here is the leak, next to the fix:
package main
import (
"context"
"fmt"
"time"
)
// leaky sends three values, with no way to give up.
func leaky(out chan<- int, exited chan<- struct{}) {
defer close(exited)
for i := range 3 {
out <- i
}
}
// fixed sends three values, but stops as soon as ctx ends.
func fixed(ctx context.Context, out chan<- int, exited chan<- struct{}) {
defer close(exited)
for i := range 3 {
select {
case out <- i:
case <-ctx.Done():
return
}
}
}
func report(name string, exited <-chan struct{}) {
select {
case <-exited:
fmt.Println(name, "goroutine exited")
case <-time.After(100 * time.Millisecond):
fmt.Println(name, "goroutine still stuck on its send")
}
}
func main() {
out := make(chan int)
exited := make(chan struct{})
go leaky(out, exited)
fmt.Println("leaky got", <-out) // take one value, then walk away
report("leaky", exited)
ctx, cancel := context.WithCancel(context.Background())
out2 := make(chan int)
exited2 := make(chan struct{})
go fixed(ctx, out2, exited2)
fmt.Println("fixed got", <-out2)
cancel() // walk away, but say so
report("fixed", exited2)
}
It prints:
leaky got 0
leaky goroutine still stuck on its send
fixed got 0
fixed goroutine exited
Both producers want to send three values, and main takes only one from each. leaky has no way to give up, so it sits on out <- 1 for the rest of the program. report waits 100ms, far longer than a goroutine needs to exit, and it’s still there. fixed puts the send inside a select with ctx.Done(), so cancel() lets it return.
Before you write go, answer one question: what makes this goroutine return? Good answers are “its input channel closes”, “its context is cancelled” or “it finishes a bounded piece of work”. “Someone reads its output” is only a good answer if that someone can’t stop early.
Go 1.26 adds an experimental goroutineleak profile to runtime/pprof, which reports goroutines blocked on something nothing else can reach. It’s only there when you build with GOEXPERIMENT=goroutineleakprofile. Without that, pprof.Lookup("goroutineleak") returns nil, and calling WriteTo on it panics, so check for nil first.
What to remember
- A context carries a stop signal, and sometimes a deadline, down a tree. Cancellation goes to children only, never up to parents or across to siblings.
- Cancelling doesn’t stop a goroutine by itself. Put every blocking step in a
selectwithcase <-ctx.Done():and returnctx.Err(). context.Canceledmeans someone calledcancel, andcontext.DeadlineExceededmeans time ran out. Usecontext.Causeto say why.- Pass
ctxas the first parameter, never store it in a struct, and always callcancel.go vetcatches a discarded one. - Keep context values for request-scoped data, with an unexported key type.
- Worker pools, pipelines, fan-in and errgroups all end the same way: a
WaitGroupwaits, then oneclosetells the reader it’s over. Sort results that arrive in no fixed order. - Before every
gostatement, know what makes that goroutine return.
Every goroutine you start needs a way to stop, and a context is usually that way.