Lesson 7: Context: Cancellation, Deadlines & Values
Why Context Is Everywhere
Open any Kubernetes controller. The first parameter of every method:
func (r *PodReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
var pod corev1.Pod
if err := r.client.Get(ctx, req.NamespacedName, &pod); err != nil {
return reconcile.Result{}, client.IgnoreNotFound(err)
}
// ... reconcile logic ...
}
Context solves three problems in distributed systems:
- Cancellation — "stop what you're doing, the request was cancelled"
- Deadlines — "this operation must complete within 5 seconds"
- Request-scoped values — "carry the trace ID and user info through the call chain"
It's an interface (Lesson 0001), its Done() method returns a channel (Lesson 0005), and it integrates with goroutines (Lesson 0005) and sync primitives (Lesson 0006). Everything connects.
Create and propagate contexts for cancellation, deadlines, and values. Understand the rules every cloud-native Go project follows.
The Context Interface
context.Context is an interface with four methods. That's it.
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
MethodReturnsPurpose
Deadline()When the context will be cancelledCheck remaining time
Done()``<-chan struct{} that closes on cancellationWait for cancellation
Err()``nil, Canceled, or DeadlineExceededWhy was it cancelled?
Value(key)Stored value or nilRetrieve request-scoped data
Contexts form a tree. You start with a root (Background() or TODO()) and derive children. Cancelling a parent cancels all children.
Cancellation — Stop What You're Doing
Cancellation is the primary use of context. When an HTTP client disconnects, the server should stop processing that request. Context makes this propagate automatically:
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // always call cancel to free resources
go worker(ctx, "worker-1")
go worker(ctx, "worker-2")
time.Sleep(2 * time.Second)
cancel() // signals ALL workers to stop
time.Sleep(time.Second)
fmt.Println("main exiting")
}
func worker(ctx context.Context, name string) {
for {
select {
case <-ctx.Done():
fmt.Println(name, "stopped:", ctx.Err())
return
default:
fmt.Println(name, "working...")
time.Sleep(500 * time.Millisecond)
}
}
}
Output:
worker-1 working...
worker-2 working...
worker-1 working...
worker-2 working...
// cancel() called after 2s
worker-1 stopped: context canceled
worker-2 stopped: context canceled
main exiting
ctx.Done() returns a <-chan struct{} — the same done-channel pattern you learned. When the context is cancelled, the channel is closed, and all goroutines waiting on <-ctx.Done() unblock.
Always Call cancel()
WithCancel, WithTimeout, and WithDeadline all return a cancel function. You MUST call it when done. Even if the timeout fires automatically, the context still holds resources until cancel() is called. Use defer cancel().
Deadlines and Timeouts
WithTimeout — "Give Up After N"
func fetchURL(parent context.Context, url string) (string, error) {
ctx, cancel := context.WithTimeout(parent, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err // includes context.DeadlineExceeded on timeout
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return string(body), nil
}
The timeout propagates through http.NewRequestWithContext — the HTTP client automatically aborts the request when the context expires.
WithDeadline — "Finish By This Wall Time"
deadline := time.Now().Add(10 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()
Functionally similar to WithTimeout, but absolute instead of relative. Use when you know the exact cutoff time.
Checking remaining time
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining < 100*time.Millisecond {
return errors.New("not enough time remaining")
}
}
WithValue — Request-Scoped Data
Context can carry values through the call chain. This is for request-scoped data only — trace IDs, user info, request start time. It is NOT for optional function parameters.
// Step 1: Define an unexported key type (prevents collisions)
type contextKey string
const (
userIDKey contextKey = "userID"
traceIDKey contextKey = "traceID"
)
// Step 2: Store values (e.g., in HTTP middleware)
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), userIDKey, "user-123")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Step 3: Retrieve values
func handleRequest(ctx context.Context) {
userID, ok := ctx.Value(userIDKey).(string)
if ok {
fmt.Println("user:", userID)
}
}
Never use built-in types as keys. If you use context.WithValue(ctx, "userID", ...), another package using the same string will collide. Always define an unexported type like type contextKey string.
Don't use context for optional parameters. If a function sometimes needs a logger and sometimes doesn't, use a functional option, not context.WithValue. Context values are invisible in the type signature — they create hidden dependencies.
The Rules of Context
RuleExplanation
First parameter, alwaysfunc DoThing(ctx context.Context, arg string) error
Name it ctxUniversal convention — don't name it c or context
Never store in a structContext is per-request, not per-object lifetime
Never pass nilUse context.TODO() if you don't have one yet
context.Background() at program rootMain, init, test main — the root of the context tree
Always defer cancel()Even with timeout-based contexts
Only values for request-scoped dataTrace IDs, auth tokens, request start time — not config
// func DoThing(ctx context.Context, ...) — YES
// func DoThing(arg string, ctx context.Context) — NO (ctx must be first)
// func DoThing(ctx context.Context, ...) error — ctx not needed? Don't add it
Context in Cloud-Native Go
Graceful HTTP Server Shutdown
The canonical pattern from the Go blog, used by every Go web service:
func main() {
srv := &http.Server{Addr: ":8080", Handler: router()}
// Start server in background
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("server: %v", err)
}
}()
// Wait for SIGINT (Ctrl+C)
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
log.Println("shutting down...")
// Give in-flight requests 10 seconds to finish
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("shutdown: %v", err)
}
log.Println("stopped")
}
Kubernetes Controller Pattern
// Every Kubernetes API call passes ctx
pod, err := r.client.Get(ctx, key, &pod)
// Context cancellation stops the controller
func (c *Controller) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case item := <-c.queue:
if err := c.reconcile(ctx, item); err != nil {
log.Printf("error: %v", err)
}
}
}
}
Practice
Write a function ProcessJobs(ctx context.Context, jobs []string) that processes each job with a 1-second simulated delay. Between jobs, check ctx.Done(). If the context is cancelled, stop processing and return which jobs were completed.
In main(), create a context with a 3-second timeout and pass it to ProcessJobs with 5 jobs. You should see only ~3 jobs complete before the timeout fires.
Create a parent context. Derive two child contexts (childA, childB) using WithCancel(parent). Launch goroutines that print their name every 500ms and exit when their context is cancelled. Cancel only childA — verify childB keeps running. Then cancel parent — verify childB also stops.
Write an HTTP server with two things:
- A middleware that extracts a
X-Request-IDheader and stores it in the context usingWithValue - A handler that retrieves the request ID from the context and includes it in the response
Test it with
curl -H "X-Request-ID: abc-123" localhost:8080. The response should echo back the request ID.
Quick Check
What's Next?
You now understand the parameter that flows through every Go cloud-native API. Next: Modules & Packages — dependency management, versioning, internal packages, and the go.mod file.
Ask Questions
Not sure when to use WithValue vs a struct field? Want to see more middleware patterns? Ask.
1. Q1: Why must you always call cancel() from WithCancel/WithTimeout?
2. Q2: Why use typed keys for WithValue instead of plain strings?
3. Q3: What does ctx.Err() return when the context is NOT cancelled?