Engineer at a laptop and monitor reviewing source code during a Go toolchain upgrade

Go 1.26: runtime and toolchain changes

October 28, 2026 · 9 min read · By Thomas A. Anderson

If your service decodes JSON on every request, Go 1.26’s default collector is now marking those per-request structs a chunk of memory at a time instead of one object at a time. Green Tea is the default. That is where most of this release’s runtime payoff sits, and it is the one item worth benchmarking on your own package before you bump the version.

The before and after are concrete. A handler that allocates a request struct, two slice-backed maps, and a small cache entry used to hand the collector a set of small objects that each needed individual marking work. The new collector processes the small objects in a span in batches, which is where the locality gain comes from. A service whose heap is a handful of multi-megabyte buffers gets nothing from this, because its marking cost was never concentrated in small objects.

The feature list is in the official Go 1.26 announcement; the ship date and cadence are in this release summary.

Green Tea GC becomes the default collector

Green Tea changes the unit of marking. Instead of tracking small objects one at a time, it processes them at the granularity of a chunk of memory, as described in this runtime evolution writeup. The design thread in the Go repository states the trade-off plainly: Green Tea has good locality when the application itself has good locality, unlike the previous collector (golang/go#73581).

What changed in Go 1.26 and where to read it

Read that twice, because it decides whether the swap helps you at all. Sequential allocation, which is what a JSON decoder and a per-request struct builder produce, exercises the chunk path well. A pointer graph that jumps across cold, scattered objects does not. The gain is real but workload-specific, which is why the r/golang thread asking whether anyone has benchmarked the new collector independently is the right instinct (thread). The release notes give a range, not a promise about your package.

So measure it as a two-leg benchmark on one host, with the collector swapped at build time and benchstat doing the comparison:

# Leg 1: Green Tea is the default on Go 1.26.
go test -run '^$' -bench BenchmarkIndexBatch -benchmem -count=10 ./indexer > tea.txt

# Leg 2: rebuild the same package with the pre-1.26 collector.
GOEXPERIMENT=nogreenteagc go test -run '^$' -bench BenchmarkIndexBatch -benchmem -count=10 ./indexer > nogreentea.txt

# benchstat ships with golang.org/x/perf
go install golang.org/x/perf/cmd/benchstat@latest
benchstat tea.txt nogreentea.txt

# benchstat prints an old/new delta with a confidence band. Anything inside
# roughly +/-2% on a shared CI runner is noise, not a GC win.
# Note: this measures one package with one allocation profile. Clear the
# build cache with "go clean -cache" between legs if results look odd,
# and run both legs on the same machine.

Each leg compiles the test binary with a different collector configuration, runs ten iterations, and writes the results to a file; benchstat then reports the delta. Two details bite people here. GOEXPERIMENT is a build-time switch rather than a runtime GODEBUG, so you cannot flip collectors per deployment without rebuilding the binary. And -count=10 is not optional if you want the confidence band to mean anything.

The opt-out has a shelf life. Production notes on Green Tea describe GOEXPERIMENT=nogreenteagc as a window for validating a rollout rather than a permanent setting, so plan to delete it from your build scripts once the benchmark and the p99 latency graph agree.

Key Takeaways:

  • Green Tea, the chunk-granularity collector, is the default in Go 1.26. Build with GOEXPERIMENT=nogreenteagc while you measure, then remove it.
  • The win lands on services with many small, short-lived allocations. Large-buffer heaps see little change.
  • new(expr) removes the throwaway helper variable Go code needed for every pointer field in a config struct.
  • go fix was rebuilt on the analysis framework and reuses the modernize analyzers from gopls, but it rewrites files in place and respects each module’s go directive.

new(expr) and the smaller language changes

The language change with the widest day-to-day reach is tiny: new() now accepts an expression, not just a type, and returns a pointer to a copy of that value. The old spelling required a helper variable for every pointer field, which is why config structs in Go tend to grow a block of locals before the real assignment.

Note: The following code is an illustrative example and has not been verified against official documentation. Please refer to the official docs for production-ready code.

package main

import "fmt"

type RetryPolicy struct {
	MaxAttempts int
	BackoffMs int
}

type ServiceConfig struct {
	Name string
	Retries *RetryPolicy // nil means "use the platform default"
	Timeout *int
}

func main() {
	// Go 1.26: new() takes an expression and returns a pointer to a copy.
	policy := new(RetryPolicy{MaxAttempts: 5, BackoffMs: 250})
	fmt.Println(policy.MaxAttempts, policy.BackoffMs) // 5 250

	// The pattern this replaces: one helper variable per pointer field.
	timeout := 30
	cfg := ServiceConfig{Name: "billing", Retries: policy, Timeout: &timeout}
	fmt.Println(*cfg.Timeout) // 30

	// Go 1.26 lets you drop the helper entirely.
	cfg.Timeout = new(30)
	fmt.Println(*cfg.Timeout) // 30

	// Note: new(expr) copies. Mutating *cfg.Timeout later does not
	// touch the original "timeout" variable, which is usually what
	// you want in config structs and almost never what you want in
	// a hot loop shared across goroutines.
}

The copy semantics are the thing to catch in review. new(expr) is a convenience for pointer fields and table-driven tests; for a variable you intend to keep mutating, keep taking its address. For a shared counter or a struct guarded by a mutex, keep the explicit address.

The other spec changes are narrower. Recursive type constraints let a type parameter constraint refer to itself, which removes a class of workarounds in generic container libraries. Type-safe error checking gets a cleaner idiom. Both have runnable examples in the Go 1.26 interactive tour, which is a faster read than the release notes if you want the new behavior next to the old.

go fix is a refactoring tool again

The 1.26 release rewrote the go fix subcommand on top of the analysis framework, reusing the modernize analyzers that previously lived in the language server. The official go fix post describes two constraints that shape how you use it: it only applies rewrites valid for the Go version declared in the module’s go.mod, and it edits files in place.

# Commit before running anything that rewrites source.
git switch -c go1.26-modernize

# Run per module, not from the root of a multi-module repository.
cd services/billing
go fix ./...

# Read every hunk. A module still declaring go 1.21 gets fewer
# rewrites than one on go 1.26, by design.
git diff --stat

That last comment is the practical difference from the old command. Because the analyzers respect the module’s declared version, running go fix before you bump the go directive produces a smaller, safer diff than running it after. Teams coming from Go 1.20 or older get the most value: patterns that used to need manual find and replace are now applied automatically.

The //go:fix inline annotation extends the same machinery to your own code. Annotate a deprecated wrapper and go fix rewrites callers to the replacement:

package client

import "time"

type Client struct {
	addr string
	timeout time.Duration
}

// NewClient is kept for callers that have not migrated yet.
//
// Deprecated: use NewClientWithTimeout instead.
//
//go:fix inline
func NewClient(addr string) *Client {
	return NewClientWithTimeout(addr, 30*time.Second)
}

func NewClientWithTimeout(addr string, timeout time.Duration) *Client {
	return &Client{addr: addr, timeout: timeout}
}

// Note: //go:fix inline only rewrites calls the analyzer can type-check.
// Callers behind build tags, reflection, or generated code stay as-is,
// so grep for the old name before you delete the wrapper.

What changed in Go 1.26 and where to read it

Five changes account for most of the upgrade work. None of them require rewriting application code, and only the collector changes runtime behavior by default.

Change What it does Documentation
Green Tea GC Default collector; marks small objects at chunk granularity instead of one by one go.dev/blog/go1.26
new(expr) new() accepts an expression and returns a pointer to a copy Interactive tour
Recursive type constraints A type parameter constraint can refer to itself Release summary
Rewritten go fix Modernize analyzers from gopls, gated on the module’s go directive go.dev/blog/gofix
cgo call overhead Reduced per-call cost for code that crosses into C go.dev/blog/go1.26

Upgrading a service: the checklist

The release cadence is two feature releases per year, so the version you build with moves whether you plan for it or not. Four items are worth checking before you merge the bump.

  • The go directive in go.mod. CI security scanners read that line as a toolchain pin, so a stale directive shows up as a vulnerable dependency even when your builder image is newer. One Go project hit exactly this: an automated check flagged a go.mod line that pinned a specific toolchain version (kubestellar-mcp#1286). Keep the directive and the builder image in sync.
  • Vendor builds. Microsoft published its own toolchain build shortly after the release, adding a GODEBUG setting named ms_version that you can set at runtime or persist in the toolchain config (Microsoft Go blog, February 2026). If you build on Windows or ship FIPS-oriented binaries, that is the build to test rather than the upstream tarball.
  • Behavior changes via GODEBUG. Go still ships compatibility switches for runtime and library behavior changes, documented on the GODEBUG page. Read the compatibility notes for this release before you assume a passing test suite means unchanged behavior.

What Go 1.26 does not fix

Green Tea does not reduce the number of allocations your code makes, and it does not make a pointer-heavy design cheap. The locality dependence is the flip side of the chunk approach: a service that walks cold, scattered object graphs should not expect the same result as one that allocates sequentially. That is also why a benchmark you did not run yourself is not evidence about your service.

go fix is not a monorepo-wide button. It rewrites files in place, it will not touch calls it cannot resolve, and the set of applicable modernizers changes with each module’s declared Go version. Run it module by module, read the diff, and keep the commit separate from the version bump so a revert is one command.

Go 1.26 is a maintenance release with one structural change. The collector swap is real and worth benchmarking on your own allocation profile; new(expr) and the go fix rewrite save typing rather than redesigns; and the rest is the usual accumulation of small fixes that makes the next six months quieter than the last six.

Sources and References

Sources cited while researching and writing this article:

Thomas A. Anderson

Mass-produced in late 2022, upgraded frequently. Has opinions about Kubernetes that he formed in roughly 0.3 seconds. Occasionally flops, but don't we all? The One with AI can dodge the bullets easily; it's like one ring to rule them all... sort of...