Context & Passing Data from Middleware
🧩 Prerequisites: You should be comfortable with middleware (lesson 0004) — the wrapping pattern, http.Handler, and next.ServeHTTP(w, r). This lesson builds directly on that.
🎯 Your mission: In lesson 0004, you learned how middleware wraps every request. But a real API needs middleware to talk to handlers — e.g., auth middleware extracting a user ID, then passing it to the route handler that needs it. Go does this with context.Context, a built-in data bag that rides along with every HTTP request. By the end, you’ll write an auth middleware that adds a username to the request, and a handler that reads it.
context.Context — a bag that travels with the request
Think of context.Context as an invisible backpack that every HTTP request carries. You can put things in it (like “which user made this request”) and take them out later. It also handles deadlines, cancellation signals, and timeouts — but for this lesson, we’re only focused on storing and retrieving values. → context package docs
How to get the context: r.Context()
Every *http.Request already has a context. You grab it with r.Context(). But you can’t modify the context directly — Go contexts are immutable. To add a value, you create a new context with context.WithValue(), and then create a new request with r.WithContext(newCtx). → Request.Context docs
The three-step dance
ctx := r.Context()— get the current contextctx = context.WithValue(ctx, key, value)— create a new context with a value addedr = r.WithContext(ctx)— create a new request that carries the updated context
Then pass this updated request tonext.ServeHTTP(w, r).
Getting values out: type assertion
ctx.Value(key) returns interface{} — Go’s generic “any type” container. To use it, you type-assert: val, ok := ctx.Value(key).(string). If ok is true, val is a string. If ok is false, the value wasn’t a string (or didn’t exist). Always check ok — skipping it can panic. → Tour of Go: Type assertions
Custom key types: why not just use strings?
If two packages both use "userID" as a key, they’ll collide — one overwrites the other. The convention is to define a custom type: type contextKey string. Each package has its own type, so keys can never collide. → context.WithValue docs (see “custom type” recommendation)
// The problem: middleware and handlers need to share data
<span class="dim">// ❌ Can't do this — handlers don't have access to middleware variables</span>
func authMiddleware(next) {
userID := "42" <span class="dim">// ← created here...</span>
next(w, r) <span class="dim">// ← ...but how does the handler get it?</span>
}
<span class="hl">// ✅ The solution: context.Context</span>
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w, r) {
<span class="dim">// Step 1: Get the current context from the request</span>
ctx := r.Context()
<span class="dim">// Step 2: Create a new context with our value added</span>
ctx = context.WithValue(ctx, ctxKeyUserID, "42")
<span class="dim">// Step 3: Create a new request carrying the updated context</span>
r = r.WithContext(ctx)
<span class="dim">// Pass the enriched request to the next handler</span>
next.ServeHTTP(w, r)
})
}
func getUserById(w, r) {
<span class="dim">// Later, in any handler down the chain:</span>
userID := r.Context().Value(ctxKeyUserID).(string)
<span class="dim">// use userID...</span>
}| Step | Function | What it does |
|---|---|---|
| 1. Get context | r.Context() | Returns the context.Context attached to this request |
| 2. Add value | context.WithValue(ctx, key, val) | Returns a <em>new</em> context with key→val added (immutable!) |
| 3. New request | r.WithContext(ctx) | Returns a <em>new</em> request carrying the updated context |
| 4. Read value | r.Context().Value(key) | Looks up a value; returns interface{}, must type-assert |
Follow each step in order. Click Done ✓ after completing it.
mkdir -p ~/dev/learn/go-lang/lessons/0005-context
cd ~/dev/learn/go-lang/lessons/0005-context
go mod init context-demoBefore wiring context into HTTP middleware, let’s see it in isolation. Create play.go and run it:
package main
import (
"context"
"fmt"
)
// A custom key type — prevents collisions between packages.
type contextKey string
func main() {
// 1) Every context starts from the "background" — an empty root.
ctx := context.Background()
// 2) WithValue creates a NEW context (the old one is unchanged).
userIDKey := contextKey("userID")
ctx = context.WithValue(ctx, userIDKey, 42)
// 3) Value() returns interface{} — use type assertion to get the int.
val, ok := ctx.Value(userIDKey).(int)
if !ok {
fmt.Println("key not found!")
return
}
fmt.Printf("userID = %d\n", val)
// 4) What if we use the wrong type in the assertion?
_, ok2 := ctx.Value(userIDKey).(string) // stored as int, asserting as string
fmt.Printf("ok2 = %v (expected false)\n", ok2)
// 5) What if we look up a key that was never stored?
_, ok3 := ctx.Value(contextKey("missing")).(int)
fmt.Printf("ok3 = %v (expected false)\n", ok3)
}Run it:
go run play.goExpected output: userID = 42, ok2 = false, ok3 = false. The type assertion acts as a safety check — it returns false instead of panicking.
Now the key pattern. This middleware pretends to authenticate a request and passes the username into the context. Read this carefully — especially the three-step dance (r.Context() → WithValue → r.WithContext):
// Custom type for context keys — prevents collisions.
type contextKey string
const ctxKeyUsername contextKey = "username"
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1) Get the existing context from the request
ctx := r.Context()
// 2) Create a new context with our value added
// (real auth would parse a token/header here)
ctx = context.WithValue(ctx, ctxKeyUsername, "alice")
// 3) Create a new request that carries the updated context
r = r.WithContext(ctx)
// 4) Pass the enriched request to the next handler
next.ServeHTTP(w, r)
})
}The handler reads it back with r.Context().Value():
func whoami(w http.ResponseWriter, r *http.Request) {
username, ok := r.Context().Value(ctxKeyUsername).(string)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
fmt.Fprintf(w, "Hello, %s!\n", username)
}Notice: the middleware and the handler share ctxKeyUsername — they both know the key. The middleware writes with WithValue, the handler reads with Value. This is the full pattern.
Create main.go in the lesson directory. This server has two handlers: one that requires auth (reads the username from context), and one that just lists users (no auth). The logging middleware from lesson 0004 is layered on top:
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
// --- Context keys ---
type contextKey string
const ctxKeyUsername contextKey = "username"
// --- Middleware ---
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
log.Printf("→ %s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
log.Printf("← %s %s (%v)", r.Method, r.URL.Path, time.Since(start))
})
}
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Pretend we authenticated the user.
// Real code would check an Authorization header here.
ctx := context.WithValue(r.Context(), ctxKeyUsername, "alice")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// --- Data ---
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
var users = []User{
{ID: 1, Name: "Alice", Email: "alice@example.com", CreatedAt: time.Now()},
{ID: 2, Name: "Bob", Email: "bob@example.com", CreatedAt: time.Now()},
}
// --- Handlers ---
func getUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
func whoami(w http.ResponseWriter, r *http.Request) {
// Read the username that authMiddleware stored in the context.
username, ok := r.Context().Value(ctxKeyUsername).(string)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
fmt.Fprintf(w, "Hello, %s!\n", username)
}
// --- Main ---
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /whoami", whoami)
mux.HandleFunc("GET /users", getUsers)
// Layer: auth → logging → mux
// The onion: authMiddleware runs first, then loggingMiddleware, then mux.
handler := authMiddleware(loggingMiddleware(mux))
log.Println("Server starting on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", handler))
}What’s new this lesson?
context.WithValue(ctx, key, val)— creates a new context with a value storedr.WithContext(ctx)— creates a new request carrying the updated contextr.Context().Value(key).(string)— reads a value and type-asserts ittype contextKey string— custom key type to avoid collisions- Multiple middleware chained:
authMiddleware(loggingMiddleware(mux))
Start the server:
go run main.goIn a second terminal, test both endpoints:
# /whoami reads the username from context (placed by auth middleware)
curl http://localhost:8080/whoami
# /users doesn't use context — it still works fine
curl http://localhost:8080/usersYou should see Hello, alice! from /whoami. The auth middleware placed “alice” into the context, and the handler read it back. The logging middleware still logs everything. Both middleware are working independently.
Press Ctrl+C to stop the server when you’re done.
Check your understanding
Why do we call r.WithContext(ctx) instead of just modifying r.Context() directly?
What does the ok in val, ok := ctx.Value(key).(string) tell you?
What problem does type contextKey string solve?
In the server from step 4, what would /whoami return if the authMiddleware was removed from the middleware chain?
🎉 You finished the lesson!
Context also handles deadlines, cancellation, real auth token parsing, and design trade-offs.
Go deeper →