Go quirks: function closures capturing mutable references
Table of contents
I was browsing the hegel-go codebase and ran into this rule in its go-concurrency agent skill:
Function closures capturing mutable references
conn.crashMessageFn = s.serverCrashMessagecapturessand readss.logFile— any field the method touches is shared state. Prefer capturing immutable values (strings, ints) rather than pointers to mutable structs.
It’s the most concise representation I’ve seen of the behavior that has bitten me in the past.
Calling it a footgun would be a bit disingenuous. Closures had to capture something when they outlive their declaring function, and Go’s designers picked capture-by-reference . That’s what lets closure-based counters and accumulators work. But a captured pointer reads through to its target on every call, so any later write shows up in the closure.
A closure with a pointer sees future writes #
Take a Client whose addr function closes over a *Config, then mutate cfg:
type Config struct {
Host string
Port int
}
type Client struct {
addr func() string
}
cfg := &Config{Host: "localhost", Port: 8080}
c := &Client{
addr: func() string {
return fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
},
}
fmt.Println(c.addr()) // localhost:8080
cfg.Host = "example.com"
cfg.Port = 9090
fmt.Println(c.addr()) // example.com:9090
Run it on Go playground .
The closure didn’t bake in "localhost:8080". It captured cfg, which is a pointer, and
went back to the same struct every time it ran. Mutating the struct between calls changed
what the closure printed.
Capture-by-reference is what makes counters work #
The spec puts it like this:
Function literals are closures: they may refer to variables defined in a surrounding function. Those variables are then shared between the surrounding function and the function literal, and they survive as long as they are accessible.
Take a counter:
func counter() func() int {
n := 0
return func() int {
n++
return n
}
}
If n were copied into the closure at creation time, calling the returned function twice
would print 1, 1. Instead it prints 1, 2, because every call reaches the same n on the
heap. The Go FAQ entry on closures running as goroutines
spells out the same mechanic for
loop variables, and Russ Cox’s Off to the Races
notes that locals whose addresses escape
end up on the heap automatically. The compiler effectively lifts the captured variable to
the heap and gives the closure a pointer to it, so the same address is shared by anyone
holding the closure.
Every time you write func() { ... cfg.Host ... }, the closure keeps cfg alive and
reaches through it on every call.
A few more examples #
A connection’s crash message races with log rotation #
The original Hegel example has a server with a log file and a Conn that knows how to
format a crash message. If we expand, it might look like this:
type Server struct {
logFile *os.File
}
type Conn struct {
crashMsg func() string
}
func (s *Server) newConn() *Conn {
return &Conn{
crashMsg: func() string {
return "log: " + s.logFile.Name()
},
}
}
Now imagine the server rotates its log file, or sets s.logFile = nil during shutdown. Both
are reasonable things to do. The closure keeps reading s.logFile whenever something asks
the connection for its crash message. If that read happens during cleanup, you have a race
on s.logFile. If it happens after rotation, the message points at the new file, not the
file the connection was actually using.
Concurrent requests share one captured bool #
Philippe Gaultier reproduced this exact bug in a rate-limiting middleware:
func NewMiddleware(next http.Handler, rateLimitEnabled bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/admin") {
rateLimitEnabled = false
}
if rateLimitEnabled {
// ... rate limit ...
}
next.ServeHTTP(w, r)
})
}
The rateLimitEnabled bool is a parameter to NewMiddleware, but the closure captures it
by reference. Every concurrent HTTP request runs the same closure and every one of them
mutates the same captured bool. One admin request flips the switch off for everyone else.
The race detector didn’t even catch this on the original middleware in Gaultier’s tests; he
had to write a separate reproducer to make it fire.
The fix is a one-line shadow at the top of the closure body, so each request gets its own copy:
func NewMiddleware(next http.Handler, rateLimitEnabled bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rateLimitEnabled := rateLimitEnabled // per-request copy
if strings.HasPrefix(r.URL.Path, "/admin") {
rateLimitEnabled = false
}
if rateLimitEnabled {
// ... rate limit ...
}
next.ServeHTTP(w, r)
})
}
:= declares a new local. With =, the closure would still write to the captured
parameter.
I find this one especially nasty because there’s no goroutine in the source. The goroutines
are added by net/http when it dispatches handlers.
From Uber’s data race study :
Developers are quite often unaware that a variable used inside a closure is a free variable and captured by reference, especially when the closure is large. More often than not, Go developers use closures as goroutines. As a result of capture-by-reference and goroutine concurrency, Go programs end up potentially having unordered accesses to free variables unless explicit synchronization is performed.
Loop variables shared one slot before Go 1.22 #
The famous version of this is loop-variable capture:
for _, v := range xs { go func() { use(v) }() } printed the last value len(xs) times
because every iteration shared one v. This one got patched in go1.22
, which gives each
iteration its own copy, and Eli Bendersky has a great explainer
of what was happening
under the hood pre-1.22 if you’re curious.
Go 1.22 only changed loop-variable lifetime. Pointer captures and method values on long-lived receivers still behave the way they always did, because the language can’t tell that you didn’t mean exactly that.
Method values capture their receiver #
s.serverCrashMessage is a method value. Under the hood it’s a closure that captures s
the same way any other closure captures a free variable. From the hegel-go skill again:
conn.crashMessageFn = s.serverCrashMessagecapturessand readss.logFile— any field the method touches is shared state.
If serverCrashMessage reads s.logFile, the resulting function value carries a live
pointer to s and re-reads s.logFile every time it’s called. Bendersky’s article walks
through the same gotcha with a Show() method on a pointer receiver: go m.Show() quietly
shares the receiver across goroutines, and nothing at the call site warns you.
Capture a value when you want a snapshot #
The fix in every case is the same: capture a value, not a reference, when you want a snapshot.
Inline:
func (s *Server) newConn() *Conn {
name := s.logFile.Name() // copy now, while we know it's valid
return &Conn{
crashMsg: func() string {
return "log: " + name
},
}
}
Now Conn doesn’t hold a pointer to Server at all. Rotation, shutdown, and mutation of
s.logFile no longer concern it.
For multi-field snapshots, build a small struct on the way in:
type addr struct {
host string
port int
}
snap := addr{host: cfg.Host, port: cfg.Port}
c.addr = func() string {
return fmt.Sprintf("%s:%d", snap.host, snap.port)
}
If the closure genuinely needs to see live state, leave it as a pointer and guard the reads with the same mutex (or atomic, or channel) that the writers use. That’s a different choice with a different cost (more synchronization, fewer surprises) and you should make it on purpose.
A few more habits that have helped me:
- When a callback or method value lands on a long-lived struct, ask: which fields does this read? Write the answer next to the field declaration.
- If a closure only ever needs primitives, prefer passing them in as values rather than reaching through a pointer.
go vet’sloopclosurechecker still catches the loop-variable case in pre-1.22 modules. It cannot catch the broader struct-capture case.- The race detector (
go test -race) catches the concurrent ones. It can’t catch single-threaded “wrong value at the wrong time” bugs like the log-rotation example.
Drop the rule into your agent’s prompt #
Pasting the two sentences into your AGENTS.md, CLAUDE.md, or whatever your agent reads
is often enough:
### Function closures capturing mutable references
`conn.crashMessageFn = s.serverCrashMessage` captures `s` and reads
`s.logFile` — any field the method touches is shared state. Prefer capturing
immutable values (strings, ints) rather than pointers to mutable structs.