Modernizers & go fix

Redowan Delowar July 4, 2026
Source

Go 1.26 rebuilt go fix from scratch. If you haven't tried it yet, give it a spin: it rewrites the code in your module to use modern language and library features.

It has quickly become one of my favorite features, and LLMs are a big part of why. Models tend to use old APIs, and sometimes they deny that a new API exists even when you point them to it. Coaxing a model is non-deterministic. go fix is a better way to keep code on the latest features of the language. Run it locally or in CI and the dated idioms get rewritten deterministically.

Alan Donovan's GopherCon talk argues that future models train on today's open-source Go, so the corpus itself needs modernizing too.

A proper revival

go fix is almost as old as Go. Before the Go 1 compatibility promise, the language and standard library changed incompatibly all the time. Early adopters ran gofix to mechanically patch their code after each weekly snapshot. Then Go 1 froze the language, and there was nothing left for gofix to patch. The command stayed in the toolchain for over a decade until the last of its hardcoded rewrites was finally removed.

Go 1.26 brought it back, rebuilt on the analysis framework that powers go vet. Currently go fix ships with 22 analyzers. Most of them are "modernizers": each one recognizes a specific dated idiom and rewrites it with the feature that replaced it.

[!Note]

Analyzers are the programs go fix runs to transform your code. Analyzers that modernize your code are also called modernizers. But there's another tool, modernize, that has even more of these analyzers. A later section covers the difference between go fix and modernize.

Using go fix to modernize Go code already walks through the command. What I want to add is how the pieces fit together: the analyzers, the two different ways to run them, and what it takes to migrate your own APIs.

Running it

Start from a clean git state, so the resulting change contains nothing but what the tool did. Then preview:

Here's a file that uses interface{}, a three-clause loop, manual string splitting, and a pointer helper:

On a module whose go.mod says go 1.26, go fix -diff prints this:

Five analyzers fired on one small file. In the order their changes appear in the diff:

  • newexpr noticed ptr is a "new-like" helper and rewrote its body at the top of the file to use Go 1.26's new(expr), which takes a pointer to a value in one step. The ptr(30) and ptr("api") call sites in main were rewritten the same way
  • stringsseq replaced ranging over strings.Split in parse with Go 1.24's strings.SplitSeq, which skips allocating the slice
  • stringscut replaced the strings.Index call and the manual slicing under it with Go 1.18's strings.Cut
  • rangeint turned the three-clause loop in main into Go 1.22's for i := range 3
  • any swapped interface{} for any on the last variable

That diff has one more change: the //go:fix inline directive newexpr wrote above the helper. It marks ptr as a wrapper whose calls should be replaced by new(...). Directives like that are how go fix migrates whole APIs. In this diff the directive does nothing: newexpr rewrote the call sites itself, and the separate analyzer that acts on these directives doesn't handle generic functions yet.

go fix also respects the Go version in your module. Flip that go.mod line to go 1.21 and rerun, and the range 3, SplitSeq, and new(expr) rewrites all disappear. Only the any and strings.Cut fixes remain, because both arrived in Go 1.18.

Each modernizer knows which release introduced its target feature and skips files below that version. The version can come from go.mod or from a //go:build go1.N constraint. New toolchains ship new modernizers and unlock more rewrites, so the Go team suggests rerunning go fix ./... after every upgrade.

When the diff looks right, apply it and then run the tool again:

Applying one fix can make another one applicable. The Go blog calls these "synergistic fixes", and twice is usually enough to catch them. Fixes can also overlap: when two rewrites touch the same lines, go fix applies one, drops the other, and warns you to run it again. A final pass removes imports the fixes left unused.

Generated files never get touched. If a generated file has dated idioms, fix the generator.

[!NOTE]

Two fixes can also clash without touching the same lines. Say a variable has exactly two uses and each fix removes one. Apply both and the variable ends up unused. In Go that's a compile error. These conflicts are rare and usually fail the build, which makes them hard to miss.

You don't have to run every analyzer at once. Each one gets a flag:

Run one analyzer per PR and each diff does exactly one thing instead of one giant mixed cleanup patch. Negation works too: -any=false runs everything minus the one analyzer you're not ready for.

[!NOTE]

A go fix run only sees the files your current platform would compile. A file tagged //go:build windows doesn't get fixed when you run on Linux. Repeat the run with different GOOS and GOARCH values if you ship platform-specific code. Also, go fix only rewrites your own module. Dependencies and vendored packages get read for type information but are never edited.

-diff has a second job: it exits non-zero when a fix is pending, so go fix -diff ./... doubles as a CI check.

What the modernizers cover

The first demo covered strings and loops. The modernizers also handle slices and concurrency:

Three more analyzers fire on it:

Top to bottom:

  • slicescontains replaced the membership loop in contains with slices.Contains
  • waitgroup rewrote the wg.Add(1), go, defer wg.Done() sequence in process into Go 1.25's wg.Go. The manual version of that bookkeeping is easy to get wrong
  • slicessort turned the sort.Slice closure in main into slices.Sort

The rewrites also swapped the sort import for slices.

Those two demos covered eight analyzers. go tool fix help prints the full roster:

go tool fix help minmax prints the full docs for one analyzer, examples included.

The Go team keeps adding analyzers. Candidates pile up on the modernizer tracking issue, and whenever a new feature gets approved, they consider shipping a modernizer with it.

What powers it

None of the machinery behind the rebuild is new. In 2017 the Go team split go vet into two layers:

  • analyzers: the algorithms that inspect code and produce the findings and fixes
  • drivers: the programs that load packages and run analyzers over them

The result was the analysis framework. Write an analyzer once and any driver can run it: go vet, gopls as you type, Bazel's nogo, or a binary of your own.

The rebuild made go fix one more driver on this framework. That makes go vet and go fix almost the same program. Per the Go blog:

The only differences between them are the criteria for the suites of algorithms they use, and what they do with computed diagnostics. Go vet analyzers must detect likely mistakes with low false positives; their diagnostics are reported to the user. Go fix analyzers must generate fixes that are safe to apply without regression in correctness, performance, or style; their diagnostics may not be reported, but the fixes are directly applied.

The convergence shows up in the flags too. Go 1.26's go vet gained its own -fix for applying the safe fixes attached to vet diagnostics. The help text of go tool fix says it plainly: report-style analyzers belong behind go vet -vettool, and fix-style analyzers behind go fix -fixtool. That -fixtool flag is also how you run a custom fixer.

go fix or modernize?

The modernizers are developed as a suite in the modernize package. The suite updates continuously, and each Go release freezes a copy into the toolchain. Go 1.26's copy is the 22-analyzer roster you saw from go tool fix help: eighteen modernizers plus the inline, buildtag, hostport, and plusbuild analyzers.

You run the standalone modernize command directly with go run:

Its help lists mostly the same analyzers plus five extras:

None of the five are in Go 1.26's go fix yet. I find it a bit strange that errorsastype didn't make the cut: it rewrites errors.As to the errors.AsType[T] API that Go 1.26 itself introduced. The newest Go's fixer can't apply a fix for the newest Go API. If you want that rewrite today, you have to run modernize. My guess is it lands in Go 1.27's toolchain.

[!WARNING]

modernize comes with fewer guarantees than go fix. Its docs call it "not an officially supported interface", and running it at @latest means the analyzers can change under you with every x/tools release. Read the diff before merging.

Three modernizers are missing from both go fix and the standalone modernize command. Each got pulled because its fix could change behavior, the one thing a fix must never do:

All three still exist in the modernize package as exported analyzers, disabled by default. If the behavior change doesn't bother you, wrap one in the x/tools unitchecker driver that go fix itself is built on:

Build it and pass the binary to go fix:

-fixtool swaps the toolchain's fix tool for yours, which means this run applies bloop and nothing else.

The modernizers themselves have bugs. The Go team ran them across the standard library during development and produced a long bug list. mapsloop was still getting caught last November, when it turned a valid loop into a maps.Copy call that doesn't compile.

Neither tool has a per-line ignore comment. Disable the offender with its flag and file a bug.

Analyzers also arrive through gopls, which ships new modernizers first and sometimes suggests fixes your toolchain's go fix won't apply yet. There's an open proposal to fold a subset of staticcheck's analyzers into go fix. The Go blog expects it in Go 1.27. And fmtappendf shipped in 1.26, but x/tools has since dropped it from the suite because its fix didn't clearly improve the code.

Run go fix by default. The release notes say it outright: a fixer that changes your program's behavior is a bug to report.

Migrations with //go:fix inline

So far all the rewrites target the language and the standard library. The same machinery works on your own APIs too.

The first tool for that is the //go:fix inline directive. The Go blog covers it in depth in //go:fix inline and the source-level inliner. Say your library renamed greet.Hello to greet.Greet. Keep the old name as a one-line wrapper, deprecate it, and annotate it:

Nothing breaks. When a user of your library runs go fix ./...:

It beats deprecating something and hoping everyone reads the changelog. gopls honors the directive too. Callers see "Call of greet.Hello should be inlined" right in the editor and can apply it as a quick fix. gopls can also inline a call on demand, even without a directive.

The deprecated golang.org/x/net/context package carries these annotations today. Run go fix on anything that still imports it and the calls move to the standard context. Its var aliases like Canceled stay behind, because the directive doesn't work on variables.

The directive handles more than renames. Say the new API added a parameter the old one hardcoded, and a type got a better name in the same release:

One go fix run migrates a caller off both:

The wrapper's hidden default is spelled out at each call site, and the time import gets added automatically. This is not textual substitution. A wrapper can reorder parameters or forward to another package. That's how ioutil.ReadFile calls become os.ReadFile.

The directive works on functions, type aliases, and constants. Only exported, package-level symbols migrate across packages: an unexported helper's calls get rewritten only inside its own package. A function needs a body, a type has to be a true alias, and a constant has to refer to another named constant. A call inside the wrapper's own dedicated test, like TestHello for Hello, doesn't get rewritten.

Annotate a whole deprecated package like that and eventually nobody imports it, so you can delete it. The x/tools deadcode command finds the wrappers nobody calls anymore.

The inliner refuses to inline a callee that contains defer rather than wrap the body in a function literal. And it doesn't handle generics yet, as the ptr helper showed.

When a clean substitution isn't safe, go fix skips the call silently. The one exception is opt-in: -inline.allow_binding_decl accepts rewrites that keep argument order safe with an extra var params = args line. Directive mistakes are silent too. Run go fix -json to see them.

What inline can't do

The inliner has a hard boundary: it can only put at the call site what the wrapper's body already contains. It has no way to use what's already in scope there.

The classic case is adding a context.Context parameter. Your library's store.Get(key) needs to become store.GetContext(ctx, key). The forwarding wrapper has no ctx to forward, so the best it can write is:

When a caller runs go fix, the wrapper's body gets copied to every call site, context.Background() included:

There's a ctx in scope one line up, and the rewrite ignores it. Every call site now carries context.Background(), and nothing marks the ones that need attention. context.TODO() is probably a better candidate here.

The fix is to use the ctx in scope at each call site. No annotation on the old function can express that, but a custom analyzer can. go fix runs it through the -fixtool flag. I'll cover how to write one in a separate post.

You can get a lot done with just the built-in analyzers. I ran the new go fix on a large RPC service at work, and newexpr alone cleaned up a pile of pointer helper calls that had been accumulating for years. Extremely satisfying.

Discussion in the ATmosphere

Loading comments...