When not to use context values

April 17, 2025

Storing values in a context is quite flexible, but comes without strict type safety, and obscures the API. So what can we do about this?

First, for type safety, we’re limited to runtime assertions:

userID := ctx.Value(userIDKey).(string)

But this can panic if we ever get an unexpected value (or even nil). So to make it safer, we can use the two-variable assignment form, which yields a bool indicating success:

userID, ok := ctx.Value(userIDKey).(string)
if !ok {
	return errors.New("userID not found in context")
}

But for the obscure API problem, what can we do?

This may be anti-climactic, but the best solution I’ve found is to simply avoid using context values. Of course that’s not always practical, so it means we have to choose between trade-offs.

But this brings me finally to answer the question: Should request-scoped “access objects” be included in a context?

My answer: No.

Don’t pass a logger, database handle, or other such object, even when specifically configured per request, via context, unless there’s really no other way. Instead, my advice, is to pass them to an object constructor, then pass your context to a method.

Let me illustrate.

Don’t do this:

func Foo(ctx context.Context, /* ... */) {
	logger := ctx.Value(loggerKey).(*slog.Logger)
	logger.Info(/* ... */)
	/* ... */
}

Instead do this:

type Controller struct {
	logger *slog.Logger
}

func New(logger *slog.Logger) Controller {
	return Controller{logger: logger}
}

func (c *Controller) Foo(ctx context.Context, /* ... */) {
  c.logger.Info(/* ... */)
}

It’s a bit more verbose in this simple case, but it makes the API explicit, and it provides compile-time type safety. There’s no risk of receiving a nil logger.

This particular approach doesn’t actually solve the issue of propagating a request-scoped logger, however. Addressing that issue is more involved than I can fully address today. It’s also well beyond the scope of the context package, which is itself a clue to the answer: It’s rare to actually need request-scoped loggers (or databases, or other objects). There’s probably a better way. (If you need help finding that better way, feel free to send me an email, and I can talk about it in an upcoming email.)


Share this

Direct to your inbox, daily. I respect your privacy .

Unsure? Browse the archive .

Related Content


The Context API contract

Today we come to the core of the context package: The Context interface itself. type Context type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key any) any } A Context carries a deadline, a cancellation signal, and other values across API boundaries. Context’s methods may be called by multiple goroutines simultaneously. For clarity, I’ve removed all of the documentation for each of the interface methods in the above quote—we’ll get to those in following emails, and include those there.


Concurrent use of contexts

Overview … The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines. Contexts are built using layers. Every time you call one of the context.With* functions, the orginal context is wrapped in a new layer. This is the “magic” that makes contexts concurrency safe by design–they’re never mutated once they’re created. ctx := context.Background() // Create an original context ctx, cancel = context.


Context values and type safety

Last week I asked whether or not it’s a good idea to pass things like a user-scoped database handle or logger via a context value. Before I provide my direct answer, I want to take a short detour… Go is a (mostly) strictly-typed language. This gives us certain guarantees. When we have a variable of type int, we know it doesn’t contain the value "cow", because that’s not an integer.

Get daily content like this in your inbox!

Subscribe