A named return silently costs you Go's new append optimization

Go 1.27's runtimefreegc reaches 77 of 2,294 growing appends in the standard library. A temporary pointer rule blocks 224 more; the alias analysis never clears the other 1,994, and that is the gate nobody is lifting.

Go 1.27 ships an experiment, GOEXPERIMENT=runtimefreegc, that lets the runtime free a slice’s old backing array the moment append outgrows it, provided the compiler can prove nothing else refers to the slice. I built a toolchain with the experiment on, compiled the standard library, and counted where it fires: 77 of 2,294 growing-append sites, 3.36%. Two independent gates hold back the rest, and they are not the same size. A pointer rule the compiler marks “for now” blocks 224 sites and is on its way out. The alias analysis never clears the other 1,994, and nothing on the way lifts that one. One shape lands your own code in that larger group for a reason you would never guess from the source: declaring the result in the signature rather than the body. The compiler does not reject those slices. It never looks at them.

Everything below was measured on darwin/arm64, go1.27.0, on 2026-09-24 and 2026-09-25. The commands are the ones I ran.

What’s coming

The design doc is go.dev/design/74299-runtime-freegc, added to golang/proposal on 2025-11-14 and last revised 2025-12-17, and its summary of the goal is “directly freeing user memory to reduce GC work”. Go has no free. Memory a program is done with stays in the heap until a collection cycle finds it; anything the runtime can release without a cycle is work the collector never does.

The first shape the experiment goes after is the growing slice. Each time growslice allocates a bigger array and copies into it, the old one becomes garbage. The doc’s account is that the compiler runs an alias analysis, and “if so, the compiler automatically inserts a call to the new runtime function runtime.growsliceNoAlias”, “which frees the old backing memory after slice growth is complete and the old storage is logically dead”. Only the intermediate arrays go: “the final slice is not freed. It is returned to the caller”, so this works even when the result escapes.

Declare a slice, append to it in a loop, return it once: most functions that build a result look like this, which is why the experiment would matter if it reached them.

Trying it today

You have to build the toolchain. Setting the variable on a released Go does nothing, and nothing tells you so.

Alex Edwards’ Go Experiments Explained, 2026-06-01, says that if your application “leans heavily on the garbage collector, it may be worth benchmarking your code with this enabled”. I did that, on a released go1.27.0, with the build cache bypassed:

Terminal window
$ GOEXPERIMENT=runtimefreegc go build -a -gcflags=-S ./... 2>&1 | grep -c NoAlias
0
$ GOEXPERIMENT=runtimefreegc go test -a -run TestAllocs -v
no-alias allocs=5 aliased allocs=5

The first counts assembly lines mentioning NoAlias across every package. The second runs the test shown below: 100 appends in a loop, once with the slice kept local and once with a second reference held to it.

What makes this confusing is that the variable isn’t ignored everywhere. go env GOEXPERIMENT echoes it back and the goexperiment.runtimefreegc build tag activates; only the compiler ignores it. Its gate reads goexperiment.RuntimeFreegc, a constant in the compiler’s own goexperiment package that is baked in when the toolchain is built, so a released toolchain has it false and nothing in your environment changes that. Known since 2026-05-23 as golang/go#79631; the fix, CL 782180, was unmerged when I measured.

The toolchain that does have it is one command away, from a checkout of the Go source:

Terminal window
$ GOEXPERIMENT=runtimefreegc ./make.bash # ~60s on an M-series
$ go version
go version go1.27.0-X:runtimefreegc darwin/arm64
$ go test -a -run TestAllocs -v
no-alias allocs=1 aliased allocs=5

The version string is the tell, and -a forces a recompile rather than a cache hit, because the cache does not know the compiler changed. Same source, same Go version. The aliased slice still allocates five times, which is correct: the optimization only applies when nothing else can see the slice.

noAlias below is lifted from the Go team’s own TestFreeAppendAllocations/slice-no-alias; aliased is the same loop with a second reference kept alive:

main.go
func noAlias() []int64 {
var s []int64
for i := range 100 {
s = append(s, int64(i))
}
return s
}
func aliased() []int64 {
var s []int64
var alias []int64
for i := range 100 {
s = append(s, int64(i))
alias = s
}
_ = alias
return s
}
main_test.go
func TestAllocs(t *testing.T) {
n := testing.AllocsPerRun(100, func() { _ = noAlias() })
a := testing.AllocsPerRun(100, func() { _ = aliased() })
t.Logf("no-alias allocs=%v aliased allocs=%v", n, a)
}

testing.AllocsPerRun, 100 runs each, discards the first run and calls runtime.GC() before it measures. The test asserts nothing; it logs two numbers and passes either way. If you’d rather not trust my test, the Go team has one: cd src/cmd/compile/internal/test && go test -run=FreeAppendAllocations -v. Run either without -race: theirs skips under the race detector and the sanitizers because, in the comment’s words, “we get 8 allocs for slice-no-alias instead of 1 with -race” (free_test.go:20). With -race on you will measure 8 and 5 and conclude the experiment does nothing, which is this post’s own mistake one layer down.

A stale cache looks exactly like a broken feature

The first thing I saw after rebuilding the toolchain was zero. The same grep -c NoAlias, the same test, five allocations in both cases, exactly what the released toolchain had produced. I concluded that the feature was broken even when the toolchain was built the documented way.

That conclusion was wrong. Go’s build cache had served objects compiled by the previous toolchain. The source was unchanged and the flags were unchanged; the compiler was not, and the cache handed back the old objects anyway. There was no message. The output looked like a measurement, and I read it as one.

Adding -a to every compile made the NoAlias symbols appear immediately.

I’m keeping this in because it’s the step you’re about to take. When you measure the effect of a toolchain change, a stale cache and a dead feature produce byte-identical evidence: the same zero from grep, the same allocation count from the test. Only -a tells them apart, which is why every go build and go test in this post carries it.

What it reaches now

The 100-element loop shows what the optimization does when it fires, and nothing about how often it fires in code nobody wrote to demonstrate it. The standard library is the largest body of Go I can compile with one command, so I compiled it twice:

Terminal window
$ go build -a -gcflags=-S std 2>&1 # emitted call instructions
$ go build -a -gcflags=all=-d=escapealias=1 std # appends proven non-aliased

The first dumps the assembly of every package in std, so I can count calls to each growslice variant. The second turns on the alias analysis’s own diagnostic, which logs every append it proves non-aliased, with a source position.

Counted by emitted call instruction:

runtime.growslice 4628
runtime.growsliceNoAlias 91
runtime.growsliceBuf 17
runtime.growsliceBufNoAlias 1

92 of 4,737 growslice call instructions are a NoAlias variant: 1.94%, per instruction in the compiled output. That denominator over-counts from the source’s point of view: when a function containing an append is inlined, every caller gets its own copy of the call, and one source site becomes several instructions. Deduplicating by source file:line leaves 2,294 sites that emit a growslice, and 77 of them emit the NoAlias variant: 3.36%, per source site. Both percentages are true and measure different things. Both count call sites, where one that runs once at startup weighs the same as one in a hot loop; neither measures time. From here on I use source sites; the question is how much code the optimization reaches.

The source-site number comes from a join: the alias diagnostic reports a file:line for each append it clears, the assembly carries one for each call it emits, and I matched them on that key. Two appends on the same line would collapse into one site, so this is an estimate with a stated method rather than an exact census.

194 packages in std contain at least one growslice call and 33 contain at least one optimized one. Counted by instruction, the best ratios are in small packages: io/fs 7 of 15, net 8 of 50, testing/fstest 6 of 28. Some of the packages you would most want this to reach get nothing: fmt 0 of 124, encoding/json/v2 0 of 106, runtime/pprof 0 of 189. The three zeros look identical from the outside and have different causes.

Why so few

The second build splits the 2,294 sites. The alias analysis cleared 371 appends in std as non-aliased. Of those, 77 became a NoAlias growslice, 224 became a plain growslice, and 71 emitted no growslice at all. Those three add up to 372, one more than the 371 cleared, because crypto/tls/prf.go:276 emits both variants from a single line and lands in two buckets. That same overlap is why the site total is 2,294 rather than 2,295. The remaining 1,994 growslice sites were never cleared. So there are two gates, and most sites fall at the first.

Both are on one line, at cmd/compile/internal/ssagen/ssa.go:4067, with the comment between them elided:

cmd/compile/internal/ssagen/ssa.go
if goexperiment.RuntimeFreegc && n.AppendNoAlias && !et.HasPointers() {
// ...
fn = ir.Syms.GrowsliceNoAlias
}

The same condition appears twice: again at 4057, choosing GrowsliceBufNoAlias for the stack-buffer path. Both are in the counts below.

goexperiment.RuntimeFreegc is the toolchain constant from earlier; n.AppendNoAlias is the alias analysis and et.HasPointers() is the pointer gate.

Which gate dominates depends on the package. Here are the zeros from the previous section plus encoding/json/jsontext, counted by source site this time, so the denominators are smaller; fmt is 20 source sites behind 124 instructions:

Packagegrowslice sitesProven non-aliasedOf those, blocked by pointers
fmt2011
encoding/json/v23811
encoding/json/jsontext6800
runtime/pprof2855

In runtime/pprof the analysis cleared five sites and the pointer gate killed all five; that package is waiting on the runtime alone. In encoding/json/jsontext the analysis cleared nothing across 68 sites, so the pointer gate never came up. fmt and encoding/json/v2 are the same story with one exception each, one site cleared out of 20 and out of 38. The pointer restriction is the whole explanation in one of these packages and almost none of it in the other three.

In code, the two gates are four rules, which I checked one at a time on the 100-element loop:

ShapeEmitsOptimized
var r []int; …; return rgrowsliceNoAliasyes
func f() (r []int), a named returngrowsliceno
[]*int, element type has pointersgrowsliceno
alias = r anywhere in the loopgrowsliceno, 5 allocs

The alias row is what the name promises, and the pointer row is et.HasPointers().

The named-return row is the one that isn’t obvious. func f() (r []int) and func f() []int { var r []int; …; return r } read as the same function with different punctuation, and only the second qualifies. The reason is more mechanical than I expected, and it is not escape analysis deciding the slice is aliased. The pass that sets the flag lives in cmd/compile/internal/escape/alias.go, and a slice only becomes a candidate for the optimization when the analysis walks an ir.ODCL node whose name has Class == ir.PAUTO, a local variable declared in the body:

cmd/compile/internal/escape/alias.go
case ir.ODCL:
decl := n.(*ir.Decl)
if decl.X != nil && decl.X.Type().IsSlice() && decl.X.Class == ir.PAUTO {

A named result is ir.PPARAMOUT. It is declared by the signature, so there is no ODCL statement in the body to walk, and it fails on both counts at once: never registered as a candidate, never reachable by the rest of the pass. Compiling both shapes with -d=escapealias=2 shows it. The local version logs adding candidate slice: r and then proved non-aliased append: r; the named-result version logs nothing beyond entering the function. The pass never rejects the slice, because it never sees it.

A named return is a stylistic choice most Go programmers make without thinking, and it lands a site among the 1,994 with no diagnostic. How many of them it accounts for, these two builds can’t say.

The table is the same on amd64. Cross-compiling the same four shapes with GOOS=linux GOARCH=amd64 go build -gcflags="-S -l" emits growsliceNoAlias for the local-variable case and growslice for the other three, matching arm64 symbol for symbol. Worth checking, because the Go team’s asmcheck file annotates only // amd64:.

What happens when the restriction lifts

The pointer gate is the one that lifts. The compiler comment beside it cites “additional runtime support (CL 698515)” as what’s needed, and the sites it turned away have already passed the harder test, the alias proof: 224 of the 371 proven-safe appends, 60%, rejected for the element type alone.

Same denominator as before, same file:line method, same caveat. If CL 698515 lands and nothing else changes, the standard library goes from 77 optimized sites to 301, from 3.36% of growing appends to 13.12%, close to four times today’s count. That multiplier is a property of the library as a whole and of no package in particular. runtime/pprof goes from zero to five, all the analysis ever cleared there. fmt and encoding/json/v2 go from zero to one site each, out of 20 and 38. encoding/json/jsontext stays at zero across 68, because the gate that holds it is the one the CL doesn’t touch.

There are two gates, and the one that lifts is the smaller one. Across the standard library the pointer gate holds back 224 sites and the alias analysis holds back 1,994; CL 698515 opens the first and leaves the second where it is. How much of your code this reaches is decided by whether the alias analysis can see your slices, and today that means a slice declared in the body, never copied, and never a named result.