Context should go away for Go 2 (2017)
76 comments
·January 21, 2025captainmuon
kgeist
Passing the current user ID/tenant ID inside ctx has been super useful for us. We’re already using contexts for cancellation and graceful termination, so our application-layer functions already have them. Makes sense to just reuse them to store user and tenant IDs too (which we pull from access tokens in the transport layer).
We have DB sharding, so the DB layer needs to figure out which shard to choose. It does that by grabbing the user/tenant ID from the context and picking the right shard. Without contexts, this would be way harder—unless we wanted to break architecture rules, like exposing domain logic to DB details, and it would generally just clutter the code (passing tenant ID and shard IDs everywhere). Instead, we just use the "current request context" from the standard lib that can be passed around freely between modules, with various bits extracted from it as needed.
What’s the alternatives, though? Syntax sugar for retrieving variables from some sort of goroutine-local storage? Not good, we want things to be explicit. Force everyone to roll their own context-like interfaces, since a standard lib's implementation can't generalize well for all sitiations? That’s exactly why contexts we introduced—because nobody wanted to deal with mismatched custom implementations from different libs. Split it into separate "data context" and "cancellation context"? Okay, now we’re passing around two variables instead of one in every function call. DI to the rescue? You can hide userID/tenantID with clever dependency injection, and that's what we did before we introduced contexts to our codebase, but that resulted in allocations of individual dependency trees for each request (i.e. we embedded userID/tenantID inside request-specific service instances, to hide the current userID/tenantID, and other request details, from the domain layer to simplify domain logic), and it stressed the GC.
vbezhenar
An alternative is to add all dependencies explicitly into function argument list or object fields, instead of using them implicitly from the context, without documentation and static typing. Including logger.
kgeist
I already talked about it above.
Main problems with passing dependencies in function argument lists:
1) it pollutes the code and makes refactoring harder (a small change in one place must be propagated to all call sites in the dependency tree which recursively accept user ID/tenant ID and similar info)
2) it violates various architectural principles, for example, from the point of view of our business logic, there's no such thing as "tenant ID", it's an implementation detail to more efficiently store data, and if we just rely on function argument lists, then we'd have to litter actual business logic with various infrastructure-specific references to tenant IDs and the like so that the underlying DB layer could figure out what to do.
Sure, it can be solved with constructor-based dependency injection (i.e. request-specific service instances are generated for each request, and we store user ID/tenant ID & friends as object fields of such request-scoped instances), and that's what we had before switching to contexts, but it resulted in excessive allocations and unnecessary memory pressure for our highload services. In complex enterprise code, those dependency trees can be quite large -- and we ended up allocating huge dependency trees for each request. With contexts, we now have a single application-scoped service dependency tree, and request-specific stuff just comes inside contexts.
Both problems can be solved by trying to group and reuse data cleverly, and eventually you'll get back to square one with an implementation which looks similar to ctx.Context but which is not reusable/composable.
>Including logger.
We don't store loggers in ctx, they aren't request-specific, so we just use constructor-based DI.
karolinepauls
A good pitch for dynamic (context) variables is that they're not globals, they're like implicit arguments passed to all functions within the scope.
Personally I've used the (ugly) Python contextvars for:
- SQS message ID in to allow extending message visibility in any place in the code
- scoped logging context in logstruct (structlog killer in development :D)
I no longer remember what I used Clojure dynvars for, probably something dumb.
That being said, I don't believe that "active" objects like DB connection/session/transaction are good candidates for a context var value. Programmers need to learn to push side effects up the stack instead. Flask-SQLAlchemy is not correct here.
Even Flask's request object being context-scoped is a bad thing since it is usually not a problem to do all the dispatching in the view.
donatj
I really don't think you need it. Justice things just what they need. Let them ask for things. You can achieve the same thing much more functionally pure with function calls a little memorization.
segfaltnh
Thread local storage means all async tasks (goroutines) must run in the same thread. This isn't how tasks are actually scheduled. A request can fan out, or contention can move parts of the computation between threads, which is why context exists.
Furthermore in Go threads are spun up at process start, not at request time, so thread-local has a leak risk or cleanup cost. Contexts are all releasable after their processing ends.
I've grown to be a huge fan of Go for servers and context is one reason. That said, I agree with a lot of the critique and would love to see an in-language solution, but thread-local ain't it.
crowcountry
Scala has implicit contextual parameters: https://docs.scala-lang.org/tour/implicit-parameters.html.
agumonkey
I've always been curious about how this feature ends up in day to day operations and long term projects. You're happy with it ?
kloop
As a veteran of a large scala project (which was re-written in go, so I'm not unbiased), no. I was generally not happy.
This was scala 2, so implicit resolution lookup was a big chunk of the problem. There's nothing at the call site that tells you what is happening. But even when it wasn't hidden in a companion object somewhere, it was still difficult because every import change had to be scrutinized as it could cause large changes in behavior (this caused a non-zero number of production issues).
They work well for anything you would use environment variables for, but a chunk of the ecosystem likes to use them for handlers (the signature being a Functor generally), which was painful
xmodem
Not OP, but I briefly seconded to a team that used Scala at a big tech co and I was often frustrated by this feature specifically. They had a lot of code that consumed implicit parameters that I was trying to call from contexts they were not available.
Then again I guess it's better than a production outage because the thread-local you didn't know was a requirement wasn't available.
segfaltnh
Scala has everything, and therefore nothing.
TeMPOraL
> React has "Context", SwiftUI has "@Environment", Emacs LISP has dynamic scope (so I heard). C# has AsyncLocal, Node.JS AsyncLocalStorage.
Emacs Lisp retains dynamic scope, but it's no longer a default for some time now, in line in other Lisps that remain in use. Dynamic scope is one of the greatest features in Lisp language family, and it's sad to see it's missing almost everywhere else - where, as you noted, it's being reinvented, but poorly, because it's not a first-class language feature.
On that note, the most common case of dynamic scope that almost everyone is familiar with, are environment variables. That's what they're for. Since most devs these days are not familiar with the idea of dynamic scope, this leads to a lot of peculiar practices and footguns the industry has around environment variables, that all stem from misunderstanding what they are for.
> This is one of those ideas that at first seem really wrong (isn't it just a global variable in disguise?)
It's not. It's about scoping a value to the call stack. Correctly used, rebinding a value to a dynamic variable should only be visible to the block doing the rebinding, and everything below it on the call stack at runtime.
> Implicit context (properly integrated into the type system) is something I would consider in any new language.
That's the problem I believe is currently unsolved, and possibly unsolvable in the overall programming paradigm we work under. One of the main practical benefits of dynamic scope is that place X can set up some value for place Z down on the call stack, while keeping everything in between X and Z oblivious of this fact. Now, this is trivial in dynamically typed language, but it goes against the principles behind statically-typed languages, which all hate implicit things.
(FWIW, I love types, but I also hate having to be explicit about irrelevant things. Since whether something is relevant or not isn't just a property of code, but also a property of a specific programmer at specific time and place, we're in a bit of a pickle. A shorter name for "stuff that's relevant or not depending on what you're doing at the moment" is cross-cutting concerns, and we still suck at managing them.)
siknad
> against the principles behind statically-typed languages, which all hate implicit things
But many statically typed languages allow throwing exceptions of any type. Contexts can be similar: "try catch" becomes "with value", "throw" becomes "get".
TeMPOraL
Yes, but then those languages usually implement only unchecked exception, as propagating error types up the call tree is seen as annoying. And then, because there are good reasons you may want to have typed error values (instead of just "any"), there is now pressure to use result types (aka. "expected", "maybe") instead - turning your return type Foo into Result<Foo, ErrorType>.
And all that it does is making you spell out the entire exception handling mechanism explicitly in your code - not just propagating the types up the call tree, but also making every function explicitly wrapping, unwrapping and branching on Result types. The latter is so annoying that people invent new syntax to hide it - like tacking ? at the end of the function, or whatever.
This becomes even worse than checked exception, but it's apparently what you're supposed to be doing these days, so ¯\_(ツ)_/¯.
masklinn
> Emacs Lisp retains dynamic scope, but it's no longer a default for some time now
https://www.gnu.org/software/emacs/manual/html_node/elisp/Va...
> By default, the local bindings that Emacs creates are dynamic bindings. Such a binding has dynamic scope, meaning that any part of the program can potentially access the variable binding. It also has dynamic extent, meaning that the binding lasts only while the binding construct (such as the body of a let form) is being executed.
It’s also not really germane to the GP’s comment, as they’re just talking about dynamic scoping being available, which it will almost certainly always be (because it’s useful).
TeMPOraL
Sorry, you're right. It's not a cultural default anymore. I.e. Emacs Lisp got proper lexical scope some time ago, and since then, you're supposed to start every new .elisp file with:
;; -*- mode: emacs-lisp; lexical-binding: t; -*-
i.e. explicitly switching the interpreter/compiler to work in lexical binding mode.sghiassy
In Jetpack compose, the Composer is embedded by the compiler at build time into function calls
https://medium.com/androiddevelopers/under-the-hood-of-jetpa...
I’m still not sure how I feel about it. While more annoying, I think I’d like to see it, rather than just have magic behind the hood
fragmede
seeing it is great. coming into a hairy monolith and having to plumb one variable through half a dozen layers to get to the creamy nougat later you actually wanted it in, is not. having to do that more than once it's why they invented the "magic" implicit context variable.
whstl
Yeah, I agree 100% with you. The thing with Golang is that it's supposed to be a very explicit language, so passing the context as an argument fits in with the rest of the language.
Nevertheless: just having it, be it implicit or explicit, beats having to implement it yourself.
kalekold
> If you use ctx.Value in my (non-existent) company, you’re fired
This is such a bad take.
ctx.Value is incredibly useful for passing around context of api calls. We use it a lot, especially for logging such context values as locales, ids, client info, etc. We then use these context values when calling other services as headers so they gain the context around the original call too. Loggers in all services pluck out values from the context automatically when a log entry is created. It's a fantastic system and serves us well. e.g.
log.WithContext(ctx).Errorf("....", err)
sluongng
Let me try to take the other side:
`ctx.Value` is an `any -> any` kv store that does not come with any documentation, type checking for which key and value should be available. It's quick and dirty, but in a large code base, it can be quite tricky to check if you are passing too many values down the chain, or too little, and handle the failure cases.
What if you just use a custom struct with all the fields you may need to be defined inside? Then at least all the field types are properly defined and documented. You can also use multiple custom "context" structs in different call paths, or even compose them if there are overlapping fields.
bluetech
> `ctx.Value` is an `any -> any` kv store that does not come with any documentation, type checking for which key and value should be available
The docs https://pkg.go.dev/context#Context suggest a way to make it type-safe (use an unexported key type and provide getter/setter). Seems fine to me.
> What if you just use a custom struct with all the fields you may need to be defined inside?
Can't seamlessly cross module boundaries.
smarkov
> `ctx.Value` is an `any -> any` kv store that does not come with any documentation, type checking for which key and value should be available.
On a similar note, this is also why I highly dislike struct tags. They're string magic that should be used sparingly, yet we've integrated them into data parsing, validation, type definitions and who knows what else just to avoid a bit of verbosity.
frankie_t
The author gave a pretty good reasoning why is it a bad idea, in the same section. However, for the demonstration purposes I think the they should have included their vision on how the request scoped data should be passed.
As I understand they propose to pass the data explicitly, like a struct with fields for all possible request-scoped data.
I personally don't like context for value passing either, as it is easy to abuse in a way that it becomes part of the API: the callee is expecting something from the caller but there is no static check that makes sure it happens. Something like passing an argument in a dictionary instead of using parameters.
However, for "optional" data whose presence is not required for the behavior of the call, it should be fine. That sort of discipline has to be enforced on the human level, unfortunately.
rubenv
> As I understand they propose to pass the data explicitly, like a struct with fields for all possible request-scoped data.
So basically context.Context, except it can't propagate through third party libraries?
frankie_t
If you use a type like `map[string]any` then yes, it's going to be the same as Context. However, you can make a struct with fields of exactly the types you want.
It won't propagate to the third-party libraries, yes. But then again, why don't they just provide an explicit way of passing values instead of hiding them in the context?
b1-88er
Maybe he doesn't have a company because he is too dogmatic about things that don't really matter.
nickcw
Contexts implement the idea of cancellation along with go routine local storage and at that they work very well.
What if for the hypothetical Go 2 we add an implicit context for each goroutine. You'd probably need to call a builtin, say `getctx()` to get it.
The context would be inherited by all go routines automatically. If you wanted to change the context then you'd use another builtin `setctx()` say.
This would have the usefulness of the current context without having to pass it down the call chain everwhere.
The cognitive load is two bultins getctx() and setctx(). It would probably be quite easy to implement too - just stuff a context.Context in the G.
rednafi
This article is from 2017!
As others have already mentioned, there won't be a Go 2. Besides, I really don't want another verbose method for cancellation; error handling is already bad enough.
incognito124
I thought go 2 was considered harmful
TeMPOraL
Yes, that's why you should instead use "COMEFROM", or it's more general form, "LET'S HAVE A WALK".
rednafi
Oh, don't even start about Go's knack for being pithy to a fault.
robertlagrant
I came here to say this.
bheadmaster
Contexts in Go are generally used for convenience in request cancellation, but they're not required, and they're not the only way to do it. Under the hood, a context is just a channel that's closed on cancellation. The way it was done before contexts was pretty much the same:
func CancellableOp(done chan error /* , args... */) {
for {
// ...
// cancellable code:
select {
case <-something:
// ...
case err := <-done:
// log error or whatever
}
}
}
Some compare context "virus" to async virus in languages that bolt-on async runtime on top of sync syntax - but the main difference is you can compose context-aware code with context-oblivious code (by passing context.Background()), and vice versa with no problems. E.g. here's a context-aware wrapper for the standard `io.Reader` that is completely compatible with `io.Reader`: type ioContextReader struct {
io.Reader
ctx context.Context
}
func (rc ioContextReader) Read(p []byte) (n int, err error) {
done := make(chan struct{})
go func() {
n, err = rc.Reader.Read(p)
close(done)
}()
select {
case <-rc.ctx.Done():
return 0, rc.ctx.Err()
case <-done:
return n, err
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
rc := ioContextReader{Reader: os.Stdin, ctx: ctx}
// we can use rc in io.Copy as it is an io.Reader
_, err := io.Copy(os.Stdout, rc)
if err != nil {
log.Println(err)
}
}
For io.ReadCloser, we could call `Close()` method when context exits, or even better, with `context.AfterFunc(ctx, rc.Close)`.Contexts definitely have flaws - verbosity being the one I hate the most - but having them behave as ordinary values, just like errors, makes context-aware code more understandable and flexible.
And just like errors, having cancellation done automatically makes code more prone to errors. When you don't put "on-cancel" code, your code gets cancelled but doesn't clean up after itself. When you don't select on `ctx.Done()` your code doesn't get cancelled at all, making the bug more obvious.
the_gipsy
Consider this:
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
reader := ioContextReader(ctx, r)
...
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
ctx = context.WithValue(ctx, "hello", "world")
...
func(ctx context.Context) {
reader.Read() // does not time out after one second, does not contain hello/world.
...
}(ctx)
bheadmaster
There are two solutions, depending on your real use case:
1) You're calling Read() directly and don't need to use functions that strictly accept io.Reader - then just implement ReadContext:
func (rc ioContextReader) ReadContext(ctx context.Context, p []byte) (n int, err error) {
done := make(chan struct{})
go func() {
n, err = rc.Reader.Read(p)
close(done)
}()
select {
case <-ctx.Done():
return 0, ctx.Err()
case <-done:
return n, err
}
}
Otherwise, just wrap the ioContextReader with another ioContextReader: reader = ioContextReader(ctx, r)
bryancoxwell
This works, but goes against convention in that (from the context package docs) you shouldn’t “store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it.”
bheadmaster
True. But this code is only proof-of-concept of how non-context-aware functions can be wrapped in a context. Such usage of context is not standard.
kiitos
You're spawning a goroutine per Read call? This is pretty bonkers inefficient, to start, and a super weird approach in any case...
bheadmaster
Yes, but this is just proof of concept. For any given case, you can optimize your approach to your needs. E.g. single goroutine ReadCloser:
type ioContextReadCloser struct {
io.ReadCloser
ctx context.Context
ch chan *readReq
}
type readReq struct {
p []byte
n *int
err *error
m sync.Mutex
}
func NewIoContextReadCloser(ctx context.Context, rc io.ReadCloser) *ioContextReadCloser {
rcc := &ioContextReadCloser{
ReadCloser: rc,
ctx: ctx,
ch: make(chan *readReq),
}
go rcc.readLoop()
return rcc
}
func (rcc *ioContextReadCloser) readLoop() {
for {
select {
case <-rcc.ctx.Done():
return
case req := <-rcc.ch:
*req.n, *req.err = rcc.ReadCloser.Read(req.p)
if *req.err != nil {
req.m.Unlock()
return
}
req.m.Unlock()
}
}
}
func (rcc *ioContextReadCloser) Read(p []byte) (n int, err error) {
req := &readReq{p: p, n: &n, err: &err}
req.m.Lock() // use plain mutex as signalling for efficiency
select {
case <-rcc.ctx.Done():
return 0, rcc.ctx.Err()
case rcc.ch <- req:
}
req.m.Lock() // wait for readLoop to unlock
return n, err
}
Again, this is not to say this is the right way, only that it is possible and does not require any shenanigans that e.g. Python needs when dealing with when mixing sync & async, or even different async libraries.orf
A mutex in a hot Read (or any IO) path isn’t efficient.
the_gipsy
> This probably doesn’t happen often, but it’s prone to name collisions.
It's funny, it really was just using strings as keys until quite recently, and obviously there were collisions and there was no way to "protect" a key/value, etc.
Now the convention is to use a key with a private type, so no more collisions. The value you get is still untyped and needs to be cast, though. Also there are still many older libraries still uses strings.
grose
The blog post from 2014 introducing context uses a private key type, so there's really no excuse: https://go.dev/blog/context#package-userip
mukunda_johnson
> It’s very similar to thread-local storage. We know how bad of an idea thread-local storage is. Non-flexible, complicates usage, composition, testing.
I kind of do wish we had goroutine local storage though :) Passing down the context of the request everywhere is ugly.
arccy
now your stuff breaks when you pass messages between channels
rednafi
Goroutines have a tiny stack at the beginning, 4KB iirc. Having a goroutine-local storage will probably open a can of worms there.
PaulKeeble
Context's spread just like exceptions do, the moment you introduce one it flies up and down all the functions to get where it needs to be. I can't help but think that local storage and operations for Go just like Threads have in Java would be a cleaner solution to the problem.
alkonaut
Was this solved? Is this context only a cancellation flag or does it do something more? The obvious solution for a cancellation trigger would be to have cancellation as an optional second argument. That's how it's solved in e.g. C#. Failing to pass the argument just makes it CancellationToken.None, which is simply never cancelled. So I/O without cancellation is simply foo.ReadAsync(x) and with cancellation it's foo.ReadAsync(x, ct).
whstl
It's not just for cancellation and timeouts, it is also used for passing down metadata, but also for cross-cutting concerns like structured loggers.
mrkeen
> If the Go language ever comes to the point where I’d have to write this
n, err := r.Read(context.TODO(), p)
> put a bullet in my head, please.Manually passing around a context everywhere sounds about as palatable as manually checking every return for error.
the_gipsy
Exactly, the snippets needs at least three lines of inane error checking boilerplate and variable juggling.
the_duke
Needs a (2017)!
miffy900
> First things first, let’s establish some ground. Go is a good language for writing servers, but Go is not a language for writing servers. Go is a general purpose programming language, just like C, C++, Java or Python
Really? Even years later in 2025, this never ended up being true. Unless your definition of 'general purpose' specifically excludes anything UI-related, like on desktop, web or mobile, or AI-related.
I know it's written in 2017, but reading it now in 2025 and seeing the author comparing it to Python of all languages in the context of it's supposed 'general purpose'ness is just laughable. Even Flutter doesn't support go. granted, that seems like a very deliberate decision to justify Dart's existence.
pjmlp
In an alternative timeline, had Rust 1.0 been available when Docker pivoted away from Python into Go, and Kubernetes from Java into Go, due to having Go folks pushing for the rewrite, and most likely they would have been taken by RIIR instead, nowadays spreading across Python and JavaScript ecosystem, including rewriting tools originally written in Go.
theThree
Context is useful in many cases. In go I have to pass ctx from func to func. In nodejs I can easily create&use context by using AsyncLocalStorage (benefit of single-thread).
This is about an explicit argument of type "Context". I'm not a Go user, and at first I thought it was about something else: an implicit context variable that allows you to pass stuff deep down the call stack, without intermediate functions knowing about it.
React has "Context", SwiftUI has "@Environment", Emacs LISP has dynamic scope (so I heard). C# has AsyncLocal, Node.JS AsyncLocalStorage.
This is one of those ideas that at first seem really wrong (isn't it just a global variable in disguise?) but is actually very useful and can result in cleaner code with less globals or less superfluous function arguments. Imagine passing a logger like this, or feature flags. Or imagine setting "debug = True" before a function, and it applies to everything down the call stack (but not in other threads/async contexts).
Implicit context (properly integrated into the type system) is something I would consider in any new language. And it might also be a solution here (altough I would say such a "clever" and unusual feature would be against the goals of Go).