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.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.

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.serverCrashMessage captures s and reads s.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’s loopclosure checker 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.
§