Back to Notes

Go Context

Go Context

context.Context is the standard way to carry deadlines, cancellations, and request-scoped values across API boundaries and goroutines. Every production Go service uses it. Pass ctx as the first parameter of every function that does I/O.


The Four Constructors

ctx := context.Background()              // root — use at main, top of request handler
ctx := context.TODO()                    // placeholder — "I'll add context later"

ctx, cancel := context.WithCancel(parent)          // manual cancel
ctx, cancel := context.WithTimeout(parent, 5*time.Second)  // cancel after duration
ctx, cancel := context.WithDeadline(parent, time.Now().Add(5*time.Second)) // cancel at time
ctx := context.WithValue(parent, key, value)       // attach request-scoped data

Always call cancel() for WithCancel/WithTimeout/WithDeadline — prevents goroutine leak.

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // always defer immediately after creation

Propagating Context

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

    result, err := fetchUser(ctx, 42)
}

func fetchUser(ctx context.Context, id int) (User, error) {
    // pass ctx down to every I/O call
    row, err := db.QueryContext(ctx, "SELECT * FROM users WHERE id = ?", id)
    if err != nil {
        return User{}, err
    }
    // ...
}

Rule: ctx is always the first parameter, never stored in a struct.


Checking Cancellation

func worker(ctx context.Context, jobs <-chan Job) {
    for {
        select {
        case <-ctx.Done():
            // context cancelled or timed out
            fmt.Println("worker stopping:", ctx.Err()) // context.Canceled or context.DeadlineExceeded
            return
        case job, ok := <-jobs:
            if !ok {
                return
            }
            process(job)
        }
    }
}

ctx.Err() returns:

  • context.Canceled — parent called cancel()
  • context.DeadlineExceeded — timeout/deadline passed
  • nil — still active

Context in HTTP Servers

http.Request already carries a context — use r.Context().

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // already has request deadline if server has WriteTimeout

    // add a tighter internal timeout
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    user, err := db.QueryContext(ctx, "SELECT ...")
    if err != nil {
        if ctx.Err() != nil {
            http.Error(w, "request timeout", http.StatusGatewayTimeout)
            return
        }
        http.Error(w, "db error", http.StatusInternalServerError)
        return
    }
}

Context Values — WithValue

Use sparingly. Only for request-scoped data that doesn't fit function parameters: request ID, auth user, trace ID.

// use unexported key type to avoid collision
type ctxKey string
const requestIDKey ctxKey = "requestID"

// middleware attaches value
func withRequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := uuid.New().String()
        ctx := context.WithValue(r.Context(), requestIDKey, id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// downstream extracts value
func getRequestID(ctx context.Context) string {
    id, _ := ctx.Value(requestIDKey).(string)
    return id
}

Don'ts:

  • Don't use string as key type — collisions across packages
  • Don't pass optional function params via context
  • Don't store mutable values — context values are read-only

Context with goroutines

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

    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            worker(ctx, id)
        }(i)
    }

    time.Sleep(3 * time.Second)
    cancel()  // signal all workers to stop
    wg.Wait()
}

Stdlib Functions That Accept Context

// database/sql
db.QueryContext(ctx, query, args...)
db.ExecContext(ctx, query, args...)

// net/http client
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
client.Do(req)

// net package
dialer.DialContext(ctx, "tcp", addr)

Common Mistakes

MistakeFix
context.WithTimeout without defer cancel()Always defer cancel() immediately
Storing context in a struct fieldPass as function parameter instead
Using string as context keyUse unexported custom type
Ignoring ctx.Err() after errorCheck if timeout/cancel caused the error
context.Background() deep inside call stackThread ctx from the top — never create Background mid-call

Interview Talking Points

  • "Context solves the goroutine leak problem — without cancellation, a goroutine doing a DB query has no way to know the client already left."
  • "context.WithTimeout wraps WithDeadline. Both create a child context that cancels at a point in time. The cancel() func is for early cancel before the deadline."
  • "WithValue is for cross-cutting concerns (trace ID, auth user) — not for business logic params. If it's business logic, make it a real function parameter."

Related

  • [[Go/Channels]] — ctx.Done() is a receive-only channel
  • [[Go/HTTP Server]] — context in request handlers
  • [[Go/HTTP Clients]] — NewRequestWithContext
  • [[synthesis/Concurrency Deep Dive]] — concurrency patterns