How to Add Context to Log Messages in Go

Add context to Go log messages using context.WithValue or structured logging with log/slog for key-value pairs.

How to Add Context to Log Messages in Go

Your server is handling a burst of requests. The logs scroll by fast. A database error appears. You see connection refused but you have no idea which user triggered it. Was it the admin? A bot? The user who just signed up? Without context, the log line is just noise. You need to attach data to the request flow so every log entry carries the identity of the work being done.

Think of a context like a tag attached to a parcel moving through a warehouse. The parcel is the request. The tag holds metadata: destination, priority, tracking number. Every worker who handles the parcel reads the tag to know what to do. If a worker drops the parcel, they write on the tag where it happened. In Go, context.Context is that tag. It travels with your request through functions and goroutines. You can attach values to it, and your logger can read those values to enrich every message.

The minimal pattern: context values

Here's the simplest way to attach data: create a context, add a value, and pass it down.

package main

import (
	"context"
	"fmt"
)

// ProcessRequest simulates handling a request with context.
func ProcessRequest(ctx context.Context) {
	// Retrieve the value using the same key used to store it.
	userID := ctx.Value("user_id")
	fmt.Printf("Processing for user: %v\n", userID)
}

func main() {
	// Background creates a root context. WithValue attaches a key-value pair.
	ctx := context.WithValue(context.Background(), "user_id", 123)
	ProcessRequest(ctx)
}

context.Background() creates the root context. It carries no values and no deadlines. context.WithValue returns a new context that wraps the original one. The original context is never modified. This immutability is key. Multiple goroutines can read the same context safely without locks. When you call ctx.Value, the implementation walks up the chain of wrappers to find the key. This lookup is fast for shallow chains.

The community convention is strict here. context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. If a function starts a goroutine, pass the context to that goroutine so it can stop when the parent cancels.

Context is plumbing. Run it through every long-lived call site.

Structured logging with slog

Manual string formatting is error-prone. log/slog, added in Go 1.21, supports structured logging natively. You attach attributes to a logger, and every message inherits them. This removes the need to extract values from the context just to format a log line.

Here's how structured logging works in a handler.

package main

import (
	"log/slog"
	"net/http"
	"os"
)

// HandleRequest demonstrates structured logging with enriched context.
func HandleRequest(w http.ResponseWriter, r *http.Request) {
	// Create a logger with request-specific attributes.
	// With returns a new logger; the original remains unchanged.
	reqLogger := slog.Default().With(
		"method", r.Method,
		"path", r.URL.Path,
		"remote_addr", r.RemoteAddr,
	)

	reqLogger.Info("request started")

	// Simulate work.
	// In real code, pass reqLogger to downstream functions.
	reqLogger.Info("request finished")
}

func main() {
	// Configure the global logger to output JSON for machine readability.
	slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
	http.HandleFunc("/api", HandleRequest)
	http.ListenAndServe(":8080", nil)
}

slog attributes are typed. slog.Int("id", 123) ensures the value is serialized as a number in JSON, not a string. This matters for log aggregation tools that filter by type. logger.With returns a new logger. Like context, the logger is immutable. You can pass the enriched logger to child functions. The original logger remains clean. This allows you to layer context: add service name at startup, add request ID in middleware, add user ID in the handler.

gofmt is mandatory. Don't argue about indentation; let the tool decide. Most editors run it on save. The slog API follows standard Go formatting rules, so your logger calls will look consistent with the rest of the codebase.

Loggers carry state. Contexts carry control. Keep them separate.

Avoiding key collisions

String keys are fragile. Two packages might use the same string and overwrite each other's values. If you store values in a context, define a private type for your keys. This ensures only your package can create keys, preventing collisions.

Here's the safe pattern for context keys.

package main

import (
	"context"
)

// contextKey is a private type to prevent key collisions.
// Defining a type ensures only this package can create keys.
type contextKey string

// userKey is the constant used to store user data.
const userKey contextKey = "user_id"

// SetUserID attaches a user ID to the context.
func SetUserID(ctx context.Context, id int) context.Context {
	// WithValue returns a new context; it never mutates the input.
	return context.WithValue(ctx, userKey, id)
}

// GetUserID retrieves the user ID safely.
func GetUserID(ctx context.Context) (int, bool) {
	// Value returns the stored value or nil if the key is missing.
	val := ctx.Value(userKey)
	if id, ok := val.(int); ok {
		return id, true
	}
	return 0, false
}

The compiler rejects the program with cannot use x (type string) as type contextKey in argument if you accidentally pass a string where the typed key is expected. This type safety is why the custom key pattern exists. It catches mistakes at compile time rather than causing silent data corruption at runtime.

Public names start with a capital letter. Private start lowercase. The contextKey type is lowercase, so other packages cannot instantiate it. The constants and functions are uppercase, so they are exported. This visibility control is how Go manages API boundaries.

Don't fight the type system. Wrap the value or change the design.

Pitfalls and runtime behavior

Context values should be small metadata. Don't put large objects in a context. If you need to pass a large payload, pass it as a function argument. Contexts are designed for cancellation, deadlines, and small per-request data like user IDs or trace IDs. Storing heavy data in context increases memory pressure and can slow down the chain lookup.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. If you pass a context to a goroutine, the goroutine must check for cancellation. If the context has a deadline, the goroutine should stop when the deadline passes. Otherwise, the goroutine runs forever, holding resources. The worst goroutine bug is the one that never logs.

if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. When working with context, check errors early. If a context is cancelled, return immediately. Don't ignore the cancellation signal.

The receiver name is usually one or two letters matching the type: (b *Buffer) Write(...), NOT (this *Buffer) or (self *Buffer). This convention keeps method signatures clean and consistent across the standard library and third-party packages.

_ (underscore) discards a value intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use it sparingly with errors. Discarding an error without handling it is a code smell.

Accept interfaces, return structs. "Accept interfaces, return structs" is the most common Go style mantra. When designing functions that use context, accept context.Context (an interface) and return concrete values. This keeps your API flexible.

Don't pass a *string. Strings are already cheap to pass by value. Passing a pointer to a string adds indirection without saving memory. Use string directly.

When to use each approach

Use logger.With when you need to attach fields to log messages within a function or handler. Use context.WithValue when you must pass data across package boundaries where a logger is not available. Use a custom key type when storing values in a context to avoid collisions between packages. Use context.Background() as the root context for incoming requests or background tasks. Use context.WithTimeout when an operation must complete within a deadline, and propagate that context to child calls. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Where to go next