{
  "$type": "site.standard.document",
  "canonicalUrl": "https://rednafi.com/go/closure-mutable-refs/",
  "description": "A Go closure holds a live reference to whatever it captures, not a snapshot. Real examples of where this trips people up, and how to keep it boring.",
  "path": "/go/closure-mutable-refs/",
  "publishedAt": "2026-04-25T00:00:00.000Z",
  "site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
  "tags": [
    "Go",
    "Concurrency"
  ],
  "textContent": "I was browsing the [hegel-go] codebase and ran into this rule in its [go-concurrency] agent\nskill:\n\n> Function closures capturing mutable references\n>\n> conn.crashMessageFn = s.serverCrashMessage captures s and reads s.logFile — any\n> field the method touches is shared state. Prefer capturing immutable values (strings,\n> ints) rather than pointers to mutable structs.\n\nIt's the most concise representation I've seen of the behavior that has bitten me in the\npast.\n\nCalling it a footgun would be a bit disingenuous. Every language has to pick how closures\nsee captured variables. Java lambdas can only read effectively-final locals, so the value is\nfrozen at the moment of capture. C++ makes you say up front whether each variable is\ncaptured by value or by reference. Go made every closure [capture-by-reference], which can\nlead to some surprising behavior.\n\nA closure with a pointer sees future writes\n\nTake a Client whose addr function closes over a Config, then mutate cfg:\n\nRun it on [Go playground].\n\nThe closure didn't bake in \"localhost:8080\". It captured cfg, which is a pointer, and\nwent back to the same struct every time it ran. Mutating the struct between calls changed\nwhat the closure printed.\n\nTo freeze a multi-field snapshot, copy what the closure needs into a local before creating\nit:\n\nCapture-by-reference is what makes counters work\n\nThe [spec] puts it like this:\n\n> Function literals are closures: they may refer to variables defined in a surrounding\n> function. Those variables are then shared between the surrounding function and the\n> function literal, and they survive as long as they are accessible.\n\nTake a counter:\n\nIf n were copied into the closure at creation time, calling the returned function twice\nwould print 1, 1. Instead it prints 1, 2, because every call reaches the same n on the\nheap. The Go FAQ entry on [closures running as goroutines] spells out the same mechanic for\nloop variables, and Russ Cox's [Off to the Races] notes that locals whose addresses escape\nend up on the heap automatically. The compiler effectively lifts the captured variable to\nthe heap and gives the closure a pointer to it, so the same address is shared by anyone\nholding the closure.\n\nEvery time you write func() { ... cfg.Host ... }, the closure keeps cfg alive and\nreaches through it on every call.\n\nA few more examples\n\nA connection's crash message races with log rotation\n\nThe original Hegel example has a server with a log file and a Conn that knows how to\nformat a crash message. If we expand, it might look like this:\n\nNow imagine the server rotates its log file, or sets s.logFile = nil during shutdown. Both\nare reasonable things to do. The closure keeps reading s.logFile whenever something asks\nthe connection for its crash message. If that read happens during cleanup, you have a race\non s.logFile. If it happens after rotation, the message points at the new file, not the\nfile the connection was actually using.\n\nThe fix is to copy what the closure needs at construction time:\n\nConn no longer holds a pointer to Server. Rotation, shutdown, and mutation of\ns.logFile no longer concern it.\n\nConcurrent requests share one captured bool\n\nPhilippe Gaultier [reproduced this exact bug] in a rate-limiting middleware:\n\nThe rateLimitEnabled bool is a parameter to NewMiddleware, but the closure captures it\nby reference. Every concurrent HTTP request runs the same closure and every one of them\nmutates the same captured bool. One admin request flips the switch off for everyone else.\nThe race detector didn't even catch this on the original middleware in Gaultier's tests; he\nhad to write a separate reproducer to make it fire.\n\nThe fix is a one-line shadow at the top of the closure body, so each request gets its own\ncopy:\n\n:= declares a new local. With =, the closure would still write to the captured\nparameter.\n\nI find this one especially nasty because there's no goroutine in the source. The goroutines\nare added by net/http when it dispatches handlers.\n\nFrom [Uber's data race study]:\n\n> Developers are quite often unaware that a variable used inside a closure is a free\n> variable and captured by reference, especially when the closure is large. More often than\n> not, Go developers use closures as goroutines. As a result of capture-by-reference and\n> goroutine concurrency, Go programs end up potentially having unordered accesses to free\n> variables unless explicit synchronization is performed.\n\nLoop variables shared one slot before Go 1.22\n\nThe famous version of this is loop-variable capture:\n\nBefore Go 1.22 this printed the last value len(xs) times because every iteration shared\none v. It got [patched in go1.22], which gives each iteration its own copy. Eli Bendersky\nhas a [great explainer] of what was happening under the hood pre-1.22 if you're curious.\n\nGo 1.22 only changed loop-variable lifetime. Pointer captures and method values on\nlong-lived receivers still behave the way they always did, because the language can't tell\nthat you didn't mean exactly that.\n\nMethod values capture their receiver\n\ns.serverCrashMessage is a method value. Under the hood it's a closure that captures s\nthe same way any other closure captures a free variable. From the hegel-go skill again:\n\n> conn.crashMessageFn = s.serverCrashMessage captures s and reads s.logFile — any\n> field the method touches is shared state.\n\nIf serverCrashMessage reads s.logFile, the resulting function value carries a live\npointer to s and re-reads s.logFile every time it's called. [Bendersky's article] walks\nthrough the same gotcha with a Show() method on a pointer receiver: go m.Show() shares\nthe receiver across goroutines, and nothing at the call site warns you.\n\nWhen you can't just snapshot\n\nIf the closure genuinely needs to see live state, leave it as a pointer and guard the reads\nwith the same mutex (or atomic, or channel) that the writers use. That's a different choice\nwith a different cost (more synchronization, fewer surprises) and you should make it on\npurpose.\n\nA few things that help spot the bug when snapshots aren't an option:\n\n- When a callback or method value lands on a long-lived struct, ask: which fields does this\n  read? Write the answer next to the field declaration.\n- If a closure only ever needs primitives, prefer passing them in as values rather than\n  reaching through a pointer.\n- go vet's loopclosure checker still catches the loop-variable case in pre-1.22 modules.\n  It cannot catch the broader struct-capture case.\n- The race detector (go test -race) catches the concurrent ones. It can't catch\n  single-threaded \"wrong value at the wrong time\" bugs like the log-rotation example.\n\nDrop the rule into your agent's prompt\n\nPasting the two sentences into your AGENTS.md, CLAUDE.md, or whatever your agent reads\nis often enough:\n\n\n\n\n[Go playground]:\n    https://go.dev/play/p/kdjbeNhdZ1i\n\n[capture-by-reference]:\n    https://go.dev/ref/spec#Function_literals::text=Function%20literals%20are%20closures%3A%20they%20may%20refer%20to%20variables%20defined%20in%20a%20surrounding%20function.%20Those%20variables%20are%20then%20shared%20between%20the%20surrounding%20function%20and%20the%20function%20literal%2C%20and%20they%20survive%20as%20long%20as%20they%20are%20accessible.\n\n[spec]:\n    https://go.dev/ref/spec#Function_literals::text=Function%20literals%20are%20closures%3A%20they%20may%20refer%20to%20variables%20defined%20in%20a%20surrounding%20function.%20Those%20variables%20are%20then%20shared%20between%20the%20surrounding%20function%20and%20the%20function%20literal%2C%20and%20they%20survive%20as%20long%20as%20they%20are%20accessible.\n\n[hegel-go]:\n    https://github.com/hegeldev/hegel-go\n\n[go-concurrency]:\n    https://github.com/hegeldev/hegel-go/blob/main/.claude/skills/go-concurrency/SKILL.md#function-closures-capturing-mutable-references\n\n[closures running as goroutines]:\n    https://go.dev/doc/faq#closures_and_goroutines\n\n[Off to the Races]:\n    https://research.swtch.com/gorace\n\n[Uber's data race study]:\n    https://www.uber.com/en-US/blog/data-race-patterns-in-go/\n\n[reproduced this exact bug]:\n    https://gaultier.github.io/blog/a_subtle_data_race_in_go.html\n\n[great explainer]:\n    https://eli.thegreenplace.net/2019/go-internals-capturing-loop-variables-in-closures/\n\n[Bendersky's article]:\n    https://eli.thegreenplace.net/2019/go-internals-capturing-loop-variables-in-closures/\n\n[patched in go1.22]:\n    https://go.dev/blog/loopvar-preview",
  "title": "Go quirks: function closures capturing mutable references"
}