Should you store *that value* in a Go context?

Avatar of the author Willem Schots
12 Nov, 2023
~5 min.
RSS

You might find yourself needing to pass new data to a significant amount of functions in your code base. Maybe you need to add a new paramater to an API, or every request should generate an ID to track it.

In Go web apps it’s common to pass a context.Context to practically every function or method. When half of the the functions in your code base need to be modified to accept new data it can be tempting to just add it to context instead. The context is already being passed around after all…

However, the documentation warns you that this is not a good idea for “optional parameters”:

Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.

I would go even further and say it’s almost never a good idea to use context to store values.

So why not?

Are you or your team struggling with aspects of Go?

Book a 30 minute check-in with me.

I can help with questions like:

  • How should we structure our Go web app?
  • Is this package a good choice for us?
  • How do I get my team up to speed quickly?
  • Can I pick your brain on {subject}?
Learn more

Looking forward to meeting you.

- Willem

Issue 1: Type safety

As discussed in the article, values retrieved via the Value method are of type any. You will need to use a type assertions to get a value of the underlying type.

main.go
package main

import (
	"fmt"
	"context"
)

func main() {
	bg := context.Background()
	// set "key" to a string value.
	ctx := context.WithValue(bg, "key", "zabba")

	// Later...

	// Get a value of type any.
	v := ctx.Value("key")
	// Type assertion to get underlying string value.
	s := v.(string)

	fmt.Println(s)
}

It also means you can’t depend on the compiler to detect type errors, type assertions will only fail during runtime.

If you change the type-assertion in the code above to a type that does not match the underlying string, it will cause a panic when the line is executed.

In addition, the compiler also won’t be able to help you if you provide the wrong keys for a value. If you misspell "key" in the Value call above, the code will still compile.

Issue 2. Hides inputs and outputs

Even without a doc comment a Go function signatures are somewhat self-documenting. At the very least you will know the order and types of parameters and results.

If you use a context to pass values, the minimum you will know from a signature is that a function or method accepts a context.Context. Users of such a function will always be required to look up what keys, values and types are actually used.

If this information is documented, then that documentation will need to be kept up to date with the source code. Extra work.

A bit of a contrived example, but the code below shows this issue:

main.go
package main

import (
	"fmt"
	"context"
)

func main() {
	bg := context.Background()

	// we would need to check documentation or
	// function definition to know about the "x" key.
	ctx := context.WithValue(bg, "x", 11)

	d1 := doubleCtx(ctx)
	
	// here the compiler will help us out.
	d2 := double(11)

	fmt.Println(d1)
	fmt.Println(d2)
}

// double signature shows us input and output types.
func double(x int) int {
	return x*2
}

// doubleCtx signature only shows us that it accepts a
// context.Context. It does not show us anything about the
// expected key for the value.
func doubleCtx(ctx context.Context) int {
	return ctx.Value("x").(int) * 2
}

What to store in contexts

As you can see, working with context values can be clunky and cumbersome.

That’s why I would always advise to default to function parameters. You will “work with the grain” of the type system and this will allow the compiler to catch mistakes for you.

However, there is certain data you might want to consider storing in context.

1. Technical metadata

Things like:

  • Trace, Request or Span IDs.
  • Request start time.
  • Route names.

Data that is there to inform you about the status of your app, but that doesn’t impact its behavior.

You often want to log data like this in different places, adding it to a context makes it widely available.

Alternatively you could "wrap" a logger with this data and pass it down as a parameter.

2. Authentication data

Some developers consider all authentication data as a kind of “metadata” separate from the main payload of a request and store it all in a context.

I often find you will still end up with functionality that needs to take the “current user” into account, so I usually pass that as an explicit parameter.

Framework contexts

A small side tangent: Some frameworks (gin and echo for example) use their own “contexts”.

These are different from the standard library context package. They generally contain all of the data from an incoming HTTP request and functionality to send a response.

Don’t pass these contexts through your entire application, unless you have very simple HTTP handlers. It’s not likely that every layer or part of your app needs to know about HTTP.

Outro

That’s a wrap for this article. I hope it helps you decide on what data to store in your contexts :)

🎓

Subscribe to my Newsletter and Keep Learning.

Gain access to more content and get notified of the latest articles:

I send emails every 1-2 weeks and will keep your data safe. You can unsubscribe at any time.

Hello! I'm the Willem behind willem.dev

I created this website to help new Go developers, I hope it brings you some value! :)

You can follow me on Twitter/X, LinkedIn or Mastodon.

Thanks for reading!