Let’s talk about making your Go programs stop when they should. It sounds simple, but it’s one of the trickiest parts of writing reliable software. How do you tell a dozen goroutines, all busy with different tasks, that it’s time to pack up and finish? This is where the context package comes in. I like to think of it as a polite but firm system of signals you can pass through your entire application.
Think about a web server. A user’s browser makes a request. That request might need to talk to a database, call three other microservices, and process some files. If the user closes their tab halfway through, you don’t want those database queries and service calls to keep chugging along, wasting resources. You need a way to broadcast a “stop” signal. That’s the core job of context.
Here’s the most basic pattern. You create a context that knows how to cancel and pass it to the parts of your code that do the work.
package main
import (
"context"
"fmt"
"time"
)
// A worker that keeps going until told to stop.
func worker(ctx context.Context, id int) {
for {
// This `select` statement is the heart of listening for cancellation.
select {
case <-ctx.Done(): // This channel fires when the context is cancelled or times out.
fmt.Printf("Worker %d is stopping because: %v\n", id, ctx.Err())
return
default:
// Simulate doing some work.
fmt.Printf("Worker %d is busy...\n", id)
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
// Create a context that automatically cancels after 2 seconds.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // It's good practice to call cancel in defer, just to clean up resources.
// Start a few workers.
go worker(ctx, 1)
go worker(ctx, 2)
// Wait for the context to be done (in this case, to time out).
<-ctx.Done()
time.Sleep(100 * time.Millisecond) // A tiny pause to let workers print their final messages.
fmt.Println("Main function is done.")
}
When you run this, you’ll see both workers printing “busy…” for about two seconds, and then they’ll both get the signal and print their stopping message. One cancel() call stopped all of them. That’s propagation.
Now, let’s get into the specific patterns that help you build this control flow throughout your programs.
First, passing the signal is a matter of function signatures. Any function that does something potentially slow—like network calls, file I/O, or long calculations—should take a context.Context as its first argument. This is a strong convention in the Go community.
func fetchDataFromAPI(ctx context.Context, url string) ([]byte, error) {
// Use the context with the HTTP request. If ctx is cancelled,
// the http.Client will stop the request.
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err // This will be a context error if ctx was cancelled.
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
The second pattern is about setting limits. Deadlines and timeouts prevent your system from hanging forever. You should almost always set a timeout for operations triggered by an external request.
func handleUserRequest(w http.ResponseWriter, r *http.Request) {
// Derive a new context from the request's context that times out in 5 seconds.
// The original request context might be cancelled if the client disconnects.
// Our timeout adds another layer: "even if the client is patient, don't take more than 5 sec."
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() // Always defer the cancel for derived contexts.
data, err := fetchDataFromAPI(ctx, "https://api.example.com/data")
if err != nil {
// Check *why* we failed.
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "Our backend is too slow right now.", http.StatusGatewayTimeout)
return
}
http.Error(w, "Something went wrong.", http.StatusInternalServerError)
return
}
w.Write(data)
}
Context isn’t just for stopping things. It’s also a way to carry information that’s specific to a single request, like a trace ID for logging or a user’s authentication token. This is the value propagation pattern. The key is to use your own custom type for the key to avoid collisions with other packages.
// Define your own type for context keys.
type contextKey string
const (
requestIDKey contextKey = "request_id"
userTokenKey contextKey = "user_token"
)
// A middleware function that adds a request ID to the context.
func addRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := generateUniqueID()
// Create a new context with the value attached.
ctx := context.WithValue(r.Context(), requestIDKey, id)
// Call the next handler with the new context.
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Later, deep inside a function, you can retrieve it.
func processOrder(ctx context.Context, order Order) error {
id, ok := ctx.Value(requestIDKey).(string) // Type assertion is needed.
if !ok {
id = "unknown"
}
log.Printf("[%s] Processing order %v", id, order)
// ... business logic
return nil
}
One of my favorite patterns is using a single cancellable context to manage a group of goroutines. You start them all, give them the same context, and if one fails critically, you cancel the context to stop all the others.
func processAllItems(ctx context.Context, items []string) error {
// Create a cancellable context for this operation.
ctx, cancel := context.WithCancel(ctx)
defer cancel() // Ensure we clean up if all goes well.
errCh := make(chan error, len(items))
for i, item := range items {
go func(idx int, it string) {
// Each goroutine does its work, listening for cancellation.
select {
case <-ctx.Done():
// The parent told us to stop, send that error.
errCh <- ctx.Err()
default:
// Otherwise, try to do the work.
errCh <- processSingleItem(ctx, it)
}
}(i, item)
}
// Collect results.
for range items {
if err := <-errCh; err != nil {
// The first serious error means we cancel everyone.
cancel()
// You might wait to drain the errCh here, but for simplicity:
return fmt.Errorf("processing failed: %w", err)
}
}
return nil // All succeeded.
}
It’s crucial to understand where contexts come from. Your main function starts with context.Background(). An HTTP request provides a context via r.Context(). You should never create a new background context inside a function that already has one passed in. Always derive new contexts from the one you received.
func poorPractice() {
ctx := context.Background() // WRONG! This is divorced from any request lifecycle.
// ... use ctx
}
func goodPractice(parentCtx context.Context) {
// RIGHT. Derive a timeout context from the parent.
ctx, cancel := context.WithTimeout(parentCtx, 1*time.Second)
defer cancel()
// ... use ctx
}
Testing code that uses context is straightforward. You create a context in your test, cancel it, and see if your function behaves correctly.
func TestMyFunctionRespectsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel it immediately.
err := myLongRunningFunction(ctx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("Expected context.Canceled error, got %v", err)
}
}
For database operations, most drivers now support context. This lets you cancel a long-running query, which is vital for both responsiveness and graceful shutdown.
func getUserByID(ctx context.Context, db *sql.DB, id int) (*User, error) {
var user User
// QueryRowContext will stop if ctx is done.
row := db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id)
err := row.Scan(&user.ID, &user.Name)
if err != nil {
return nil, err
}
return &user, nil
}
Finally, let’s talk about shutting down a server gracefully. When your application receives a shutdown signal (like Ctrl+C or a systemd stop), you want to stop accepting new requests but allow current ones to finish—up to a point.
func runServer(ctx context.Context, srv *http.Server) error {
// Listen for the application-level shutdown signal.
go func() {
<-ctx.Done() // This will fire when the parent wants to shut down.
log.Println("Starting graceful shutdown...")
// Give existing requests 10 seconds to finish.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("Forced shutdown: %v", err)
}
}()
// Start the server. This will return when Shutdown is called.
return srv.ListenAndServe()
}
// In main:
func main() {
srv := &http.Server{Addr: ":8080", Handler: myHandler}
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := runServer(rootCtx, srv); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
log.Println("Server exited cleanly.")
}
In this pattern, signal.NotifyContext gives us a root context that cancels when an interrupt signal arrives. We pass that to runServer. When the signal comes, the server stops accepting new connections and waits a bit for handlers to finish.
These patterns—passing the context, setting deadlines, storing values, coordinating groups, and managing shutdowns—form a toolkit for building robust Go applications. They help you write programs that are respectful of system resources and user time. Start by adding ctx context.Context as the first argument to your functions, and you’ll find a natural way to integrate these ideas.