48. `context`: cancellation and deadlines

📖 Reading · 11 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

A quit channel stops one goroutine. A context.Context stops an entire tree of them — every function your request called, and everything they called — with one signal. It's the standard way Go programs handle cancellation, timeouts and request-scoped values.

The shape of it

A Context carries three things: a Done() channel that closes when it's time to stop, an Err() explaining why, and optional values.

package main

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

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

    go func() {
        time.Sleep(20 * time.Millisecond)
        cancel()
    }()

    fmt.Println("waiting...")
    <-ctx.Done()
    fmt.Println("ctx.Done() fired")
    fmt.Println("reason:", ctx.Err())
}

context.Background() is the empty root context — you start from it in main, and in a server the framework hands you one per request.

WithCancel derives a child context plus a cancel function. Calling cancel() closes ctx.Done(), which anything selecting on it sees immediately.

Cancelling a worker

package main

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

func worker(ctx context.Context, wg *sync.WaitGroup, out chan<- int) {
    defer wg.Done()
    processed := 0
    for {
        select {
        case <-ctx.Done():
            fmt.Println("worker: stopping because", ctx.Err())
            out <- processed
            return
        default:
            processed++
            time.Sleep(5 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    out := make(chan int, 1)

    var wg sync.WaitGroup
    wg.Add(1)
    go worker(ctx, &wg, out)

    time.Sleep(30 * time.Millisecond)
    cancel()

    wg.Wait()
    fmt.Println("worker did some units of work:", <-out > 0)
}

The for { select { case <-ctx.Done(): return; default: ... } } loop is the shape of every cancellable Go worker. The default case does one unit of work and comes back to check again.

Timeouts and deadlines

The most common use by far:

package main

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

func fetch(ctx context.Context, d time.Duration) (string, error) {
    done := make(chan string, 1)
    go func() {
        time.Sleep(d)
        done <- "data"
    }()

    select {
    case v := <-done:
        return v, nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

func main() {
    fast, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel()
    v, err := fetch(fast, 10*time.Millisecond)
    fmt.Println("fast:", v, err)

    slow, cancel2 := context.WithTimeout(context.Background(), 30*time.Millisecond)
    defer cancel2()
    v, err = fetch(slow, 200*time.Millisecond)
    fmt.Println("slow:", v, err)
}

context.DeadlineExceeded is the error you get on timeout; context.Canceled is what an explicit cancel() produces. Callers usually check with errors.Is(err, context.DeadlineExceeded).

WithDeadline is the same thing with an absolute time instead of a duration.

Always defer cancel(), even when the context has a timeout. It releases the timer and the child's resources immediately; skipping it is a slow leak, and go vet will point it out.

Contexts form a tree

A derived context inherits its parent's cancellation. Cancel a parent and every descendant stops:

package main

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

func main() {
    parent, cancelParent := context.WithCancel(context.Background())
    childA, cancelA := context.WithCancel(parent)
    childB, cancelB := context.WithTimeout(parent, time.Hour)
    defer cancelA()
    defer cancelB()

    go func() {
        time.Sleep(20 * time.Millisecond)
        cancelParent()
    }()

    <-childA.Done()
    <-childB.Done()

    fmt.Println("childA:", childA.Err())
    fmt.Println("childB:", childB.Err())
    fmt.Println("both stopped when the parent did")
}

childB had a one-hour timeout and still stopped in 20 ms. Cancellation flows downward only: cancelling a child never affects its parent.

This is what makes context worth the plumbing. An HTTP handler gets a context tied to the client connection; if the client disconnects, every database query and outbound call started under it is cancelled automatically, all the way down.

Passing a context correctly

The conventions are strict, and Go tooling enforces most of them:

// Yes: first parameter, always named ctx
func FetchUser(ctx context.Context, id int) (*User, error)

// No: never store a context in a struct
type Client struct {
    ctx context.Context   // don't
}

// No: never pass nil
FetchUser(nil, 7)        // use context.TODO() if you truly have none yet
  • First parameter, named ctx, typed context.Context. No exceptions.
  • Don't store it in a struct. A context belongs to one call, not to an object with a longer life.
  • context.TODO() marks a spot where a context should be threaded through but you haven't done it yet. It behaves like Background() and reads as a to-do to the next person.
  • Don't pass nil.

Values (use sparingly)

A context can carry request-scoped values — a request ID, a trace span, an authenticated user:

package main

import (
    "context"
    "fmt"
)

type ctxKey string

const requestIDKey ctxKey = "requestID"

func handle(ctx context.Context) {
    if id, ok := ctx.Value(requestIDKey).(string); ok {
        fmt.Println("handling request", id)
        return
    }
    fmt.Println("handling request with no id")
}

func main() {
    ctx := context.WithValue(context.Background(), requestIDKey, "abc-123")

    handle(ctx)
    handle(context.Background())
}

Two rules that keep this from becoming a mess:

  1. Use an unexported custom key type (type ctxKey string), never a bare string. It makes collisions between packages impossible.
  2. Only for request-scoped metadata that crosses API boundaries. Not for optional arguments, not for dependencies. A value that's needed for the function to work belongs in the signature, where the compiler can check it.

Context values are untyped and invisible to the compiler — every read is a type assertion that might fail. That's why the advice is "sparingly".

Where you'll meet it

Almost every I/O-shaped API in modern Go takes a context:

req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
rows, err := db.QueryContext(ctx, "SELECT ...")
err := conn.PingContext(ctx)

Which means: thread a context through your own functions and you get end-to-end timeout and cancellation across your whole stack, for free. That's the payoff for the plumbing.

Your turn

Give slowTask (200 ms) a 50 ms context timeout and print the resulting error:

context deadline exceeded
package main

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

func slowTask(ctx context.Context) error {
    select {
    case <-time.After(200 * time.Millisecond):
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func main() {
    // create a 50ms timeout context, defer its cancel, call slowTask, print the error
}
package main

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

func slowTask(ctx context.Context) error {
    select {
    case <-time.After(200 * time.Millisecond):
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

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

    if err := slowTask(ctx); err != nil {
        fmt.Println(err)
    }
}

That's concurrency. Next module: writing one function that works for many types — generics.