From Go 1.22 to 1.27: Loop Variables, Routing, range-over-func, Green Tea GC, json/v2 and Generic Methods
In Go, the go line in go.mod and GODEBUG settings decide a release's behaviour, but removed settings end that protection: because Go 1.27 removed asynctimerchan, the timer behaviour of a module saying go 1.22 changed with nothing but a toolchain upgrade. The changes from 1.22 to 1.27 that matter for backend work, the nil check fix, os.Root, uuid, measuring the new allocator, what moving to json/v2 does to clients, and go fix modernizers.
Version note: The code in this post was built and run with Go 1.27.4; the Bookstore module's go line is 1.26.0 and the code also compiles with Go 1.26.11. For the comparisons, the Go 1.26.11 and Go 1.24.13 toolchains were also run through GOTOOLCHAIN. Labs that use packages only found in Go 1.27 are set apart with //go:build go1.27. Version details were checked against the release notes on go.dev and the Go 1.27.4 source code (November 2026).
Guess First
Bookstore, the online bookstore used as the shared example across this site's series, has an old helper service; its go.mod says go 1.22, and it contains this code that resets a timer:
package main
import (
"fmt"
"time"
)
func main() {
t := time.NewTimer(time.Millisecond)
time.Sleep(5 * time.Millisecond)
t.Reset(time.Hour)
select {
case <-t.C:
fmt.Println("stale value from the old timer")
default:
fmt.Println("no stale value")
}
}
Without changing a single character in the code or in go.mod, you only upgrade the toolchain from Go 1.26.11 to Go 1.27.4. Does the output stay the same?
$ GOTOOLCHAIN=go1.26.11 go run .
stale value from the old timer
$ go run . # Go 1.27.4
no stale value
It changed. Go 1.23 made timer channels synchronous: after Reset or Stop, no value left over from the old timer is read from the channel. But that behaviour was tied to a GODEBUG setting called asynctimerchan, and for modules whose go line was below 1.23 the old behaviour was the default. Go 1.27 removed the setting entirely; now everyone gets the new behaviour, whatever the go line says. Go's compatibility promise is strong, but it is not endless. In this post we look at the changes from Go 1.22 to 1.27 that a backend developer should know, and at how to do an upgrade: the go line, GODEBUG, go fix, and the move to json/v2 promised in post 10.
The go Line, GODEBUG and the Toolchain
Go's compatibility rule has been the same since Go 1: code written for Go 1 keeps compiling with later Go 1.x releases. When behaviour has to change (security, bug fixes), Go does it through GODEBUG settings, and it decides which setting is the default by looking at the go line in the module's go.mod. The line we saw in the go version -m output in post 13 is the result:
build DefaultGODEBUG=tracebacklabels=0,x509sslcertoverrideplatform=0
Bookstore is built with Go 1.27.4, but its go line is 1.26.0. So two new behaviours of 1.27 are off in this binary: pprof labels in stack trace headers, and the SSL_CERT_FILE/SSL_CERT_DIR environment variables changing the certificate pool on Windows and macOS. When I ran go mod edit -go=1.27.0 on a copy and rebuilt, the DefaultGODEBUG line disappeared entirely. You can change a single setting with a godebug line in go.mod or a //go:debug comment in the main package.
Removed settings are where this mechanism ends. When I added //go:debug asynctimerchan=1 to the code from Guess First, Go 1.26.11 gave the old behaviour and Go 1.27.4 stopped the build:
$ go run .
# timerdemo
./main.go:1:1: invalid //go:debug: removed GODEBUG "asynctimerchan" set to old value "1" (https://go.dev/doc/godebug#go-127)
With the same setting written into go.mod as godebug asynctimerchan=1, the error pointed at go.mod:5. According to the release notes, a removed setting can now only be written with its final default value. The list of which setting was added and removed in which release is on the GODEBUG page on go.dev.
The go line has one more job: it states the lowest Go version needed to build the module. When an older toolchain meets such a module, it downloads the newer toolchain and runs with it, depending on the GOTOOLCHAIN setting; that is also how I ran the Go 1.26.11 build check in this series. The important changes of the six releases for backend work:
| Release | Date | Highlights for backend work |
|---|---|---|
| 1.22 | February 2024 | a new loop variable per iteration, range over integers, ServeMux with methods and wildcards, math/rand/v2, sql.Null[T] |
| 1.23 | August 2024 | range-over-func iterators and iter, unique, synchronous timer channels |
| 1.24 | February 2025 | generic type aliases, the tool directive, os.Root, b.Loop, t.Context, Swiss Table maps, weak, omitzero |
| 1.25 | August 2025 | container-aware GOMAXPROCS, testing/synctest, WaitGroup.Go, CrossOriginProtection, the nil check fix, json/v2 and Green Tea experiments |
| 1.26 | February 2026 | new(expr), Green Tea GC by default, errors.AsType, the new go fix, slog.NewMultiHandler |
| 1.27 | August 2026 | generic methods, encoding/json/v2, uuid, crypto/mldsa, the goroutineleak profile, the size-specialized allocator, macOS 13+ |
Go ships a major release every six months and supports each release until two newer majors are out. In November 2026 the supported releases are 1.26 and 1.27; 1.25 went out of support in August, when 1.27 shipped. When 1.28 arrives in February 2027, support for 1.26 ends. That is a shorter cycle than .NET's yearly releases with LTS/STS; in practice it means "a small upgrade every six months".
1.22: Loop Variables, ServeMux and math/rand/v2
The most important change in 1.22 is that a for loop creates its variable anew in every iteration. Before, all iterations shared one variable, and closures or goroutines created in the loop all saw the last value. This change is also tied to the go line; writing //go:build go1.21 at the top of a file lowers that file's language version to 1.21. labs/14-loopvar runs the same function in two files:
// titles collects one closure per book and calls them after the loop.
func titles() []string {
var fs []func() string
for _, t := range catalog {
fs = append(fs, func() string { return t })
}
var out []string
for _, f := range fs {
out = append(out, f())
}
return out
}
$ go run ./labs/14-loopvar
go1.22+: [Dune Emma Middlemarch]
go1.21: [Middlemarch Middlemarch Middlemarch]
C# 5 made the same change for foreach in 2012; in Go, that is why the tt := tt line from post 12 is no longer needed. The forvar modernizer of go fix removes those old lines. We used the ServeMux patterns from 1.22 in post 09 and sql.Null[T] in post 11. math/rand/v2 dropped the global seed and brought the generic rand.N function; in 1.27 the Rand type gained a generic method of the same name.
1.23 and 1.24: Iterators, Timers, os.Root
We covered the iterators of 1.23 in detail in post 06. The timer change in the same release is the source of the behaviour in Guess First; in addition, timers and tickers that were never stopped are now collected by the GC once nothing references them. The unique package keeps equal values in a single copy and turns comparison into a pointer comparison; it is meant for strings that repeat a lot (country codes, category names).
The most useful addition in 1.24 for backend work is os.Root. Joining a file name from the user with filepath.Join(dir, name) lets you leave the folder when name contains ../; this is called path traversal. A root opened with os.OpenRoot rejects every path that leaves it:
root, err := os.OpenRoot(covers)
if err != nil {
panic(err)
}
defer root.Close()
for _, name := range []string{"9780441172719.jpg", "../secrets.env"} {
// os.Open(filepath.Join(covers, name)) would happily open ../secrets.env.
pf, plainErr := os.Open(filepath.Join(covers, name))
if plainErr == nil {
pf.Close()
}
f, err := root.Open(name)
if err == nil {
f.Close()
}
fmt.Printf("%-20s os.Open: %v | root.Open: %v\n", name, errText(plainErr), errText(err))
}
}
$ go run ./labs/14-osroot
9780441172719.jpg os.Open: ok | root.Open: ok
../secrets.env os.Open: ok | root.Open: openat ../secrets.env: path escapes from parent
For an endpoint that serves cover images, that is a one-line change. In the same release the map implementation moved to Swiss Tables (no code change needed), the weak package brought weak pointers, generic type aliases were completed, and b.Loop and t.Context, which we used in post 12, arrived. The tool directive from post 02 and omitzero from post 10 are also from 1.24.
1.25 and 1.26: Containers, Tests and Green Tea
We measured the container-aware GOMAXPROCS of 1.25 in post 13; testing/synctest (08, 12), WaitGroup.Go (08) and CrossOriginProtection (09) are from this release too. The change that can surprise you during an upgrade is a compiler bug fix. From Go 1.21 to 1.24 the compiler wrongly moved some nil checks after the error check, and this code ran without a panic:
func coverName() (string, error) {
f, err := os.Open("covers/9780441172719.jpg")
name := f.Name() // Go 1.21-1.24: no panic here, the nil check was moved below
if err != nil {
return "", err
}
return name, nil
}
$ GOTOOLCHAIN=go1.24.13 go run .
error: open covers/9780441172719.jpg: no such file or directory
$ go run . # Go 1.27.4
panic: runtime error: invalid memory address or nil pointer dereference
The code was always wrong: it calls f.Name() while f is nil. Go 1.25 fixed the bug, and now the panic happens in the right place. If a service starts panicking after an upgrade from 1.24, look first for lines that use a result before the error check; the fix is to move the error check right below the call.
In 1.26 the Green Tea GC became the default (13), errors.AsType arrived (05), new can now take an expression (new(expr) in 03), slog.NewMultiHandler arrived (13), and go fix was rewritten. The behaviour of go mod init writing the previous release also came in 1.26; as we saw in post 02, it was reverted in 1.26.1, and the go mod init of Go 1.27.4 writes go 1.27.4.
1.27: Generic Methods, json/v2, uuid and the New Allocator
We saw generic methods in post 06: a method can now declare its own type parameters, but interface methods still cannot, and a generic method cannot satisfy an interface method. Let me repeat a detail we found in the same post: the broader type inference when assigning to a function type, unlike generic methods, is not tied to the go line. The Go 1.27 toolchain compiles that code with a go 1.26.0 line too, while Go 1.26.11 rejects it. If the code must also compile with the older release, building with that release in CI is a must.
The standard library gained a uuid package. The import path is just uuid:
func main() {
a, b := uuid.NewV7(), uuid.NewV7() // time-ordered: good as a database key
fmt.Println(a)
fmt.Println(b)
fmt.Println("v7 sorts by creation time:", a.Compare(b) < 0)
fmt.Println("v4:", uuid.NewV4())
if _, err := uuid.Parse("ORD-1001"); err != nil {
fmt.Println("parse:", err)
}
}
01a1dd17-1756-7800-acad-f6820339f332
01a1dd17-1756-786a-a9e3-f18df7e52c18
v7 sorts by creation time: true
v4: 86a01feb-6fcc-4376-8120-181686d2ef81
parse: invalid uuid
Version 7 UUIDs carry a timestamp in their first 48 bits, so they sort by creation time; used as a primary key in a database, new rows tend to be added at the end of the index. uuid.New() is currently the same as NewV4. In Bookstore, github.com/google/uuid has so far only been an indirect dependency of go-mssqldb; using the standard library in new code means one dependency less.
On the runtime side, the size-specialized allocator makes allocations smaller than 80 bytes cheaper. labs/14-alloc creates a 32-byte struct on the heap; I also ran the same benchmark with GOEXPERIMENT=nosizespecializedmalloc, which turns the new allocator off:
$ go test -run '^$' -bench . -benchmem -cpu 1 -count 3 ./labs/14-alloc
BenchmarkSmallAlloc 116146044 10.41 ns/op 32 B/op 1 allocs/op
BenchmarkSmallAlloc 120671779 9.973 ns/op 32 B/op 1 allocs/op
BenchmarkSmallAlloc 100000000 10.41 ns/op 32 B/op 1 allocs/op
$ GOEXPERIMENT=nosizespecializedmalloc go test -run '^$' -bench . -benchmem -cpu 1 -count 3 ./labs/14-alloc
BenchmarkSmallAlloc 77513540 13.59 ns/op 32 B/op 1 allocs/op
BenchmarkSmallAlloc 97752036 13.45 ns/op 32 B/op 1 allocs/op
BenchmarkSmallAlloc 84756985 13.32 ns/op 32 B/op 1 allocs/op
For this single allocation the difference is about 23%. The release notes expect an overall gain of around 1% in allocation-heavy programs; a cheaper allocation does not make the whole program that much faster. The cost is binary size: Bookstore's Linux binary is 9,216,160 bytes with the new allocator and 9,162,912 without it, about 52 KB (the release notes say about 60 KB). The goroutineleak profile, which we used as an experiment in post 08, is now generally available, and we saw it at /debug/pprof/goroutineleak in post 13. crypto/mldsa brings ML-DSA, a signature algorithm designed for the post-quantum era. On macOS, Go 1.27 needs at least macOS 13; nothing changed for Windows and Linux/amd64.
Moving to json/v2
In post 10 I said Bookstore's API stays on v1 and that we would discuss the move in post 14. In Go 1.27, v1 already runs on top of v2; in the CPU profile in post 13 we saw encoding/json/jsontext on the stack. So even without migrating you already get part of the performance. The real question is behaviour: what would v2's defaults do to clients? labs/14-jsonv2migrate sends the bodies from post 10 through the v1 logic of today's decodeJSON and through its v2 counterpart:
// decodeV2 does the same with json/v2. UnmarshalRead reads the whole input
// and rejects anything after the first value by itself.
func decodeV2(body []byte, dst any) error {
return json.UnmarshalRead(bytes.NewReader(body), dst, json.RejectUnknownMembers(true))
}
body 1
v1: ok, customerId="ada"
v2: ok, customerId="ada"
body 2
v1: ok, customerId="mallory"
v2: error: cannot unmarshal JSON string into Go main.placeOrderRequest: unknown object member name "CUSTOMERID"
body 3
v1: ok, customerId="mallory"
v2: error: jsontext: duplicate object member name "customerId"
body 4
v1: ok, customerId="ad�a"
v2: error: jsontext: invalid UTF-8 within "/customerId" after offset 17
body 5
v1: error: unknown field "coupon"
v2: error: cannot unmarshal JSON string into Go main.placeOrderRequest: unknown object member name "coupon"
body 6
v1: error: body must contain a single JSON object
v2: error: jsontext: invalid character '{' after top-level value after offset 32
The valid request (1) is the same on both sides. In 2, 3 and 4, v2 rejects bodies that v1 accepts but should not; the Mallory problem from post 10 closes by itself in v2. In 6 the single-value check we did with a second Decode is no longer needed, because UnmarshalRead reads the whole input. The cost is row 2: an old client that sends a field name in different case now gets a 400. If you have such clients, v1's matching can be brought back with an option, and the repeated field is still rejected:
package main
import (
"encoding/json/v2"
"fmt"
"strings"
)
type req struct {
CustomerID string `json:"customerId"`
}
func main() {
for _, body := range []string{`{"CustomerId":"ada"}`, `{"customerId":"ada","CUSTOMERID":"mallory"}`} {
var dst req
err := json.UnmarshalRead(strings.NewReader(body), &dst,
json.RejectUnknownMembers(true), // DisallowUnknownFields
json.MatchCaseInsensitiveNames(true), // keep v1's matching for old clients
)
fmt.Printf("%-45s %q %v\n", body, dst.CustomerID, err)
}
}
{"CustomerId":"ada"} "ada" <nil>
{"customerId":"ada","CUSTOMERID":"mallory"} "ada" jsontext: duplicate object member name "CUSTOMERID"
The text of error messages changes as well, and it contains Go type names (main.placeOrderRequest); in post 10 we said that sending the raw decoder message to the client is a problem, and with v2 that work can no longer be postponed. The plan for Bookstore: stay on v1 as long as Go 1.26 must be supported; when 1.28 ships and 1.26 goes out of support, raise the go line to 1.27, move decodeJSON to json.UnmarshalRead, map decoder errors to our own messages, and add bodies 2, 3 and 4 to the handler tests from post 12. On the response side v2 writes a nil slice as []; Bookstore already sends empty slices, so nothing changes there.
Modernizing with go fix
Rewritten in 1.26, go fix runs "modernizers" that use the analysis framework of go vet and move code to newer language and library features. In Go 1.27.4, go tool fix help lists 26 analyzers; 1.27 added atomictypes, embedlit, slicesbackward and unsafefuncs, and renamed waitgroup to waitgroupgo. To see what it would do before changing anything, use -diff:
$ go fix -diff ./...
--- /home/user/bookstore/labs/07-goroutines/main.go (old)
+++ /home/user/bookstore/labs/07-goroutines/main.go (new)
@@ -19,11 +19,9 @@
release := make(chan struct{})
var wg sync.WaitGroup
for range n {
- wg.Add(1)
- go func() {
- defer wg.Done()
+ wg.Go(func() {
<-release // park until main closes the channel
- }()
+ })
}
fmt.Printf("started %d goroutines in %v\n", n, time.Since(start).Round(time.Millisecond))
...
fix: omitzero: ignoring alternative fix "Replace omitempty with omitzero (behavior change)"
...
--- /home/user/bookstore/internal/httpapi/middleware.go (old)
+++ /home/user/bookstore/internal/httpapi/middleware.go (new)
...
@@ -13,8 +14,8 @@
// Chain wraps h so that the first middleware in the list runs first.
func Chain(h http.Handler, mws ...Middleware) http.Handler {
- for i := len(mws) - 1; i >= 0; i-- {
- h = mws[i](h)
+ for _, mw := range slices.Backward(mws) {
+ h = mw(h)
}
return h
}
(I shortened the output; there was also a waitgroupgo in labs/07-stockcheck, an errorsastype in labs/05-errors and an omitzero suggestion in labs/10-jsonv2.) The waitgroupgo I mentioned in post 08 did its job. Modernizers look at the file's Go version: wg.Go arrived in 1.25, so it was suggested in Bookstore with its go 1.26.0; when I tried the same pattern in a small module with a go 1.24 line there was no suggestion, and with the line at 1.25 there was. The omitzero analyzer does not apply a fix that would change behaviour on its own; it only removes the omitempty that has no effect. To review the diff as a pull request and apply it, go fix ./... without -diff is enough.
Production Notes
Split the upgrade into two steps. First upgrade only the toolchain (the CI image, the golang: tag in the Dockerfile) and leave the go line alone; the GODEBUG mechanism keeps behaviour largely the same, and if something breaks you only roll back the toolchain. Then raise the go line in a separate change; the new defaults and the new vet checks come in at that point. The DefaultGODEBUG line in the go version -m output from post 13 shows, in production too, which old behaviours a binary runs with.
Build with two releases in CI. The code of this series was compiled with Go 1.27.4 and Go 1.26.11 for every post; that is the only way to catch differences that are not tied to the go line, like the inference change from post 06. go vet changes with the upgrade too: the Go 1.27 check that catches wrapping a pointer-typed error with %w only runs in files whose language version is 1.27 or later.
Read the release notes down to the "Minor changes to the library" section. Behaviour changes such as HTTP/1 Response.Body reading unread content up to a limit on close in 1.27 are listed there; for a service that uses an HTTP client heavily, that can be a noticeable change.
Common Pitfalls
Raising the toolchain and the go line in the same commit. If something breaks, you cannot tell which one broke it.
Treating GODEBUG as a permanent fix. Every setting that brings back old behaviour has a lifetime; asynctimerchan came in 1.23 and went in 1.27. If you add a GODEBUG line, note the release that will remove it.
Applying go fix without review. Modernizers are designed to keep behaviour, but the diff should still go through code review, especially in packages with low test coverage.
Trying the new release only on your own machine. If the builder image in the Dockerfile, the CI runner and the developers' toolchains stay on different releases, "works on my machine" problems start. Adding a toolchain line to go.mod makes everyone use at least that release.
A Small Experiment
★☆☆ In a copy of Bookstore, run go mod edit -go=1.27.0, build, and look at the DefaultGODEBUG line in the go version -m output. Then add godebug tracebacklabels=0 to go.mod and look again.
★★☆ Apply the changes go fix -diff ./... suggests, then run go test -race ./... and build with Go 1.26.11. Does Chain with slices.Backward compile on 1.26?
★★★ In a copy of internal/httpapi, rewrite decodeJSON with encoding/json/v2 (add //go:build go1.27 to the file or raise the copy's go line to 1.27). Map decoder errors to your own messages based on *json.SemanticError and *jsontext.SyntacticError, and add bodies 2, 3 and 4 from this post to the handler tests from post 12.
This post is the last of the first fourteen planned posts of the series, but the series does not end here. So far we have covered the language, concurrency, a service with net/http, the database, tests and production; what is still missing are the standard library packages a C# developer stumbles over on the first day in Go. The next post starts with strings: strings and strconv, fmt verbs, bytes versus runes, and Turkish characters. After that come the time package and the "2006-01-02" layout, slices/maps/sort/cmp, file work with io.Reader/io.Writer, and finally an HTTP client with its timeouts and retries.