Understanding Go’s context Package: A Deep Dive
Leeting Yan
Go’s context package is one of the most important tools for building robust, cancellable, timeout-aware, concurrent programs. Whether you are writing HTTP servers, gRPC services, background workers, or database operations, you will almost always use context.Context.
This article provides a deep, practical, and complete analysis of the context package using clear code examples.
1. Why context Exists
Modern Go programs are highly concurrent. You might start goroutines for:
- database queries
- API calls
- background tasks
- streaming events
But how do you cancel a goroutine?
How do you propagate deadlines across function calls?
How do you attach request-scoped values safely?
Go’s answer is:
Use
context.Contextfor cancellation, timeouts, and request-scoped data.
2. What a Context Actually Is
context.Context is an interface, not a struct:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Every context supports:
- Deadline(): When this context should expire
- Done(): A channel closed when the context is cancelled
- Err(): Why it was cancelled (
context.Canceledorcontext.DeadlineExceeded) - Value(): Retrieve request-scoped data
3. The Root Context: context.Background()
Every context tree starts with either:
context.Background()context.TODO()
Example:
ctx := context.Background()
Use cases:
- top-level init
- main function
- tests
context.TODO() is used when you know a context is required but haven’t decided how to handle it yet.
4. Creating Cancellable Contexts
The most common pattern is:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
Full example:
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("worker stopped:", ctx.Err())
return
default:
fmt.Println("working...")
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go worker(ctx)
time.Sleep(2 * time.Second)
fmt.Println("Stopping worker")
cancel()
time.Sleep(500 * time.Millisecond)
}
Output:
working...
working...
...
Stopping worker
worker stopped: context canceled
5. Context With Timeout
A context automatically cancelled after a time.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
Example:
package main
import (
"context"
"fmt"
"time"
)
func longTask(ctx context.Context) error {
select {
case <-time.After(5 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := longTask(ctx)
fmt.Println("Result:", err)
}
Output:
Result: context deadline exceeded
6. Context With Deadline
Similar to timeout, but with an absolute timestamp:
deadline := time.Now().Add(1 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
This is useful when propagating deadlines across service boundaries.
7. Passing Context Through Function Calls
A core rule of Go:
Always pass context as the first argument:
func(ctx context.Context, ...)
Example:
func fetchData(ctx context.Context) (string, error) {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(1 * time.Second):
return "hello", nil
}
}
func handler(ctx context.Context) error {
data, err := fetchData(ctx)
if err != nil {
return err
}
fmt.Println("Data:", data)
return nil
}
Never store a context inside a struct.
Never pass nil context.
8. Using Done() Correctly
Done() returns a channel that’s closed when the context is cancelled.
Wrong:
<-ctx.Done() // blocks forever if no cancel/timeout
Right:
select {
case <-ctx.Done():
return ctx.Err()
case result := <-work:
return result
}
Never ignore ctx.Err().
9. Context Values: When to Use (and Avoid)
Add values:
ctx := context.WithValue(context.Background(), "userID", 123)
Retrieve:
id := ctx.Value("userID")
But Go has strong guidance:
Context is for request-scoped data, NOT configuration.
Do not put everything into context.
Do not use string keys.
Example of proper key usage:
package main
import (
"context"
"fmt"
)
type userKey struct{} // empty struct as namespace
func main() {
ctx := context.WithValue(context.Background(), userKey{}, "alice")
name := ctx.Value(userKey{})
fmt.Println(name)
}
This avoids collisions between different packages.
10. Cancellation Propagation
Cancelling a parent context cancels all children.
Example:
ctx, cancel := context.WithCancel(context.Background())
child1, _ := context.WithCancel(ctx)
child2, _ := context.WithTimeout(ctx, 5*time.Second)
cancel() // cancels parent
<-child1.Done() // immediately cancelled
<-child2.Done() // immediately cancelled
This is extremely useful in request/response pipelines.
11. Real-World Example: HTTP Server with Request Timeout
Example using Go’s http.Server (which automatically passes context):
package main
import (
"context"
"encoding/json"
"net/http"
"time"
)
type Response struct {
Message string `json:"message"`
}
func slowOperation(ctx context.Context) (string, error) {
select {
case <-time.After(3 * time.Second):
return "completed", nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
defer cancel()
res, err := slowOperation(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusGatewayTimeout)
return
}
_ = json.NewEncoder(w).Encode(Response{Message: res})
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}
Try accessing:
curl localhost:8080
Outputs:
context deadline exceeded
12. Using Context for Database Operations
Most DB drivers support context cancellation.
Example with database/sql:
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
row := db.QueryRowContext(ctx, "SELECT sleep(1)")
err := row.Scan(&data)
If the DB takes too long, cancel stops the query and returns an error.
13. Context in gRPC
gRPC heavily relies on context:
- deadlines
- metadata
- cancellation
Example:
func (s *Server) GetUser(ctx context.Context, req *UserRequest) (*UserReply, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
userID := req.Id
// ...
return &UserReply{Id: userID, Name: "Alice"}, nil
}
14. Best Practices (Must Follow)
✔ Always pass context as the first parameter
func Process(ctx context.Context, input string)
✔ Always honor context cancellation
Do not ignore <-ctx.Done().
✔ Use context.WithValue sparingly
Only for request-scoped data:
- request ID
- user ID
- trace ID
✔ Avoid storing context inside structs
Bad:
type Service struct{ ctx context.Context }
✔ Do not pass nil contexts
If you don’t know which to use:
ctx := context.TODO()
✔ Wrap functions that may block in select {} with context
Correct:
select {
case result := <-ch:
case <-ctx.Done():
}
15. Summary
context is a foundational part of Go’s concurrency model.
It solves three important problems:
- Cancellation propagation
- Timeouts and deadlines
- Request-scoped values
You now know:
- How to create and cancel contexts
- How to use timeouts and deadlines
- How to pass context across function calls
- How to use and avoid
context.WithValue - How context integrates with HTTP and gRPC