Go 1.22 is a bigger release than its changelog suggests, because one of its changes fixes a bug that has been costing people money for a decade.
NOTE
Written in February 2024, shortly after the release. Two things have moved on since: the rangefunc experiment described below shipped properly in Go 1.23 along with the iter package, and math/rand/v2 has settled in as the default recommendation for new code. The rest still stands. Version-specific details in old posts age badly, so check the release notes (opens in a new tab) before relying on any of it.
Loop variables, finally
Before 1.22, a for loop declared its variables once and reused them across every iteration. Every closure capturing the loop variable captured the same variable, so by the time any of them ran, they all saw the final value.
This produced the single most common bug in Go:
for _, item := range items {
go func() {
process(item) // pre-1.22: every goroutine may see the same item
}()
}The fix was item := item at the top of the loop body — a line that looks like a no-op and is not, which is roughly the worst possible ergonomics for a required workaround.
In Go 1.22 each iteration gets its own variables, and the bug is gone.
IMPORTANT
This only applies if the module declares go 1.22 or later in its go.mod. That is what makes a language semantics change safe to ship: old modules keep the old behaviour, and you opt in by bumping the version.
It also means upgrading your toolchain does not fix this for you. Bumping go.mod does.
It is worth being clear about how expensive the old behaviour was. Let's Encrypt revoked three million certificates (opens in a new tab) in 2020 because of a bug that came down to exactly this. It was not an obscure trap; it caught people who knew about it.
Ranging over integers
A small ergonomic change in the same release:
for i := range 10 {
fmt.Println(i)
}Equivalent to for i := 0; i < 10; i++, and considerably harder to typo.
This arrived alongside an experiment, GOEXPERIMENT=rangefunc, that allowed ranging over functions — the foundation for user-defined iterators. That experiment graduated in Go 1.23, which added the iter package and made range-over-function a normal part of the language.
Routing in the standard library
Go's standard library is unusually complete, but net/http's router was a real gap. http.ServeMux matched prefixes and nothing else — no path parameters, no method matching. Every non-trivial service pulled in a third-party router for what felt like it should be built in.
1.22 fixed it:
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/users/{user_id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "User ID is %q", r.PathValue("user_id"))
})Methods in the pattern, wildcards in the path, and r.PathValue to read them back.
This does not replace a full router — no middleware chaining, no route groups — but it covers what most services actually need, and it removes a dependency from a lot of projects.
There is a compatibility note: pattern matching and escaped-character handling changed, so a few existing routes may behave differently. GODEBUG=httpmuxgo121=1 restores the old behaviour while you migrate.
math/rand/v2
The first v2 package in the standard library, and a useful precedent for how Go intends to fix APIs it cannot change in place.
Two changes worth knowing:
Read is gone. It existed in math/rand and was routinely misused for anything security-sensitive. If you need random bytes that matter, crypto/rand.Read is the one you want — and always was.
There is a generic N function that works with any integer type, replacing the family of Int64N and Uint64N variants.
Seeding also changed. rand.Seed was deprecated in Go 1.20 — the global source is seeded randomly at startup, so seeding it by hand is unnecessary and usually makes things worse. For a reproducible sequence, construct your own:
r := rand.New(rand.NewPCG(1, 2)) // deterministic
fmt.Println(r.IntN(100))Performance, for free
Two changes that require nothing from you:
Runtime: 1–3% faster across the board, from keeping garbage collection metadata closer to the objects it describes. Better locality, less memory, no API change.
PGO: 2–14% faster with profile-guided optimization enabled. If you are not using PGO, it is worth a look — collect a profile from production, commit it as default.pgo, and the compiler optimises against your real workload rather than a guess.
What the release says
Nothing here is a headline feature. There is no generics-scale addition, and the release notes read as maintenance.
But the loop variable fix ends a decade of a subtle, expensive bug, and it does so without breaking anything, using the go.mod version as the switch. That mechanism is arguably the more important thing on display: it means Go can now fix semantic mistakes rather than living with them forever.
For a language whose main promise is that your code will still compile in ten years, working out how to change the language without breaking that promise is a bigger deal than any single feature.