context: Cancellation & Deadlines
Imagine a user in Pune opens a train search page, your server starts three database queries and two API calls, and then the user closes the tab. All that work is now wasted. Or a payment API hangs, and every request waiting on it piles up until the server runs out of memory. Go's context package solves both problems: it carries a cancellation signal and a deadline through every function involved in one piece of work.
In this lesson you will create contexts with timeouts and cancel functions, make your functions respect them, see how cancellation flows from parent to child, record a reason with a cause, and carry request-scoped values.
The Context type
A context.Context is an interface with four methods. The two you use most are:
Done()returns a channel that is closed when the work should stop.Err()returnsnilwhile the context is active, thencontext.Canceledorcontext.DeadlineExceededexplaining why it stopped.
Every context tree starts from context.Background(), an empty context that is never cancelled. You derive new contexts from it with functions such as WithTimeout and WithCancel. By convention, a function that may block or do I/O takes a context as its first parameter, named ctx.
Timeouts with WithTimeout
context.WithTimeout(parent, d) returns a new context that is cancelled automatically after d, plus a cancel function. Your slow function selects on ctx.Done() alongside its real work:
package main
import (
"context"
"errors"
"fmt"
"time"
)
func fetchFare(ctx context.Context, route string) (int, error) {
select {
case <-time.After(200 * time.Millisecond): // a slow fare API
return 1450, nil
case <-ctx.Done():
return 0, fmt.Errorf("fare for %s: %w", route, ctx.Err())
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
fare, err := fetchFare(ctx, "Delhi-Jaipur")
if err != nil {
fmt.Println("error:", err)
fmt.Println("timed out:", errors.Is(err, context.DeadlineExceeded))
return
}
fmt.Println("fare:", fare)
}error: fare for Delhi-Jaipur: context deadline exceeded timed out: true
The API needs 200 ms but the context allows 100 ms, so ctx.Done() closes first and the function returns at once. Change the timeout to 500 ms and the program prints fare: 1450.
Always call cancel, usually with defer cancel() on the next line, even when the timeout will fire on its own. It frees the timer and any resources tied to the context straight away; go vet warns if you forget.
Cancelling work yourself
context.WithCancel(parent) gives you a context that stops when you call cancel(). Here a token generator runs until the caller has enough:
package main
import (
"context"
"fmt"
)
func tokens(ctx context.Context, out chan<- int) {
defer close(out)
for n := 1; ; n++ {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan int)
go tokens(ctx, ch)
for t := range ch {
fmt.Println("token", t)
if t == 3 {
cancel()
break
}
}
fmt.Println("done")
}token 1 token 2 token 3 done
Without the context, the generator goroutine would block forever on its next send after main stops reading: a goroutine leak. With it, cancel() closes ctx.Done(), the select picks that case, and the goroutine exits cleanly.
Cancellation flows down the tree
Contexts form a tree. Cancelling a parent cancels every context derived from it, but cancelling a child never affects the parent. A server can therefore cancel one request's context, and every database query and API call started for that request stops too.
package main
import (
"context"
"errors"
"fmt"
"time"
)
func main() {
parent, cancelParent := context.WithCancel(context.Background())
child, cancelChild := context.WithTimeout(parent, time.Hour)
defer cancelChild()
cancelParent()
<-child.Done()
fmt.Println("child:", child.Err())
ctx, cancel := context.WithCancelCause(context.Background())
cancel(errors.New("user closed the tab"))
fmt.Println(ctx.Err())
fmt.Println(context.Cause(ctx))
}child: context canceled context canceled user closed the tab
The child had a one-hour timeout, yet it stopped immediately because its parent was cancelled. WithCancelCause (Go 1.20) lets you record why something was cancelled; ctx.Err() still says context canceled, while context.Cause(ctx) returns your reason, which makes logs far more useful. There are also WithTimeoutCause and WithDeadlineCause.
Request-scoped values
context.WithValue attaches a key and value to a context, for data that belongs to one request and must cross API boundaries, such as a request ID for logs or the logged-in user. Use your own unexported key type so packages cannot clash:
package main
import (
"context"
"fmt"
)
type ctxKey string
const requestIDKey ctxKey = "request-id"
func logLine(ctx context.Context, msg string) {
id, ok := ctx.Value(requestIDKey).(string)
if !ok {
id = "no-id"
}
fmt.Printf("[%s] %s\n", id, msg)
}
func main() {
ctx := context.WithValue(context.Background(), requestIDKey, "req-7f3a")
logLine(ctx, "payment started")
logLine(context.Background(), "health check")
}[req-7f3a] payment started [no-id] health check
Value returns any, so you use a type assertion to get the string back. Do not use context values for ordinary function inputs like a customer ID or page size; pass those as normal parameters, so the function's needs stay visible in its signature.
Where contexts come from in real programs
You rarely call context.Background() deep inside your code. Contexts usually arrive from outside:
- In an HTTP handler,
r.Context()is cancelled when the client disconnects or the server shuts down. - In tests,
t.Context()(Go 1.24) is cancelled just before the test finishes. - In
main,signal.NotifyContext(context.Background(), os.Interrupt)gives a context cancelled when the user presses Ctrl+C.
Pass that context down to every call that accepts one: http.NewRequestWithContext, database QueryContext methods, and your own functions. You will use r.Context() and signal.NotifyContext in net/http & a REST API.
Common mistakes
- Forgetting
defer cancel(). Resources stay held until the parent ends;go vetreports a lost cancel. - Ignoring
ctx.Done()in long loops. A context only helps if the code checks it; select onctx.Done()or checkctx.Err()regularly. - Starting from
context.Background()inside a handler. That breaks the chain; user.Context(). - Using context values as a bag of parameters. Keep values for request-scoped data such as IDs and auth.
Key points
- A
context.Contextcarries a cancellation signal (Done), a reason (Err), an optional deadline and request-scoped values. WithTimeout,WithDeadlineandWithCancelderive new contexts; always call the returnedcancel.- Functions that block should accept
ctxfirst and stop whenctx.Done()closes. - Cancelling a parent cancels all its children;
WithCancelCauseandcontext.Causerecord why. - Get contexts from
r.Context(),t.Context()orsignal.NotifyContextand pass them down.
Exercise
Write checkStock(ctx context.Context, item string, delay time.Duration) (bool, error) that pretends to call a warehouse API taking delay, and returns true unless the context ends first. In main, use one context with a 150 ms timeout for two calls in a row: "rice" taking 100 ms and then "dal" taking 100 ms. Print the result of each call and explain why the second fails.
Show solution
package main
import (
"context"
"fmt"
"time"
)
func checkStock(ctx context.Context, item string, delay time.Duration) (bool, error) {
select {
case <-time.After(delay):
return true, nil
case <-ctx.Done():
return false, fmt.Errorf("check %s: %w", item, ctx.Err())
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
for _, item := range []string{"rice", "dal"} {
ok, err := checkStock(ctx, item, 100*time.Millisecond)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println(item, "in stock:", ok)
}
}rice in stock: true error: check dal: context deadline exceeded
The timeout covers the whole operation, not each call. The first call uses 100 ms of the 150 ms budget, so the second call has only 50 ms left and the deadline fires before its 100 ms delay ends.
Quick Quiz
1. What does ctx.Done() return?
Done returns a channel you can select on; it closes when the work should stop.
2. What error does ctx.Err() return after a timeout?
A context that hit its deadline reports context.DeadlineExceeded, which prints as "context deadline exceeded".
3. What happens to child contexts when the parent is cancelled?
Cancellation propagates from parent to all derived contexts.
4. Where should a context parameter go in a function signature?
By convention, ctx context.Context is the first parameter and is not stored in structs.
Learn Go with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
