02 - Context Deep Dive

📋 Jump to Takeaways

In real Go code, context.Context is everywhere. Every HTTP handler, every database call, every goroutine that might need to stop. It's how you tell concurrent work: "time's up" or "we don't need this anymore."

Why Context Exists

Before context, the standard pattern was a manual done channel:

done := make(chan struct{})

go func() {
    for {
        select {
        case <-done:
            fmt.Println("stopping")
            return
        case job := <-jobs:
            process(job)
        }
    }
}()

// Later: signal the goroutine to stop
close(done)

This works, but every function needs its own done channel passed around. No standard way to add timeouts. No way to propagate cancellation through a call chain. Every team invented their own version.

Context standardizes this into a single interface that the entire ecosystem understands.

ctx, cancel := context.WithCancel(context.Background())

go func() {
    for {
        select {
        case <-ctx.Done(): // same as <-done, but standard
            fmt.Println("stopping:", ctx.Err())
            return
        case job := <-jobs:
            process(job)
        }
    }
}()

cancel() // same as close(done), but also supports timeouts, deadlines, and propagation

ctx.Done() is the done channel. cancel() is close(done). But context adds timeouts, deadlines, value propagation, and parent-child cancellation for free.

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

Four methods. That's it. Done() returns a channel that closes when the context is cancelled. Err() tells you why.

context.Background and context.TODO

Every context chain starts with a root.

ctx := context.Background() // production code — the root context
ctx := context.TODO()       // placeholder when you're not sure which context to use yet

Background is the standard root. TODO is for when you know you need a context but haven't wired it up yet. Both are identical in behavior — the difference is intent.

context.WithCancel

Create a context you can cancel manually.

package main

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

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // always defer cancel to avoid leaks

    done := make(chan struct{})
    go func() {
        defer close(done)
        select {
        case <-ctx.Done():
            fmt.Println("cancelled:", ctx.Err())
            return
        case <-time.After(5 * time.Second):
            fmt.Println("finished work")
        }
    }()

    time.Sleep(1 * time.Second) // simulate doing something before cancelling
    cancel() // signal the goroutine to stop
    <-done   // wait for goroutine to finish
}

Output: cancelled: context canceled

The goroutine checks ctx.Done() in a select. When cancel() is called, the channel closes and the goroutine exits cleanly. Notice we use a done channel instead of time.Sleep to wait for the goroutine — that's the proper way.

context.WithTimeout

Cancel automatically after a duration.

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

select {
case <-time.After(5 * time.Second):
    fmt.Println("work done")
case <-ctx.Done():
    fmt.Println("timed out:", ctx.Err())
}

Output: timed out: context deadline exceeded

WithTimeout is syntactic sugar for WithDeadline with a relative time.

context.WithDeadline

Same as WithTimeout but with an absolute time.

deadline := time.Now().Add(3 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

Use WithTimeout for "give this 5 seconds." Use WithDeadline for "this must finish by 2:30 PM."

Checking the Deadline

You can check if a context has a deadline and how much time is left.

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

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

Useful when you need to decide whether to start expensive work or bail early.

context.WithValue

Attach request-scoped data to a context. Common for request IDs, auth tokens, tracing.

type contextKey string

const requestIDKey contextKey = "requestID"

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

// Later, in a different function:
if id, ok := ctx.Value(requestIDKey).(string); ok {
    fmt.Println("request:", id)
}

Why a custom type for keys? If two packages both use a plain string "id" as a key, one overwrites the other — collision. A custom type like type contextKey string is unique per package. Even if two packages define the same underlying string, Go treats them as different keys because the types are different. Think of it as a namespace.

// Bad — any package using "id" collides
ctx = context.WithValue(ctx, "id", "user-123")

// Good — only this package can access this key
type contextKey string
const idKey contextKey = "id"
ctx = context.WithValue(ctx, idKey, "user-123")

Other rules:

  • Don't use context for passing function parameters — it's for request-scoped metadata
  • Values are immutable — WithValue creates a new context, doesn't modify the parent

Context Propagation

Contexts form a tree. Cancelling a parent cancels all children.

parent, cancelParent := context.WithCancel(context.Background())
child1, cancelChild1 := context.WithTimeout(parent, 5*time.Second)
child2, cancelChild2 := context.WithCancel(parent)

defer cancelChild1()
defer cancelChild2()

cancelParent() // cancels parent, child1, AND child2

This is why you pass context through your call chain. An HTTP handler creates a context, passes it to a service function, which passes it to a database call. If the client disconnects, the entire chain cancels.

Real-World Pattern: HTTP Handler

func handler(w http.ResponseWriter, r *http.Request) {
    // r.Context() is cancelled if the client disconnects
    ctx := r.Context()

    result, err := fetchData(ctx)
    if err != nil {
        if ctx.Err() != nil {
            return // client gone, don't bother responding
        }
        http.Error(w, "error", 500)
        return
    }

    fmt.Fprint(w, result)
}

func fetchData(ctx context.Context) (string, error) {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    // process response...
    return "data", nil
}

The timeout is layered on top of the request context. If the client disconnects OR 3 seconds pass, the HTTP call is cancelled.

Real-World Pattern: Worker with Context

func worker(ctx context.Context, id int, jobs <-chan int, wg *sync.WaitGroup) {
    defer wg.Done()
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("worker %d stopping: %v\n", id, ctx.Err())
            return
        case job, ok := <-jobs:
            if !ok {
                return // channel closed
            }
            fmt.Printf("worker %d processing job %d\n", id, job)
            time.Sleep(500 * time.Millisecond) // simulate processing time
        }
    }
}

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

    jobs := make(chan int)
    var wg sync.WaitGroup

    for i := 0; i < 3; i++ {
        wg.Add(1)
        go worker(ctx, i, jobs, &wg)
    }

    for j := 0; j < 10; j++ {
        select {
        case jobs <- j:
        case <-ctx.Done():
            fmt.Println("stopping job dispatch:", ctx.Err())
            goto done
        }
    }
done:
    close(jobs)
    wg.Wait()
}

Workers check ctx.Done() alongside their job channel. When the context expires, everyone stops.

Common Mistakes

Not calling cancel. Every WithCancel, WithTimeout, and WithDeadline returns a cancel function. Always call it, even if the context expires on its own. It releases resources.

// Wrong
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)

// Right
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

Passing context as a struct field. Context should be the first parameter of a function, not stored in a struct.

// Wrong
type Server struct {
    ctx context.Context
}

// Right
func (s *Server) Handle(ctx context.Context) { ... }

Using context.Value for everything. It's not a general-purpose key-value store. Use it for request-scoped metadata (request ID, auth info, tracing). Pass real dependencies as function parameters.

Key Takeaways

  • context.Background() is the root — start every context chain here
  • WithCancel for manual cancellation, WithTimeout for duration-based, WithDeadline for absolute time
  • Always defer cancel() — even if the context times out on its own
  • Cancelling a parent cancels all children — contexts form a tree
  • Check ctx.Done() in select statements to make goroutines cancellable
  • Pass context as the first function parameter, never store it in a struct
  • Use WithValue sparingly — request IDs and tracing, not function arguments

🚀 Ready to run?

Complete runnable examples for this lesson.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

© 2026 ByteLearn.dev. Free courses for developers. · Privacy