Understanding The Context Package: Managing Request-scoped Data, Deadlines, And Cancellation With The Context Package

Overview

The context package in Go is essential for managing request-scoped data, timeouts, and cancellations across various parts of your application. This chapter provides an in-depth look into how Go’s context package helps simplify managing concurrent operations. You’ll learn how to propagate values like user data across goroutines, handle timeouts to ensure efficient resource management, and gracefully cancel operations when required. By the end, you’ll understand how to avoid common pitfalls and implement the context package to build robust, scalable Go applications.

Context

Writing concurrent code has many challenges. This chapter will deal specifically with the challenges around:

  • Sharing common (contextual) values across concurrent routines
  • Canceling concurrent processes
  • Timing out concurrent processes

The Problem

To illustrate the difficulties of writing solid concurrent code, we will build a service that queries a database.

The problems we need to solve for are:

  • Graceful shutdown of all go routines if the service is told to shut down:
    • DB Queries
  • Cancel all DB actions related to a specific request
  • Share values across concurrent requests such as Server ID, User ID, Request ID.

While most of these issues can be solved with concurrency primitives, it requires a lot of planning, and in general, is quite difficult and tedious.

Because of this, the context package was introduced in Go 1.7 to solve for these exact scenarios.

The Context Interface

Context can solve for all of the problems described in our problem scenario.

Instead of a concrete type, Context is defined as an interface in Go, and consists of just four methods:

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

For most general scenarios, the context package has default implementations for almost all use cases. You can, however, create your own context as well for more specific situations. Projects such as Buffalo do this to make it even easier to work with their framework.

Boilerplate Code

To start with, let’s look at a database query. We’ll use an in-memory sqlite database for this example.

Here is our main section:


db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
	log.Fatal(err)
}

// Load some sample data
if err := load(db); err != nil {
	log.Fatal(err)
}

// Get all users
users, err := GetUsers(db)
if err != nil {
	log.Fatal(db)
}
for _, u := range users {
	fmt.Printf("%+v\n", u)
}

Here is the code to retrieve the users:


type User struct {
	ID   int
	Name string
}

func GetUsers(db *sql.DB) ([]User, error) {
	rows, err := db.Query("select * from users;")
	if err != nil {
		return nil, err
	}

	users := []User{}

	for rows.Next() {
		var u User
		if err := rows.Scan(&u.ID, &u.Name); err != nil {
			return nil, err
		}
		users = append(users, u)
	}
	return users, nil
}

Output:


{ID:1 Name:Wilma}
{ID:2 Name:Fred}
{ID:3 Name:Betty}
{ID:4 Name:Barney}

Goroutines

In the previous code, it may not be obvious, but the call to db.Query is just a wrapper for db.QueryContext. By doing this, the db package is now able to work with all subsequent calls with a context present. This allows for specific drivers to be able to use context later for cancellation.

It is worth noting that not all database drivers use context in the same manner. For instance, a call to postgres with context being canceled is handled differently than calling sqlite with the in-memory setup. While it’s not important to understand the exact differences, what is important is to understand they all observe the context for cancellation.

Creating A Context

All contexts start with a context.Background() context. This is just an “empty” context onto which we can create new “child” contexts.

Contexts behave like trees with a top node, context.Background(), and then further sub-nodes.

To create a background context, you can use the following code:

ctx := context.Background()

Context.Background Vs Context.TODO

The context package provides two functions for creating empty contexts:

  • context.Background() - Use this when you have a clear understanding of what context to use. This is the most common starting point for creating context trees. Use it in main(), initialization, tests, and as the top-level context for incoming requests.

  • context.TODO() - Use this as a placeholder when you’re unsure which context to use or when the surrounding function has not been updated to accept a context parameter yet. This signals to readers (and linters) that the context usage is incomplete and needs to be addressed.

When to use each:

// ✓ Good: Use Background() in main
func main() {
    ctx := context.Background()
    // ...
}

// ✓ Good: Use TODO() when refactoring
func legacyFunction() {
    // TODO: Update signature to accept context parameter
    ctx := context.TODO()
    doWork(ctx)
}

Best Practice: If you see context.TODO() in production code during a review, it’s a signal that the code needs refactoring to properly propagate a context from the caller.

Db.QueryContext

Now that we understand that db.Query is going to call db.QueryContext, we can swap out to use db.QueryContext and provide our own context to have more control in the future.

Here is the code using our own context. Note that the behavior has not changed yet, but will allow us to incorporate future changes for handling timeouts and cancellation.


type User struct {
	ID   int
	Name string
}

func GetUsers(db *sql.DB) ([]User, error) {
	// we expect someone to give us this context in the future
	ctx := context.TODO()

	// pass in our context
	rows, err := db.QueryContext(ctx, "select * from users;")
	if err != nil {
		return nil, err
	}

	users := []User{}

	for rows.Next() {
		var u User
		if err := rows.Scan(&u.ID, &u.Name); err != nil {
			return nil, err
		}
		users = append(users, u)
	}
	return users, nil
}

In future revisions, we’ll create the context (or derive the context) from an earlier point in the code, likely refactoring GetUsers to take a context as an argument.

Note: In concurrent programming, it is very common for most calls to take a context as the first argument, resulting in almost all function signatures looking like this:

func SomeFunction(ctx context.Context, ...)

For instance, GetUsers would become:

func GetUsers(ctx context.Context, db *sql.DB) ([]User, error)

Cancellation

While the above code works fine for basic scenarios. What happens if we want to:

  • cancel the exiting query?
  • need the query to return within a specific deadline, and cancel if it exceeds that deadline?

Context was built with that in mind. In fact, one of the most useful, and common, uses for context.Context is cancellation. Because goroutines are asynchronous in nature, we are also able to use many of the concurrency patterns in conjunction with context. We’ll see this as we continue to modify our project.

Canceling With Contexts

There are two primary ways to cancel with a context. The first is to use a cancel function, and the second is to set up the context with a timeout that will automatically call the cancel function. Determining which method to use will be specific to the use case you are solving for in your program.

To create a cancelable context, you can use context.WithCancel. Because each context attaches to an existing context, we also need to provide an existing context. In most cases, you’ll start with a context.Background when creating your first cancelable context:

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

WithCancel returns a copy of parent with a new Done channel. The returned context’s Done channel is closed when the returned cancel function is called or when the parent context’s Done channel is closed, whichever happens first.

Because a context has internal resources that are not freed until the context is canceled, it is common to defer the cancel function. This is safe because calling cancel is an idempotent operation. This means that any subsequent call to cancel will do nothing, thus having no adverse affect on your program.

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

Using WithCancel

When the cancel function is a called a message is sent to the ctx.Done() channel that we can listen to and then stop our goroutines safely.


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

	// do some work asynchronously
	go func() {
		fmt.Println("doing some work...")
		// call the cancel function when done working
		time.Sleep(2 * time.Second)
		fmt.Println("finished with work")
		cancel()
	}()

	// code waits for context to finish via `cancel` function
	<-ctx.Done()
	fmt.Println("context finished")

	// Check the error
	err := ctx.Err()
	if err != context.Canceled {
		log.Fatal(err)
	}
}

Output:


doing some work...
finished with work
context finished

Canceling With Timeouts

Context also provides two methods to allow cancellation of a process within a specified amount of time:

context.WithTimeout
context.WithDeadline

Both do almost the same thing. WithTimeout will cancel the context after a certain amount of time. WithDeadline will cancel the context at a certain time.

They also both provide a cancel function so you can cancel earlier than the timeout if desired.

WithTimeout Vs WithDeadline: When To Use Each

While both achieve automatic cancellation, choosing between them depends on your use case:

Use WithTimeout when you care about duration:

// Cancel after 5 seconds from now
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

Use WithDeadline when you care about a specific time:

// Cancel at exactly 3:00 PM
deadline := time.Date(2025, 1, 15, 15, 0, 0, 0, time.UTC)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()

Common patterns:

  • API calls, database queries: Use WithTimeout - “this operation should complete within 500ms”
  • Batch jobs, scheduled tasks: Use WithDeadline - “this job must finish by midnight”
  • Request handling: Use WithTimeout - “process this request within 30 seconds”
  • Time-based coordination: Use WithDeadline - “all workers must stop at 5:00 PM”

Note: WithTimeout is actually implemented using WithDeadline internally:

// Simplified implementation
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
    return WithDeadline(parent, time.Now().Add(timeout))
}

Using WithTimeout

By using WithTimeout we can ensure we finish within a specified time.

Notice the only line of code that changed was the line in which we created the context. The rest of the code did not need to change at all to observe this new deadline.


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

	// do some work asynchronously
	go func() {
		fmt.Println("doing some work...")
		time.Sleep(2 * time.Second)
		fmt.Println("finished with work")
		// call the cancel function when done working
		cancel()
	}()

	// code waits for context to finish via `cancel` function
	<-ctx.Done()
	fmt.Println("context finished")

	// Check the error
	err := ctx.Err()
	if err != context.Canceled {
		log.Fatal(err)
	}
}

Output:


doing some work...
context finished
2021/02/08 13:30:38 context deadline exceeded
exit status 1

Notice that we get an error? This is because we specifically timed out before our process could finish.

Context Errors

There are two specific errors that the context package can return. Depending on the needs of your program, they can be useful in making decisions. They are both returned from the Context.Err method.

From the documentation:

Canceled is the error returned by Context.Err when the context is canceled.

var Canceled = errors.New("context canceled")

DeadlineExceeded is the error returned by Context.Err when the context’s deadline passes.

var DeadlineExceeded error = deadlineExceededError{}

If Context.Err() returns nil, this means that the context was neither canceled nor timed out.

Cancellation With A Cause

Context.Err() only tells you that a context was canceled, not why. In a system with multiple cancellation paths, knowing the reason is important for logging, metrics, and error handling.

context.WithCancelCause works like WithCancel but lets you attach a specific error as the cancellation reason. context.Cause(ctx) returns the error passed to cancel. If the context was canceled with a regular CancelFunc (no cause), Cause returns the same value as ctx.Err().


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

	cancel(fmt.Errorf("database connection lost"))

	fmt.Println("ctx.Err():        ", ctx.Err())
	fmt.Println("context.Cause():  ", context.Cause(ctx))
}

Output:


/*
ctx.Err():         context canceled
context.Cause():   database connection lost
*/

WithTimeoutCause And WithDeadlineCause

The same pattern extends to timeouts and deadlines. By default, when a timeout expires, context.Cause returns context.DeadlineExceeded, which tells you nothing about which timeout fired. WithTimeoutCause and WithDeadlineCause let you attach a specific error so you know exactly which layer timed out.


func main() {
	// Without cause
	ctx1, cancel1 := context.WithTimeout(context.Background(), 1*time.Millisecond)
	defer cancel1()
	<-ctx1.Done()
	fmt.Println("WithTimeout:")
	fmt.Println("  ctx.Err():        ", ctx1.Err())
	fmt.Println("  context.Cause():  ", context.Cause(ctx1))

	// With cause
	ctx2, cancel2 := context.WithTimeoutCause(context.Background(), 1*time.Millisecond,
		fmt.Errorf("database query exceeded limit"))
	defer cancel2()
	<-ctx2.Done()
	fmt.Println("\nWithTimeoutCause:")
	fmt.Println("  ctx.Err():        ", ctx2.Err())
	fmt.Println("  context.Cause():  ", context.Cause(ctx2))
}

Output:


/*
WithTimeout:
  ctx.Err():         context deadline exceeded
  context.Cause():   context deadline exceeded

WithTimeoutCause:
  ctx.Err():         context deadline exceeded
  context.Cause():   database query exceeded limit
*/

Updating Our Program

Now that we have a better understanding of Context, we can make some improvements in our program.

We’ll make the following changes:

  1. Create a top level context that the entire program will use.
  2. Derive all new contexts from our top level context.
  3. Create timeouts where appropriate with our contexts.

Main

In the main function, we’ll create a top level context with a cancel function. We’ll pass this context to all other calls.

We’ll also run our code in a goroutine. This will allow us to add a future timeout to the entire program if we desired with only one line of code.


db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
	log.Fatal(err)
}

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

// Load some sample data
if err := load(ctx, db); err != nil {
	log.Fatal(err)
}

// Get all users
users, err := GetUsers(ctx, db)
if err != nil {
	log.Fatal(err)
}
for _, u := range users {
	fmt.Printf("%+v\n", u)
}

Load

The load function now takes a context. As such, we want to make sure we will return early if our parent context is canceled:


func load(ctx context.Context, db *sql.DB) error {
	_, err := db.ExecContext(ctx,
		`
		create table users(id int, name text);
		insert into users(id, name) values (1, 'Wilma'), (2, 'Fred'), (3, 'Betty'), (4, 'Barney');
		`,
	)
	return err
}

GetUsers


type User struct {
	ID   int
	Name string
}

func GetUsers(ctx context.Context, db *sql.DB) ([]User, error) {
	// Put a timeout on our query
	ctx, cancel := context.WithTimeout(ctx, time.Second)
	defer cancel()

	// create an error channel to return any errors in our goroutine
	ec := make(chan error, 1)

	// create a channel to retrieve our result
	result := make(chan []User, 1)

	// launch the code in a go routine
	go func() {
		// pass in our context
		rows, err := db.QueryContext(ctx, "select * from users;")
		if err != nil {
			ec <- err
			return
		}

		users := []User{}
		for rows.Next() {
			var u User
			if err := rows.Scan(&u.ID, &u.Name); err != nil {
				ec <- err
				return
			}
			users = append(users, u)
		}
		result <- users
	}()

	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	case err := <-ec:
		return nil, err
	case u := <-result:
		return u, nil
	}
}

Finished Program

Here is the entire set of code, now written to make use of the context package:


package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"time"

	_ "github.com/mattn/go-sqlite3"
)

func main() {
	db, err := sql.Open("sqlite3", ":memory:")
	if err != nil {
		log.Fatal(err)
	}

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

	// Load some sample data
	if err := load(ctx, db); err != nil {
		log.Fatal(err)
	}

	// Get all users
	users, err := GetUsers(ctx, db)
	if err != nil {
		log.Fatal(err)
	}
	for _, u := range users {
		fmt.Printf("%+v\n", u)
	}
}

func load(ctx context.Context, db *sql.DB) error {
	_, err := db.ExecContext(ctx,
		`
		create table users(id int, name text);
		insert into users(id, name) values (1, 'Wilma'), (2, 'Fred'), (3, 'Betty'), (4, 'Barney');
		`,
	)
	return err
}


type User struct {
	ID   int
	Name string
}

func GetUsers(ctx context.Context, db *sql.DB) ([]User, error) {
	// Put a timeout on our query
	ctx, cancel := context.WithTimeout(ctx, time.Second)
	defer cancel()

	// create an error channel to return any errors in our goroutine
	ec := make(chan error, 1)

	// create a channel to retrieve our result
	result := make(chan []User, 1)

	// launch the code in a go routine
	go func() {
		// pass in our context
		rows, err := db.QueryContext(ctx, "select * from users;")
		if err != nil {
			ec <- err
			return
		}

		users := []User{}
		for rows.Next() {
			var u User
			if err := rows.Scan(&u.ID, &u.Name); err != nil {
				ec <- err
				return
			}
			users = append(users, u)
		}
		result <- users
	}()

	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	case err := <-ec:
		return nil, err
	case u := <-result:
		return u, nil
	}
}

Summary

As we can see, when we introduce a context, not only do we have to observe the context, but we end up running more of our code within localized goroutines to ensure that we properly observe any context timeouts or cancellations that may occur upstream. This is an important pattern with concurrent programming and context.

While this approach may seem tedious at first, it will offer the greatest flexibility long term as you implement SLA’s for future performance.

Production Scenarios

While the example we just looked at only had really three layers of context:

  • Main routine
  • User Lookup (GetUsers)
  • DB Query

It’s common to have even more layers. For example, you may have a RESTful web service, in which case the same context would have the following layers it passes through:

  • Main Routine
  • HTTP Service Routine
  • HTTP Handler Routine
  • User Lookup (GetUsers)
  • DB Query

Each level may cause a timeout/deadline to occur. Each level may need it’s own ability to cancel.

Using a context allows this type of program to share values, cancellation, and deadlines through each layer.

As a bonus, you now have the ability to cancel the top level context, and have all layers and services shut down gracefully.

Signal-Based Cancellation With Os/signal

One of the most common production patterns is gracefully shutting down your application when receiving OS signals (like SIGINT from Ctrl+C or SIGTERM from container orchestrators).

Go 1.16 introduced signal.NotifyContext, which elegantly combines signal handling with context cancellation.

Signal.NotifyContext

Before signal.NotifyContext, signal handling required manual channel management:

Old Pattern (Pre-Go 1.16):

// ❌ Old: Manual signal handling
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)

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

go func() {
    <-sigCh
    cancel()
}()

Modern Pattern (Go 1.16+):

// ✓ Good: Using signal.NotifyContext
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

The stop function de-registers the signal handler and releases resources. Always defer it to prevent resource leaks.

Graceful Shutdown Pattern

Here’s a complete pattern for gracefully shutting down an HTTP server:


package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	// Create a context that will be canceled on SIGINT or SIGTERM
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	// Create HTTP server
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		// Simulate some work
		time.Sleep(2 * time.Second)
		fmt.Fprintf(w, "Request completed at %s\n", time.Now().Format(time.RFC3339))
	})

	srv := &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}

	serverErrors := make(chan error, 1)

	// Start server in a goroutine
	go func() {
		log.Println("Server starting on :8080")
		serverErrors <- srv.ListenAndServe()
	}()

	select {
	case <-ctx.Done():
		log.Println("Shutdown signal received")
	case err := <-serverErrors:
		if err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Printf("Server exited with error: %v", err)
		}
		return
	}

	// Stop receiving any more signals (allow force quit on second Ctrl+C)
	stop()

	// Create a context with timeout for the shutdown phase
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	log.Println("Gracefully shutting down server (30s timeout)...")

	// Attempt graceful shutdown
	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("Server forced to shutdown: %v", err)
		return
	}

	if err := <-serverErrors; err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Printf("Server exited with error after shutdown: %v", err)
		return
	}

	log.Println("Server stopped gracefully")
}

Key points:

  1. Primary context from signal.NotifyContext - Triggers on OS signals
  2. Shutdown timeout - Separate context for the shutdown phase (e.g., 30 seconds to drain connections)
  3. Two-phase shutdown:
    • Phase 1: Stop accepting new requests
    • Phase 2: Complete in-flight requests within timeout
  4. Error handling - Distinguish between clean shutdown and errors

Signal Propagation In Services

In production services, you typically want to:

  1. Create a signal-aware context at the application root
  2. Pass this context to all long-running operations
  3. Each operation observes ctx.Done() for cancellation

Example with multiple workers:


package main

import (
	"context"
	"log"
	"os"
	"os/signal"
	"sync"
	"syscall"
	"time"
)


func main() {
	// Create signal-aware context at application root
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	log.Println("Starting application with 3 workers")
	log.Println("Press Ctrl+C to trigger graceful shutdown")

	var wg sync.WaitGroup

	// Start multiple workers that all observe the same context
	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(ctx, &wg, i)
	}

	// Wait for all workers to complete
	wg.Wait()
	log.Println("All workers stopped, application exiting")
}

func worker(ctx context.Context, wg *sync.WaitGroup, id int) {
	defer wg.Done()

	log.Printf("Worker %d: Started", id)

	ticker := time.NewTicker(1 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			// Context canceled due to signal
			log.Printf("Worker %d: Received shutdown signal, cleaning up...", id)

			// Simulate cleanup work
			time.Sleep(500 * time.Millisecond)

			log.Printf("Worker %d: Stopped", id)
			return

		case t := <-ticker.C:
			// Normal work
			log.Printf("Worker %d: Working at %s", id, t.Format("15:04:05"))
		}
	}
}

/*
2025/01/15 10:30:00 Starting application with 3 workers
2025/01/15 10:30:00 Press Ctrl+C to trigger graceful shutdown
2025/01/15 10:30:00 Worker 1: Started
2025/01/15 10:30:00 Worker 2: Started
2025/01/15 10:30:00 Worker 3: Started
2025/01/15 10:30:01 Worker 1: Working at 10:30:01
2025/01/15 10:30:01 Worker 2: Working at 10:30:01
2025/01/15 10:30:01 Worker 3: Working at 10:30:01
2025/01/15 10:30:02 Worker 1: Working at 10:30:02
2025/01/15 10:30:02 Worker 2: Working at 10:30:02
2025/01/15 10:30:02 Worker 3: Working at 10:30:02
^C
2025/01/15 10:30:03 Worker 2: Received shutdown signal, cleaning up...
2025/01/15 10:30:03 Worker 1: Received shutdown signal, cleaning up...
2025/01/15 10:30:03 Worker 3: Received shutdown signal, cleaning up...
2025/01/15 10:30:03 Worker 2: Stopped
2025/01/15 10:30:03 Worker 1: Stopped
2025/01/15 10:30:03 Worker 3: Stopped
2025/01/15 10:30:03 All workers stopped, application exiting
*/

Output when pressing Ctrl+C:


/*
2025/01/15 10:30:00 Starting application with 3 workers
2025/01/15 10:30:00 Press Ctrl+C to trigger graceful shutdown
2025/01/15 10:30:00 Worker 1: Started
2025/01/15 10:30:00 Worker 2: Started
2025/01/15 10:30:00 Worker 3: Started
2025/01/15 10:30:01 Worker 1: Working at 10:30:01
2025/01/15 10:30:01 Worker 2: Working at 10:30:01
2025/01/15 10:30:01 Worker 3: Working at 10:30:01
2025/01/15 10:30:02 Worker 1: Working at 10:30:02
2025/01/15 10:30:02 Worker 2: Working at 10:30:02
2025/01/15 10:30:02 Worker 3: Working at 10:30:02
^C
2025/01/15 10:30:03 Worker 2: Received shutdown signal, cleaning up...
2025/01/15 10:30:03 Worker 1: Received shutdown signal, cleaning up...
2025/01/15 10:30:03 Worker 3: Received shutdown signal, cleaning up...
2025/01/15 10:30:03 Worker 2: Stopped
2025/01/15 10:30:03 Worker 1: Stopped
2025/01/15 10:30:03 Worker 3: Stopped
2025/01/15 10:30:03 All workers stopped, application exiting
*/

All workers receive the cancellation signal and shut down gracefully.

Production Best Practices

1. Always use a shutdown timeout:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

<-ctx.Done() // Wait for signal

// Create a separate timeout context for shutdown
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

if err := srv.Shutdown(shutdownCtx); err != nil {
    log.Printf("Server shutdown failed: %v", err)
}

2. Handle multiple shutdown signals:

// First signal: graceful shutdown
// Second signal: force exit
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

<-ctx.Done()
log.Println("Shutting down gracefully, press Ctrl+C again to force")

stop() // De-register so second signal works normally

3. Coordinate multiple services:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

// Start all services with the same context
g, ctx := errgroup.WithContext(ctx)

g.Go(func() error {
    if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
        return err
    }
    return nil
})
g.Go(func() error { return grpcServer.Serve(grpcListener) })
g.Go(func() error { return messageConsumer.Run(ctx) })
g.Go(func() error {
    <-ctx.Done()

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    _ = httpServer.Shutdown(shutdownCtx)
    grpcServer.GracefulStop()
    messageConsumer.Stop()

    return ctx.Err()
})

// Wait for signal or any service to fail
if err := g.Wait(); err != nil && !errors.Is(err, context.Canceled) {
    log.Fatalf("Service error: %v", err)
}

When To Use Signal.NotifyContext

✓ Use it for:

  • Application-level shutdown coordination
  • Server lifecycle management
  • Long-running background workers
  • Batch processing jobs that should be interruptible

✗ Don’t use it for:

  • Request-scoped contexts (use http.Request.Context())
  • Short-lived operations (use WithTimeout instead)
  • Library code (let the caller control signal handling)

Best Practice: Create the signal context once in main() and derive all other contexts from it. This ensures a single signal can cascade through your entire application.

Code Walkthrough (Optional)

Picking up where we left off with the concurrent solution, lets see how we could use context in place of some channels:

Note: Run the program with go run main.go "$(< sites.txt)" to see the results.


package main

import (
	"fmt"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

// create a custom type to receive our responses
type resp struct {
	site     string
	duration time.Duration
	err      error
}

func main() {
	// Load sites from a file
	args := os.Args[1:]
	// Args might be a single string with line returns to delimit sites, break them apart
	if len(args) == 1 {
		args = strings.Split(args[0], "\n")
	}

	// Create a channel to send all sites to
	// We'll create it buffered so that we throttle how much many go routines will be processing at any given time
	sites := make(chan string, 30)

	// create an unbuffered channel to receive on
	// because we don't have contention or latency on the end of our
	// process, there is no need to buffer this channel
	responses := make(chan resp)

	now := time.Now()

	// launch the same number of go routines that we have a buffer size for
	for i := 0; i < cap(sites); i++ {
		go get(sites, responses)
	}

	// feed all of our data into the send channel
	// this channel will block when it's full, and unblock
	// when space becomes available
	// We will do this in a goroutine so we don't block execution
	go func() {
		for _, s := range args {
			sites <- s
		}
		// ensure that any open go routines close down
		close(sites)
	}()

	// read back our responses
	for i := 0; i < len(args); i++ {
		r := <-responses
		if r.err != nil {
			log.Printf("failed to retrieve %s: %s\n", r.site, r.err)
			continue
		}
		log.Printf("Retrieved site %s in %s\n", r.site, r.duration)
	}
	fmt.Printf("It took %s for the entire process to finish\n", time.Since(now))
}

func get(sites chan string, responses chan resp) {
	// range through the receiving channel.  When this channel is closed
	// all go routines will terminate
	for s := range sites {
		now := time.Now()

		// pick a magic timeout duration
		td := 2 * time.Second
		// create a timeout.
		timeout := time.NewTimer(td)
		// this ensures we don't leak a go routine
		defer timeout.Stop()

		// create a channel to to receive the response on locally
		// make sure it's buffered so you don't leak a goroutine
		work := make(chan resp, 1)

		// retrieve the site in a goroutine to avoid blocking
		go func() {
			// retrieve the website
			_, err := http.Get(s)
			work <- resp{site: s, duration: time.Since(now), err: err}
		}()

		select {
		case r := <-work:
			// retrieve the value from our local function and pass it through to our send channel
			responses <- r
		case <-timeout.C:
			responses <- resp{site: s, err: fmt.Errorf("timed out after %s", td)}
		}
	}
}

Adding A Context

Adding a context makes the timeout a little easier in the get function.

Note: Run the program with go run main.go "$(< sites.txt)" to see the results.


package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

// create a custom type to receive our responses
type resp struct {
	site     string
	duration time.Duration
	err      error
}

func main() {
	// Load sites from a file
	args := os.Args[1:]
	// Args might be a single string with line returns to delimit sites, break them apart
	if len(args) == 1 {
		args = strings.Split(args[0], "\n")
	}

	// Create a channel to send all sites to
	// We'll create it buffered so that we throttle how much many go routines will be processing at any given time
	sites := make(chan string, 30)

	// create an unbuffered channel to receive on
	// because we don't have contention or latency on the end of our
	// process, there is no need to buffer this channel
	responses := make(chan resp)

	now := time.Now()

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

	// launch the same number of go routines that we have a buffer size for
	for i := 0; i < cap(sites); i++ {
		go get(ctx, sites, responses)
	}

	// feed all of our data into the send channel
	// this channel will block when it's full, and unblock
	// when space becomes available
	// We will do this in a goroutine so we don't block execution
	go func() {
		for _, s := range args {
			sites <- s
		}
		// ensure that any open go routines close down
		close(sites)
	}()

	// launch the reads in a goroutine to not block context cancellation
	go func() {
		// signal program has finished
		defer cancel()
		// read back our responses
		for i := 0; i < len(args); i++ {
			r := <-responses
			if r.err != nil {
				log.Printf("failed to retrieve %s: %s\n", r.site, r.err)
				continue
			}
			log.Printf("Retrieved site %s in %s\n", r.site, r.duration)
		}
	}()

	// wait for program to finish
	<-ctx.Done()
	if err := ctx.Err(); err != context.Canceled {
		fmt.Printf("\nprogram timed out: %s\n", err)
		return
	}
	fmt.Printf("It took %s for the entire process to finish\n", time.Since(now))
}

func get(ctx context.Context, sites chan string, responses chan resp) {
	// range through the receiving channel.  When this channel is closed
	// all go routines will terminate
	for s := range sites {
		now := time.Now()

		// pick a magic timeout duration
		td := 2 * time.Second
		// create a timeout.
		ctx, cancel := context.WithTimeout(ctx, td)
		defer cancel()

		// create a channel to to receive the response on locally
		// make sure it's buffered so you don't leak a goroutine
		work := make(chan resp, 1)

		// retrieve the site in a goroutine to avoid blocking
		go func() {
			// retrieve the website
			_, err := http.Get(s)
			work <- resp{site: s, duration: time.Since(now), err: err}
		}()

		select {
		case r := <-work:
			// retrieve the value from our local function and pass it through to our send channel
			responses <- r
		case <-ctx.Done():
			responses <- resp{site: s, err: fmt.Errorf("timed out after %s", td)}
		}
	}
}

Adding Program Timeout

Now that we have a context in place, we can also tell the program we want to end after 10 seconds for the entire program.

Note: Run the program with go run main.go "$(< sites.txt)" to see the results.


package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

// create a custom type to receive our responses
type resp struct {
	site     string
	duration time.Duration
	err      error
}

func main() {
	// Load sites from a file
	args := os.Args[1:]
	// Args might be a single string with line returns to delimit sites, break them apart
	if len(args) == 1 {
		args = strings.Split(args[0], "\n")
	}

	// Create a channel to send all sites to
	// We'll create it buffered so that we throttle how much many go routines will be processing at any given time
	sites := make(chan string, 30)

	// create an unbuffered channel to receive on
	// because we don't have contention or latency on the end of our
	// process, there is no need to buffer this channel
	responses := make(chan resp)

	now := time.Now()

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

	// launch the same number of go routines that we have a buffer size for
	for i := 0; i < cap(sites); i++ {
		go get(ctx, sites, responses)
	}

	// feed all of our data into the send channel
	// this channel will block when it's full, and unblock
	// when space becomes available
	// We will do this in a goroutine so we don't block execution
	go func() {
		for _, s := range args {
			sites <- s
		}
		// ensure that any open go routines close down
		close(sites)
	}()

	// launch the reads in a goroutine to not block context cancellation
	go func() {
		// signal program has finished
		defer cancel()
		// read back our responses
		for i := 0; i < len(args); i++ {
			r := <-responses
			if r.err != nil {
				log.Printf("failed to retrieve %s: %s\n", r.site, r.err)
				continue
			}
			log.Printf("Retrieved site %s in %s\n", r.site, r.duration)
		}
	}()

	// wait for program to finish
	<-ctx.Done()
	if err := ctx.Err(); err != context.Canceled {
		fmt.Printf("\nprogram timed out: %s\n", err)
		return
	}
	fmt.Printf("It took %s for the entire process to finish\n", time.Since(now))
}

func get(ctx context.Context, sites chan string, responses chan resp) {
	// range through the receiving channel.  When this channel is closed
	// all go routines will terminate
	for s := range sites {
		// create a channel to to receive the response on locally
		// make sure it's buffered so you don't leak a goroutine
		work := make(chan resp, 1)

		// pick a magic timeout duration
		td := 2 * time.Second
		// create a timeout.
		ctx, cancel := context.WithTimeout(ctx, td)
		defer cancel()

		now := time.Now()

		// retrieve the site in a goroutine to avoid blocking
		go func() {
			// retrieve the website
			_, err := http.Get(s)
			work <- resp{site: s, duration: time.Since(now), err: err}
		}()

		select {
		case r := <-work:
			// retrieve the value from our local function and pass it through to our send channel
			responses <- r
		case <-ctx.Done():
			responses <- resp{site: s, err: fmt.Errorf("timed out after %s", td)}
		}
	}
}

WithoutCancel

Sometimes you need to perform cleanup operations that must complete even after the parent context is canceled. Common examples include:

  • Logging - Recording metrics or errors after a request is canceled
  • Metrics collection - Sending telemetry data regardless of request outcome
  • Cleanup operations - Releasing resources or notifying external systems

context.WithoutCancel returns a copy of the parent context that is never canceled when the parent is canceled. The returned context has no deadline and returns nil for Err() and Done().

Basic Usage

// Parent context gets canceled
ctx, cancel := context.WithCancel(context.Background())
cancel()

// Create a context that won't be canceled
cleanupCtx := context.WithoutCancel(ctx)

// cleanupCtx.Done() returns nil - it will never be canceled
// cleanupCtx.Err() returns nil - no error

Practical Example: Metrics After Cancellation

Your context often carries request-scoped values like request IDs and trace IDs that your metrics system needs. When the request is canceled, you still want to send metrics with those values. WithoutCancel preserves the values while preventing the canceled context from aborting your metrics call.


func handleRequest(ctx context.Context, metricsURL string) {
	defer sendMetrics(ctx, metricsURL)

	reqID := ctx.Value(requestIDKey).(string)
	fmt.Printf("[%s] processing request...\n", reqID)

	select {
	case <-time.After(500 * time.Millisecond):
		fmt.Printf("[%s] request completed\n", reqID)
	case <-ctx.Done():
		fmt.Printf("[%s] request canceled: %v\n", reqID, ctx.Err())
	}
}

func sendMetrics(ctx context.Context, metricsURL string) {
	// WithoutCancel preserves values but strips both
	// cancellation and the parent's deadline. Add a
	// timeout so the HTTP call below won't hang forever.
	logCtx := context.WithoutCancel(ctx)
	logCtx, cancel := context.WithTimeout(logCtx, 2*time.Second)
	defer cancel()

	reqID := logCtx.Value(requestIDKey).(string)
	fmt.Printf("[%s] sending metrics...\n", reqID)

	req, _ := http.NewRequestWithContext(logCtx, "POST", metricsURL, nil)
	req.Header.Set("X-Request-ID", reqID)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		fmt.Printf("[%s] metrics failed: %v\n", reqID, err)
		return
	}
	resp.Body.Close()
	fmt.Printf("[%s] metrics sent (status %d)\n", reqID, resp.StatusCode)
}

Output:


/*
[req-abc-123] processing request...
[req-abc-123] request canceled: context canceled
[req-abc-123] sending metrics...
[server] received metrics from req-abc-123
[req-abc-123] metrics sent (status 200)
*/

WithoutCancel strips both cancellation and the parent’s deadline. That means any downstream call using this context (HTTP, database, gRPC) would have no time limit at all. sendMetrics adds its own timeout so those calls won’t hang forever if the metrics service is unreachable.

Important Caveats

1. Add a timeout if you propagate the context downstream:

WithoutCancel strips both cancellation and the parent’s deadline. If you pass the context to a downstream call (HTTP, database, gRPC), that call will have no time limit. Add your own timeout so it can’t hang forever.

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

If you’re only reading values from the context and not passing it to another call, the timeout doesn’t matter.

2. Use sparingly:

WithoutCancel is for exceptional cases like cleanup, metrics, and logging. Normal application code should respect parent context cancellation.

AfterFunc

context.AfterFunc registers a callback to run in its own goroutine when a context is canceled. It returns a stop function that can prevent the callback from running if called before cancellation.

The problem it solves: Before AfterFunc, reacting to context cancellation during a blocking operation required spawning a goroutine and allocating channels on every call, even though cancellation almost never happens. AfterFunc only spawns a goroutine when cancellation actually fires.

Common usage patterns in production Go code:

  • Cancelling blocking I/O — Go’s own net package uses AfterFunc internally to interrupt TCP dials by setting a write deadline when the context is canceled. crypto/tls uses it to close connections during a canceled TLS handshake.
  • Context merging — Cancel when either of two independent contexts cancels. A server handler gets a request context but also needs to cancel when the server shuts down. Projects like Milvus, Temporal, and StackRox use this pattern.
  • Resource cleanup — “When this context dies, close this resource.” Used by quic-go, Tailscale, and Docker for closing connections, loggers, and subscriptions tied to a context’s lifetime.
  • Waking sync.Cond — Call cond.Broadcast() when the context is canceled to unblock goroutines waiting on a condition variable. Used by Cilium’s promise implementation.

AfterFunc is a building block for systems and infrastructure code. For typical application code, listening on ctx.Done() in a select statement is simpler and more readable.

For details and examples, see the context package documentation.

Exercise (20m)

The following code retrieves book information from an open API. It currently uses timers to control timeout.

Rewrite the code using contexts to observe the same timeouts.

You can download the source code using the button in the left navigation.

main.go:


package main

import (
	"fmt"
	"log"
	"time"
	"training/api"
)

func main() {
	isbns := []string{
		"1788626540",
		"178712598X",
		"1838643575",
		"0672338033",
		"1788475275",
		"1788622596",
		"1788294289",
		"1788627911",
		"1838554491",
	}

	timer := time.NewTimer(time.Second * 30)
	defer timer.Stop()

	results := api.Books(isbns)
	for i := 0; i < len(isbns); i++ {
		select {
		case result := <-results:
			if result.Err != nil {
				log.Fatal(result.Err)
			}
			fmt.Println(result.Book.Title)
		case <-timer.C:
			log.Fatal("timeout exceeded")
		}
	}
}

api/api.go


package api

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"time"
)

type BookResult struct {
	Book Book
	Err  error
}

// Books will return a list of books based on the provided ISBN numbers
func Books(isbns []string) <-chan BookResult {
	br := make(chan BookResult, len(isbns))

	for _, isbn := range isbns {
		// fan out go routines so they all request concurrently
		go func(isbn string) {
			// set a timeout for our book retrievals
			timer := time.NewTimer(time.Second * 10)
			defer timer.Stop()

			work := make(chan BookResult, 1)

			// don't block while retrieving the book
			go func() {
				book, err := getBook(isbn)
				work <- BookResult{Book: book, Err: err}
			}()

			select {
			case result := <-work:
				br <- result
			case <-timer.C:
				br <- BookResult{Err: fmt.Errorf("timed out retrieving %q", isbn)}
			}
		}(isbn)
	}
	return br
}

// getBook retrieves the book requested via the isbn
func getBook(isbn string) (Book, error) {
	u, err := url.Parse("https://openlibrary.org/isbn/" + isbn + ".json")
	if err != nil {
		return Book{}, err
	}
	resp, err := http.Get(u.String())
	if err != nil {
		return Book{}, err
	}
	defer resp.Body.Close()
	var book Book
	if err := json.NewDecoder(resp.Body).Decode(&book); err != nil {
		return Book{}, err
	}
	return book, nil
}

api/book.go


package api

type Book struct {
	Publishers      []string        `json:"publishers"`
	NumberOfPages   int             `json:"number_of_pages"`
	Isbn10          []string        `json:"isbn_10"`
	Covers          []int           `json:"covers"`
	LastModified    LastModified    `json:"last_modified"`
	LatestRevision  int             `json:"latest_revision"`
	Key             string          `json:"key"`
	Authors         []Authors       `json:"authors"`
	Ocaid           string          `json:"ocaid"`
	Contributions   []string        `json:"contributions"`
	Languages       []Languages     `json:"languages"`
	Classifications Classifications `json:"classifications"`
	SourceRecords   []string        `json:"source_records"`
	Title           string          `json:"title"`
	Identifiers     Identifiers     `json:"identifiers"`
	Created         Created         `json:"created"`
	Isbn13          []string        `json:"isbn_13"`
	LocalID         []string        `json:"local_id"`
	PublishDate     string          `json:"publish_date"`
	Works           []Works         `json:"works"`
	Type            Type            `json:"type"`
	FirstSentence   FirstSentence   `json:"first_sentence"`
	Revision        int             `json:"revision"`
}
type LastModified struct {
	Type  string `json:"type"`
	Value string `json:"value"`
}
type Authors struct {
	Key string `json:"key"`
}
type Languages struct {
	Key string `json:"key"`
}
type Classifications struct {
}
type Identifiers struct {
	Goodreads    []string `json:"goodreads"`
	Librarything []string `json:"librarything"`
}
type Created struct {
	Type  string `json:"type"`
	Value string `json:"value"`
}
type Works struct {
	Key string `json:"key"`
}
type Type struct {
	Key string `json:"key"`
}
type FirstSentence struct {
	Type  string `json:"type"`
	Value string `json:"value"`
}

Solution

While the code didn’t change a lot, we now have future capabilities to control other timeouts/cancellation, as well as add context specific values (we’ll cover this next).

main.go:


package main

import (
	"context"
	"fmt"
	"log"
	"time"
	"training/api"
)

func main() {
	isbns := []string{
		"1788626540",
		"178712598X",
		"1838643575",
		"0672338033",
		"1788475275",
		"1788622596",
		"1788294289",
		"1788627911",
		"1838554491",
	}

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

	results := api.Books(ctx, isbns)
	for i := 0; i < len(isbns); i++ {
		select {
		case result := <-results:
			if result.Err != nil {
				log.Fatal(result.Err)
			}
			fmt.Println(result.Book.Title)
		case <-ctx.Done():
			log.Fatal(ctx.Err())
		}
	}
}

api/api.go


package api

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"time"
)

type BookResult struct {
	Book Book
	Err  error
}

// Books will return a list of books based on the provided ISBN numbers
func Books(ctx context.Context, isbns []string) <-chan BookResult {
	br := make(chan BookResult, len(isbns))

	for _, isbn := range isbns {
		// fan out go routines so they all request concurrently
		go func(isbn string) {
			// set a timeout for our book retrievals
			ctx, cancel := context.WithTimeout(ctx, time.Second*10)
			defer cancel()

			work := make(chan BookResult, 1)

			// don't block while retrieving the book
			go func() {
				book, err := getBook(isbn)
				work <- BookResult{Book: book, Err: err}
			}()

			select {
			case result := <-work:
				br <- result
			case <-ctx.Done():
				br <- BookResult{Err: fmt.Errorf("timed out retrieving %q: %w", isbn, ctx.Err())}
			}
		}(isbn)
	}
	return br
}

// getBook retrieves the book requested via the isbn
func getBook(isbn string) (Book, error) {
	u, err := url.Parse("https://openlibrary.org/isbn/" + isbn + ".json")
	if err != nil {
		return Book{}, err
	}
	resp, err := http.Get(u.String())
	if err != nil {
		return Book{}, err
	}
	defer resp.Body.Close()
	var book Book
	if err := json.NewDecoder(resp.Body).Decode(&book); err != nil {
		return Book{}, err
	}
	return book, nil
}

Context Values

Contexts also allow us to pass values through our applications easily, using context.WithValue. This can clean up an application and can help make sure that necessary information is passed along between concurrent processes.

What Values Should Be Passed?

Do pass anything that is “request” specific, such as the “current user” or a request ID.

Don’t pass “global” values such as database connections or loggers.

Context Value Anti-Patterns

While context values are powerful, they can be misused. Here are critical anti-patterns to avoid:

❌ The “Side Car API” Anti-Pattern

Don’t use context as a side channel to pass function parameters. Context values should not replace explicit function parameters.

Bad Example (Side Car API):

// ❌ Bad: Using context to avoid explicit parameters
func ProcessOrder(ctx context.Context) error {
    orderID := ctx.Value("orderID").(string)     // Hidden dependency
    userID := ctx.Value("userID").(int)          // Hidden dependency
    warehouse := ctx.Value("warehouse").(string)  // Hidden dependency

    // ... process order
}

// Caller has to stuff everything into context
ctx = context.WithValue(ctx, "orderID", "12345")
ctx = context.WithValue(ctx, "userID", 42)
ctx = context.WithValue(ctx, "warehouse", "NYC")
ProcessOrder(ctx)

Good Example (Explicit Parameters):

// ✓ Good: Function signature is clear and self-documenting
func ProcessOrder(ctx context.Context, orderID string, userID int, warehouse string) error {
    // ... process order
}

// Caller's intent is clear
ProcessOrder(ctx, "12345", 42, "NYC")

Why this matters:

  • API Clarity: Function signatures should document what the function needs
  • Type Safety: Context values lose type information and require assertions
  • Discoverability: IDEs can’t autocomplete or validate context values
  • Testing: Explicit parameters are easier to mock and test
  • Refactoring: Changes to context values can break code silently

Context Values: The Right Use Cases

✓ Use context values for request-scoped data that crosses API boundaries:

  • Trace IDs - For distributed tracing across services
  • Request IDs - For logging and correlation
  • Authentication tokens - User identity across middleware layers
  • Deadline/timeout information - Already handled by context itself

Example of appropriate use:

// ✓ Good: Authentication middleware adds user to context
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        user := authenticateRequest(r)
        ctx := WithUser(r.Context(), user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// ✓ Good: Handler extracts user from context
func OrderHandler(w http.ResponseWriter, r *http.Request) {
    user, _ := UserFromContext(r.Context())

    // orderID comes from explicit parameter (URL path or body)
    // user comes from context (set by middleware)
    ProcessOrder(r.Context(), orderID, user.ID)
}

Rule of thumb: If a caller would normally pass it as a parameter, make it a parameter. Only use context for cross-cutting concerns that permeate many layers.

Using WithValue

You can use the WithValue function to add any value to a context via a specific key.


ctx := context.WithValue(context.Background(), "id", 42)

The context now has a key called id with the value of 42 set.

You can retrieve a value from a context using the Value method:


id, ok := ctx.Value("id").(int)
if !ok {
	fmt.Println("id not fount")
	return
}
fmt.Printf("id is %d\n", id)

It’s important to note that all values are stored as an interface in context. This means that you need to do a type assertion when retrieving them to restore the proper type to the variable.

Interface Values

As we stated in the previous example, all values are stored as an empty interface{} (any) in the context tree.

The following example shows the danger in skipping the type assertion when retrieving values from a context:


func main() {
	// Add the value of "42" with the key of "id"
	ctx := context.WithValue(context.Background(), "id", 42)

	// Retrieve the value back out
	id := ctx.Value("id")

	fmt.Printf("id is %d, %T\n", id, id)
	fmt.Println(id + 10)
}

This will result in a runtime panic of type mismatch:


./main.go:17:17: invalid operation: id + 10 (mismatched types interface {} and int)

Non-string Context Keys

Context keys must be comparable, but using strings like the previous example isn’t a good idea. String keys aren’t namespaced, so if two packages use the same string key, they will collide and likely overwrite or retrieve the wrong data.

For instance, suppose that we different packages that we use that we pass our context through. If those packages use the same key name, such as ID, they will overwrite that key if any other package also uses that same value for the key identifier.

In concurrent programs, it is very common to pass your context around through many packages. If we think about a RESTful API built in Go, the context would get passed to the server, then to the handlers, then to the database, and likely through a handful of middleware on both the web and database layers. In addition, if it also uses any type of metrics package to track the distributed system performance, it is likely to have dozens of keys stored in the context for each specific layer/system.

One way to ensure that you don’t collide with the same key name another package uses is to use a very obscure key name, like this-is-my-key-nobody-else-use-this-please. As I suspect you may already agree, that isn’t a great approach.

Overlapping Middleware

Given a middleware package that uses an “id” key to set a current user:


func Authenticate(ctx context.Context) context.Context {
	return context.WithValue(ctx, "id", 42)
}

And an orders package that uses an “id” key to track the next order id to create:


func NextOrderID(ctx context.Context) context.Context {
	defer func() { nextID++ }()
	return context.WithValue(ctx, "id", nextID)
}

Using them together will cause interference:


func main() {
	ctx := context.Background()
	ctx = middleware.Authenticate(ctx)

	middleware.PrintUserID(ctx)

	ctx = orders.NextOrderID(ctx)
	orders.CreateOrder(ctx)

	middleware.PrintUserID(ctx)
}
User ID: 42
Order ID: 0
User ID: 0

Namespacing Context Values

A common practice is to use an unexported (private) type that only your package has access to. Because the Context looks up values based on both the type and value of the key, only your package will have access to that specific key.

The first step is to declare your unexported type:


type key int

Then, using the type you just defined, declare a const to use as a key:


const userKey key = 0

The value of the key doesn’t matter. Using a new unexported type is what prevents keys from becoming comparable even if another package uses the value of 0 as a key somewhere else.

If a package uses multiple key-values pairs, each const should have a unique value. For instance, if we also needed a request ID, we would declare it as well in our package:

const userKey key = 0
const requestKey key = 1

Context Getters / Setters

Because you’ll always use the same keys to access the values in the context, it’s also common to create your own setters and getters. This removes the need for you to keep track of which keys are used, and allow for you to call methods for easier readability/usage.

Setter:


// WithUser will embed the User in the provided context
func WithUser(ctx context.Context, u *User) context.Context {
	return context.WithValue(ctx, userKey, u)
}

Getter:


// FromContext will retrieve the User from the provided context
func FromContext(ctx context.Context) (*User, error) {
	u, ok := ctx.Value(userKey).(*User)
	if !ok {
		return nil, errors.New("user not found")
	}
	return u, nil
}

Using Getters & Setters

We can then use these getters and setters as needed in our code:


func main() {
	u := &User{
		ID:   0,
		Name: "Tim",
	}

	ctx := WithUser(context.Background(), u)
	printUser(ctx)

}

func printUser(ctx context.Context) {
	u, err := FromContext(ctx)
	if err != nil {
		fmt.Println("err getting user:", err)
		return
	}
	fmt.Println("user:", u)
}

Exercise (15m)

The current code has inadvertently used the same key values with different packages. As a result, the tests are now failing.

To make the tests pass again, you’ll need to perform the following steps:

  • Define unexported types for each package for your key types
  • Declare new constants based on the new unexported types
  • Update each corresponding setter and getter to use the new constants
  • Run the tests to ensure the code is now working

To start the exercise, use the Download Source button in the left hand navigation. Then, open the folder exercises/02-middleware in your editor.

To run the tests, you can use the following command from the exercises/02-middleware:

go test ./...

Note: No changes are needed to make the tests pass. Only the domain and tracking packages need to be modified.

Here is the test:


func TestContext(t *testing.T) {
	ctx := context.Background()

	expUser := domain.User{ID: 10, Name: "Rob"}
	ctx = domain.WithContext(ctx, &expUser)

	expTracer := tracking.Tracer{ID: 20}
	ctx = tracking.WithContext(ctx, &expTracer)

	gotUser, err := domain.FromContext(ctx)
	if err != nil {
		t.Error(err)
	}
	if !cmp.Equal(gotUser, &expUser) {
		t.Errorf("unexpected user\n%s\n", cmp.Diff(gotUser, expUser))
	}

	gotTracer, err := tracking.FromContext(ctx)
	if err != nil {
		t.Error(err)
	}
	if !cmp.Equal(gotTracer, &expTracer) {
		t.Errorf("unexpected tracer\n%s\n", cmp.Diff(gotTracer, expTracer))
	}
}

Here is the current failing output of the test:


$ go test ./...
--- FAIL: TestContext (0.00s)
    main_test.go:23: user not found
    main_test.go:26: unexpected user
          interface{}(
        -       (*domain.User)(nil),
        +       domain.User{ID: 10, Name: "Rob"},
          )

FAIL
FAIL    training        0.094s
?       training/domain [no test files]
?       training/tracking       [no test files]
FAIL

Here are the related files (hint, these are the only two files that need to change):

domain/user.go:


package domain

import (
	"context"
	"errors"
)

type User struct {
	ID   int
	Name string
}

func FromContext(ctx context.Context) (*User, error) {
	u, ok := ctx.Value("ID").(*User)
	if !ok {
		return nil, errors.New("user not found")
	}

	return u, nil
}

func WithContext(ctx context.Context, u *User) context.Context {
	return context.WithValue(ctx, "ID", u)
}

tracking/tracking.go:


package tracking

import (
	"context"
	"errors"
)

type Tracer struct {
	ID int
}

func WithContext(ctx context.Context, t *Tracer) context.Context {
	return context.WithValue(ctx, "ID", t)
}

func FromContext(ctx context.Context) (*Tracer, error) {
	t, ok := ctx.Value("ID").(*Tracer)
	if !ok {
		return nil, errors.New("tracer not found")
	}
	return t, nil
}

Solution

Here are the only two files that needed changes to work.

The fix was to add unexported types and declare the corresponding constants from those keys.

domain/user.go:


package domain

import (
	"context"
	"errors"
)

// Define our custom key type
type key int

// declare our key value
const userKey key = 0

type User struct {
	ID   int
	Name string
}

func FromContext(ctx context.Context) (*User, error) {
	u, ok := ctx.Value(userKey).(*User)
	if !ok {
		return nil, errors.New("user not found")
	}

	return u, nil
}

func WithContext(ctx context.Context, u *User) context.Context {
	return context.WithValue(ctx, userKey, u)
}

tracking/tracking.go:


package tracking

import (
	"context"
	"errors"
)

// Define our custom key type
type key int

// declare our key value
const tracerKey key = 0

type Tracer struct {
	ID int
}

func WithContext(ctx context.Context, t *Tracer) context.Context {
	return context.WithValue(ctx, tracerKey, t)
}

func FromContext(ctx context.Context) (*Tracer, error) {
	t, ok := ctx.Value(tracerKey).(*Tracer)
	if !ok {
		return nil, errors.New("tracer not found")
	}
	return t, nil
}

If we run main.go we’ll also get the expected results:


user: &{10 Rob}
tracer: &{20}