How not to use context.WithValue in Go

While working on my recent Go project, I had a use-case where I wanted to pass a struct between two Go packages and I used context.WithValue to do it.

In retrospect while reading the Go docs for it, I believe I have gone against every possible rule for using it 😅 Sometimes you will have to try things out practically to get a lasting lesson.

This is such a case and I am going to share the lessons that I learned here.

All these lessons come from this single commit - feel free to take a look at it if you are interested.

my use-case

I have three kinds of packages.

  • main package - starting point of my app
  • trigger, connector, scaler packages - these are called from main and accept a context.
  • event package which is initialized in main and is supposed to be used in the above packages
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

func main() {
// .....

eventBus, err := event.Init()
if err != nil {
fmt.Println("error initializing the event bus", err)
os.Exit(1)
}
ctx = context.WithValue(ctx, "eventBus", eventBus)

// .....

for id, scaler := range scalers {
err := scaler.Register(ctx)
if err != nil {
registerErrs = append(registerErrs, err)
}

// .....
}

Inside the scaler, I would do something like this.

1
2
3
4
5
6
7
8
9
10
func (s *Scaler) Register(ctx context.Context) error {
// .....

eventBus := ctx.Value("eventBus").(event.Bus)
eventBus.Subscribe(fmt.Sprintf("scaler.%s", s.id), func({
// .....
})

// .....
}

what’s wrong here?

This line ctx = context.WithValue(ctx, "eventBus", eventBus) in main.go is what is wrong.

While trying to refactor, I accidentally removed that line from main.go and ran go build. Guess what? The build succeeded without any problem 😱

This is scary because the eventBus is at the core of my project. All the packages emit and subscribe to events via it. I would maybe expect a compiler error if something as obvious as not passing it to these packages was happening.

If we try to run the passing build, it would result in a runtime panic whenever we hit the code path where it was used. Because we are getting the eventBus := ctx.Value("eventBus").(event.Bus) at runtime and we missed setting that value via context.WithValue, we will get back a nil reference. Since that value is being used just after that eventBus.Subscribe(), it will lead to a runtime panic.

1
panic: interface conversion: interface {} is nil, not event.Bus

Let us visit the docs

It is time to visit the Go docs for context.WithValue

WithValue returns a copy of parent in which the value associated with key is val.

Yep, I did want value associated with my key.

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

LOL, I was not even trying to pass an optional parameter, but a mandatory parameter.

The provided key must be comparable and should not be of type string or any other built-in type to avoid collisions between packages using context.

LOL, I was using string type.

Users of WithValue should define their own types for keys.

I did have this idea in mind and wanted to do it as a refactor.

To avoid allocating when assigning to an interface{}, context keys often have concrete type struct{}. Alternatively, exported context key variables’ static type should be a pointer or interface.

Okay, I still don’t fully understand this part because the example in the Go Doc seems to use the type of string

1
2
3
4
type favContextKey string

k1 := favContextKey("k1")
k2 := favContextKey("k2")

I would have expected it to be something like this based on that last line from the docs

1
2
3
4
type favContextKey struct{}

s1 := favContextKey{}
s2 := favContextKey{}

I am guessing k1 and k2 will result in memory allocation whereas s1 and s2 won’t. Could somebody confirm it for me?

Then how to use context.WithValue

As the docs suggest, it is should be strictly used for carrying request-scoped data that ideally live only during the lifetime of a request.

Example: let us consider an http handler which gets called every time we make an http request to a client.

1
2
3
4
5
6
7
8
func(w http.ResponseWriter, r *http.Request) {
// ......

ctx := context.WithValue(r.Context(), requestID{}, r.Header.Get("X-Request-ID"))
resp, err := someOtherAPI.client.Request(ctx)

// ....
}

So, here the context is very specific to the handler and lives only throughout the lifetime of the handler. It is used to store a piece of information very specific to the request (i.e. the request-id of the request) and pass it to the downstream API requests which could make use of it.

References

Two URLs on the internet helped me in my learning here:

~ ~ ~ ~

I dedicate this to all people who are faced with the question of “should I pass down my logger in my go context?” in their busy lives. The answer is simple. Don’t do it.