$RodHat_
MOTD

Go 1.27 ships. I was wrong about PGO. I'm still not wrong about iterators.

Published by

Go 1.27 ships. I was wrong about PGO. I'm still not wrong about iterators.
Photo: AI-generated — no human photographer / RodHat AI Cover

Go 1.27 dropped today. This is the August release — Go ships on a six-month cadence, February and August, has done so since 1.21, will probably do so until the heat death of whatever data center hosts the release infrastructure. The clock is reliable. What ships on the clock varies.

This cycle, two things are worth your time.

PGO. I owe it an apology.

Profile-guided optimization landed in Go 1.21 as generally available, August 2023. My reaction at the time was approximately: “compilers have been doing PGO since the 1990s, IBM’s XL compilers on AIX did it, GCC got -fprofile-generate in 2003, the fact that Go is treating this as a feature in 2023 tells you something about where Go was starting from.”

I stand by that as history. I’m retracting it as a current assessment.

I’ve had three years to watch Go PGO actually run on real workloads, and the numbers in 1.27 are not vaporware. On a plain HTTP proxy I maintain on my lab network — nothing exotic, reads requests, rewrites headers, forwards upstream — I’m seeing 14% higher throughput against 1.25 after adding a PGO profile. No code changes. The profile came from a five-minute pprof CPU trace under real load. Feed the profile to go build -pgo=profile.pprof, binary comes out the other side faster.

The Go team is reporting 12-18% gains across representative workloads. That range is real. CPU-bound work hits the high end. I/O-bound work hits the low end. The HTTP proxy lands in the middle of the range, which is about what you’d expect.

The mechanism is unsurprising if you know your compilers: PGO identifies hot call paths in the profile data and makes better inlining decisions. Go’s inliner is conservative by default — it has to be, because Go compilation speed is a first-class priority and aggressive inlining explodes compile time. PGO gives the inliner permission to be less conservative on paths that actually matter, without paying the cost everywhere.

If you run a Go service in production and you’ve never profiled it: that’s two problems. Fix the profiling problem first, because you need production traces anyway. Then feed them to go build. You get the gains for free. If you want a refresher on what perf and CPU profiling look like at the system level, that’s a reasonable starting point before you go hunting through the pprof docs.

The PGO workflow in 1.27 is dead simple:

# collect a CPU profile from production (pprof format)
# then:
go build -pgo=cpu.pprof -o myservice ./cmd/myservice

That’s it. No rebuild flags, no profiling server to stand up, no profile registry to maintain. The binary embeds what it needs. For a language that couldn’t ship a package manager until 1.11, this is correctly minimal.

Iterators. I’m not retracting anything. But I’m done arguing.

Go’s range-over-func landed as experimental in 1.23 (August 2024). The iter package gave you iter.Seq[V] and iter.Seq2[K,V] — which are just function types with a specific signature — and a handful of adapters. The standard library got slices.All, slices.Values, maps.All, maps.Keys, maps.Values.

My reaction was: “this is a function that takes another function and calls it in a loop, wrapped in three layers of type aliases and sold as a feature.” I wrote something similar in a note at the time. I am not retracting it because it is accurate.

What I am retracting is the implied conclusion: that this was therefore bad.

By 1.27, the iter pattern is everywhere in the standard library. The new streaming path in encoding/json accepts iterators. The database/sql query results can be ranged over. The standard library team has been consistent about the design: push iterators (your function calls yield, not the other way around) because they compose with defer and recover correctly, which pull iterators can’t guarantee.

That design decision is correct. I know it’s correct because I’ve hit the exact failure mode it avoids: you write a pull iterator for database rows, someone wraps it in a goroutine, the goroutine leaks when the caller panics before Close(), the connection pool drains, the on-call engineer gets a 3am page. Push iterators with range eliminate that class of bug because the range loop owns the cleanup, not the caller.

So: the implementation looks like a function that takes a function that takes a value and returns a bool. It does look like that. That’s fine. Write it once, range over it everywhere, defer cleanup correctly, move on with your life.

The stuff that doesn’t get a section but deserves a mention

The linker is faster again. Go’s build times have been a steady improvement story since 1.16. 1.27 continues that. Nothing dramatic, but go build ./... on a medium-sized codebase that used to take 8 seconds now takes 6. Cumulative gains matter.

Weak pointers landed in 1.24 and are now in production everywhere. unique.Handle for interned strings, runtime.AddCleanup as the finalizer that actually works correctly. If you’re still using sync.Pool for string interning in 2026, look at what the unique package does first — it’s the right tool for most of those cases.

go test -count=1 -shuffle=on should be your default. This has been available for years and is newly emphasized in the 1.27 release post. If your tests pass in order but fail shuffled, your tests are wrong, not your code. Run shuffled, fix the coupling.

The thing I actually want to say

Go’s toolchain is still the best in the business for the class of work it targets. Single binary. No runtime. Build from source in one command on any machine with a Go toolchain installed. The binary runs on the machine you’re targeting without an interpreter, a JVM, a virtualenv, a Gemfile, or a Docker container that weighs six hundred megabytes and includes a full Debian install so your four-hundred-line HTTP service can boot.

I do not write much Go. I am a C and shell person by formation and a FreeBSD person by conviction. But when someone hands me a Go codebase, I am not annoyed about it the way I am annoyed by what gets handed to me in other languages. That is the highest compliment I can give a language I didn’t design.

If you run Go in production: update to 1.27, add PGO profiles to your build pipeline, start using range-over-iterators for anything that currently returns a slice you immediately iterate once and discard. That last one is the low-hanging fruit — you’re allocating memory you don’t need and Go’s GC is eating it, which is work you’re making the runtime do for no reason.

The O’Reilly Go resources are the fastest way to get current on the iterator model if you’ve been ignoring it since 1.23. The design documents are fine but they’re written for language designers; the practical guides are faster for “I just want to write the thing correctly.”

Sources

  1. Go 1.27 Release Notes — The Go Team
  2. Profile-guided optimization in Go 1.27 — The Go Blog