Picture this: your application is juggling thousands of concurrent requests. A few of them start dragging their feet, eating up memory and CPU cycles while your users wait. How do you stop these runaway operations without taking down the whole server? That’s exactly what the Go context package was built for.
This guide walks you through the Go context package and shows you how to use it for request cancellation, timeouts, and passing request-scoped data. You’ll see real code examples, learn a few patterns worth following, and pick up on mistakes that trip up even experienced Go developers. By the end, you’ll know how to write Go code that handles messy, real-world request lifecycles without leaking goroutines or hanging forever.
What Is the Go Context Package?
The context package, part of the Go standard library since Go 1.7, gives you a way to carry deadlines, cancellation signals, and request-scoped values across function calls and even across network boundaries. If you’ve written any concurrent Go code that talks to databases, HTTP clients, or other goroutines, you’ve probably already bumped into it.
It’s a small package, but it solves a big problem: how do you tell a chain of function calls, “Hey, stop what you’re doing”?
Why Bother With Context?
Without context, canceling work in Go gets messy fast. You end up passing custom cancellation channels around, writing your own timeout logic, or – worse – just letting goroutines run to completion even after nobody cares about the result anymore. That’s how you leak goroutines and burn through memory.
Context fixes this by giving every function in the call chain a shared, standard signal to check. If the parent operation gets canceled or times out, every downstream function can find out about it and stop cleanly.
The Building Blocks
- Context values: key-value pairs you can attach to a context and read further down the call chain (useful for things like request IDs or auth tokens).
- Cancellation signals: a channel-based mechanism that tells goroutines to stop working.
- Deadlines and timeouts: hard limits on how long an operation is allowed to run before it gets canceled automatically.
According to the Go blog post on concurrency patterns, Google introduced context internally years before open-sourcing it, specifically to solve cancellation problems across their massive distributed systems. That’s a good sign it’s built for real production use, not just toy examples.
Setting Up Your Environment
Before we get into code, make sure Go is installed. Head to the official Go download page, grab the installer for your OS, and run it.
Once that’s done, confirm everything works by running this in your terminal:
go version
You should see something like go version go1.22.0 darwin/arm64. If you get a “command not found” error, double check your PATH environment variable includes Go’s bin directory.
Creating Your First Context
Every context in your program starts from a root. Go gives you two ways to create one: context.Background() and context.TODO().
package main
import (
"context"
"fmt"
)
func main() {
// context.Background() returns an empty, non-nil context.
// Use it at the top level of your program, like in main() or in tests.
ctx := context.Background()
fmt.Println("Context created successfully:", ctx)
}
Code language: JavaScript (javascript)
Use context.Background() when you know you need a context but there isn’t a parent one yet – typically at the entry point of your application. Use context.TODO() as a placeholder when you’re not yet sure which context to use, usually while you’re still writing or refactoring code. Just don’t forget to replace it before shipping to production.
Adding Cancellation to a Context
Here’s where things get useful. context.WithCancel() returns a derived context along with a cancel function. Call that function, and every goroutine listening on ctx.Done() gets notified immediately.
package main
import (
"context"
"fmt"
"time"
)
func main() {
// Create a cancellable context derived from the background context
ctx, cancel := context.WithCancel(context.Background())
// Run some work in a separate goroutine
go func() {
select {
case <-time.After(2 * time.Second):
fmt.Println("Work completed")
case <-ctx.Done():
// ctx.Err() tells you why the context was canceled
fmt.Println("Work cancelled:", ctx.Err())
}
}()
// Simulate deciding to cancel after 1 second
time.Sleep(1 * time.Second)
cancel()
// Give the goroutine time to react before main() exits
time.Sleep(2 * time.Second)
}
Code language: PHP (php)
Run this, and you’ll see “Work cancelled: context canceled” printed after roughly one second, instead of waiting the full two seconds for the fake work to finish. That’s the whole point – you get to stop things early instead of waiting them out.
Handling Deadlines and Timeouts
Cancellation is great when you decide, at runtime, that something needs to stop. But often you know upfront how long an operation should be allowed to run. That’s what context.WithDeadline() and context.WithTimeout() are for.
WithTimeout() is really just a convenience wrapper around WithDeadline() – it calculates the deadline for you based on a duration.
package main
import (
"context"
"fmt"
"time"
)
func main() {
// This context will automatically cancel itself after 1 second
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel() // always call cancel to release resources, even if the timeout fires first
select {
case <-time.After(2 * time.Second):
fmt.Println("Work completed")
case <-ctx.Done():
fmt.Println("Work timed out:", ctx.Err())
}
}
Code language: PHP (php)
Notice the defer cancel() line. Even when a timeout fires on its own, you should still call cancel() to free up the resources associated with the context. Skipping this is a common source of subtle memory leaks in long-running Go services.
Guidelines for Using Context Effectively
A few habits will save you a lot of debugging time down the road:
- Pass context as the first parameter. By convention, Go functions that accept a context take it as the first argument, usually named
ctx. This signals to other developers that the function can be canceled or has a timeout. - Don’t store contexts in structs. Pass them explicitly through function calls instead. Storing a context in a struct field makes it stale and disconnected from the actual request lifecycle.
- Start with
context.Background()at the top level. Yourmain()function, top-level HTTP handlers, or test functions are the right places to create a root context. - Treat
context.TODO()as temporary. It’s fine while prototyping, but replace it with a real context before merging code. - Always call the cancel function, even if you’re confident the context will expire naturally. It’s cheap insurance against leaked resources.
Building a Cancellable API Handler
Let’s put this together with something closer to a real-world scenario: an HTTP handler that cancels slow requests instead of letting them hang indefinitely.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
// Derive a timeout context from the incoming request's context.
// r.Context() is automatically canceled if the client disconnects.
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
select {
case <-time.After(10 * time.Second):
// This branch simulates slow work that takes too long
fmt.Fprintln(w, "Work completed")
case <-ctx.Done():
// This fires first because our timeout (5s) is shorter than the fake work (10s)
http.Error(w, "Request cancelled", http.StatusGatewayTimeout)
}
}
func main() {
http.HandleFunc("/", handler)
fmt.Println("Server starting on port 8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Println("Server failed to start:", err)
}
}
Code language: PHP (php)
A couple of details worth calling out: we derive our timeout context from r.Context() rather than context.Background(). Why does that matter? Because r.Context() is tied to the actual HTTP request. If the client closes the connection early, Go automatically cancels that context, and our handler stops working immediately instead of continuing to burn CPU on a request nobody’s waiting for anymore.
Testing the Server
Start the server with go run main.go, then hit http://localhost:8080 in your browser or with curl. Since our simulated work takes 10 seconds but the timeout is set to 5, you’ll get a 504 Gateway Timeout response after about 5 seconds instead of waiting the full 10.
Try adjusting the timeout value and the simulated work duration to see how the behavior changes. It’s a good way to build intuition for how these two settings interact in a real service.
Common Pitfalls to Watch For
- Forgetting to call
cancel(): Every context created withWithCancel,WithTimeout, orWithDeadlineshould have its cancel function called, typically withdefer. Skipping this leaks memory over time. - Passing
nilinstead of a context: Some codebases still do this out of habit. Usecontext.TODO()if you genuinely don’t have a context yet. - Overusing context values: Context values are handy for things like request IDs or tracing metadata, but they’re not a replacement for normal function parameters. Stuffing business logic data into context values makes code harder to follow.
- Ignoring
ctx.Err(): When a context is done,ctx.Err()tells you whether it was canceled or timed out. Checking this can help with better logging and debugging.
Wrapping Up
The Go context package might look small, but it solves a real problem that shows up constantly in concurrent programs: how do you stop work cleanly when nobody needs the result anymore? Once you get comfortable with WithCancel, WithTimeout, and passing context through your call chain, you’ll find it becomes second nature.
Next Steps
- Try swapping
context.WithTimeout()forcontext.WithDeadline()in the handler example and see how the code changes. - Read the Go Blog’s original post on context for more background on why the package was designed this way.
- Check out our guide on Go Error Handling to see how context and error handling often work together in real applications.
- Got a context-related bug story? Share it in the comments – there’s a good chance someone else has hit the same issue.