{
"$type": "site.standard.document",
"canonicalUrl": "https://rednafi.com/go/gofix/",
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreibz4kwi3yba6ehh7omf5irvknstbjgvvq6g72d4hmkpe55hg2mehu"
},
"mimeType": "image/png",
"size": 121470
},
"description": "Go 1.26 rebuilt go fix on the analysis framework. It modernizes your code and respects the Go version your module declares. The post covers the modernizers, the bigger x/tools suite, and what //go:fix inline can and can't migrate.",
"path": "/go/gofix/",
"publishedAt": "2026-07-04T00:00:00.000Z",
"site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
"tags": [
"Go",
"Tooling"
],
"textContent": "Go 1.26 rebuilt go fix from scratch. If you haven't tried it yet, give it a spin: it\nrewrites the code in your module to use modern language and library features.\n\nIt has quickly become one of my favorite features, and LLMs are a big part of why. Models\ntend to use old APIs, and sometimes they deny that a new API exists even when you point them\nto it. Coaxing a model is non-deterministic. go fix is a better way to keep code on the\nlatest features of the language. Run it locally or in CI and the dated idioms get rewritten\ndeterministically.\n\nAlan Donovan's [GopherCon talk] argues that future models train on today's open-source Go,\nso the corpus itself needs modernizing too.\n\nA proper revival\n\ngo fix is almost as old as Go. Before the [Go 1 compatibility promise], the language and\nstandard library changed incompatibly all the time. Early adopters ran gofix to mechanically\npatch their code after each weekly snapshot. Then Go 1 froze the language, and there was\nnothing left for gofix to patch. The command stayed in the toolchain for over a decade until\nthe last of its hardcoded rewrites was [finally removed].\n\nGo 1.26 brought it back, [rebuilt on the analysis framework] that powers go vet. Currently\ngo fix ships with 22 analyzers. Most of them are _\"modernizers\"_: each one recognizes a\nspecific dated idiom and rewrites it with the feature that replaced it.\n\n> [!Note]\n>\n> Analyzers are the programs go fix runs to transform your code. Analyzers that modernize\n> your code are also called modernizers. But there's another tool, modernize, that has\n> even more of these analyzers. A later section covers the difference between go fix and\n> modernize.\n\n[Using go fix to modernize Go code] already walks through the command. What I want to add is\nhow the pieces fit together: the analyzers, the two different ways to run them, and what it\ntakes to migrate your own APIs.\n\nRunning it\n\nStart from a clean git state, so the resulting change contains nothing but what the tool\ndid. Then preview:\n\nHere's a file that uses interface{}, a three-clause loop, manual string splitting, and a\npointer helper:\n\nOn a module whose go.mod says go 1.26, go fix -diff prints this:\n\nFive analyzers fired on one small file. In the order their changes appear in the diff:\n\n- [newexpr] noticed ptr is a \"new-like\" helper and rewrote its body at the top of the file\n to use Go 1.26's [new(expr)], which takes a pointer to a value in one step. The ptr(30)\n and ptr(\"api\") call sites in main were rewritten the same way\n- [stringsseq] replaced ranging over strings.Split in parse with Go 1.24's\n strings.SplitSeq, which skips allocating the slice\n- [stringscut] replaced the strings.Index call and the manual slicing under it with Go\n 1.18's strings.Cut\n- [rangeint] turned the three-clause loop in main into Go 1.22's for i := range 3\n- [any] swapped interface{} for any on the last variable\n\nThat diff has one more change: the //go:fix inline directive newexpr wrote above the\nhelper. It marks ptr as a wrapper whose calls should be replaced by new(...). Directives\nlike that are how go fix migrates whole APIs. In this diff the directive does nothing:\nnewexpr rewrote the call sites itself, and the separate analyzer that acts on these\ndirectives [doesn't handle generic functions yet].\n\ngo fix also respects the Go version in your module. Flip that go.mod line to go 1.21 and\nrerun, and the range 3, SplitSeq, and new(expr) rewrites all disappear. Only the any\nand strings.Cut fixes remain, because both arrived in Go 1.18.\n\nEach modernizer knows which release introduced its target feature and skips files below that\nversion. The version can come from go.mod or from a //go:build go1.N constraint. New\ntoolchains ship new modernizers and unlock more rewrites, so the Go team suggests rerunning\ngo fix ./... after every upgrade.\n\nWhen the diff looks right, apply it and then run the tool again:\n\nApplying one fix can make another one applicable. The Go blog calls these _\"synergistic\nfixes\"_, and twice is usually enough to catch them. Fixes can also overlap: when two\nrewrites touch the same lines, go fix applies one, drops the other, and warns you to run\nit again. A final pass removes imports the fixes left unused.\n\nGenerated files never get touched. If a generated file has dated idioms, fix the generator.\n\n> [!NOTE]\n>\n> Two fixes can also clash without touching the same lines. Say a variable has exactly two\n> uses and each fix removes one. Apply both and the variable ends up unused. In Go that's a\n> compile error. These conflicts are rare and usually fail the build, which makes them hard\n> to miss.\n\nYou don't have to run every analyzer at once. Each one gets a flag:\n\nRun one analyzer per PR and each diff does exactly one thing instead of one giant mixed\ncleanup patch. Negation works too: -any=false runs everything minus the one analyzer\nyou're not ready for.\n\n> [!NOTE]\n>\n> A go fix run only sees the files your current platform would compile. A file tagged\n> //go:build windows doesn't get fixed when you run on Linux. Repeat the run with\n> different GOOS and GOARCH values if you ship platform-specific code. Also, go fix\n> only rewrites your own module. Dependencies and vendored packages get read for type\n> information but are [never edited].\n\n-diff has a second job: it exits non-zero when a fix is pending, so go fix -diff ./...\ndoubles as a CI check.\n\nWhat the modernizers cover\n\nThe first demo covered strings and loops. The modernizers also handle slices and\nconcurrency:\n\nThree more analyzers fire on it:\n\nTop to bottom:\n\n- [slicescontains] replaced the membership loop in contains with slices.Contains\n- [waitgroup] rewrote the wg.Add(1), go, defer wg.Done() sequence in process into Go\n 1.25's wg.Go. The manual version of that bookkeeping is easy to get wrong\n- [slicessort] turned the sort.Slice closure in main into slices.Sort\n\nThe rewrites also swapped the sort import for slices.\n\nThose two demos covered eight analyzers. go tool fix help prints the full roster:\n\ngo tool fix help minmax prints the full docs for one analyzer, examples included.\n\nThe Go team keeps adding analyzers. Candidates pile up on the [modernizer tracking issue],\nand whenever a new feature gets approved, they consider shipping a modernizer with it.\n\nWhat powers it\n\nNone of the machinery behind the rebuild is new. In 2017 the Go team split go vet into two\nlayers:\n\n- [analyzers]: the algorithms that inspect code and produce the findings and fixes\n- [drivers]: the programs that load packages and run analyzers over them\n\nThe result was the [analysis framework]. Write an analyzer once and any driver can run it:\ngo vet, gopls as you type, Bazel's nogo, or a binary of your own.\n\nThe rebuild made go fix one more driver on this framework. That makes go vet and\ngo fix almost the same program. Per the Go blog:\n\n> The only differences between them are the criteria for the suites of algorithms they use,\n> and what they do with computed diagnostics. Go vet analyzers must detect likely mistakes\n> with low false positives; their diagnostics are reported to the user. Go fix analyzers\n> must generate fixes that are safe to apply without regression in correctness, performance,\n> or style; their diagnostics may not be reported, but the fixes are directly applied.\n\nThe convergence shows up in the flags too. Go 1.26's go vet gained its own -fix for\napplying the safe fixes attached to vet diagnostics. The help text of go tool fix says it\nplainly: report-style analyzers belong behind go vet -vettool, and fix-style analyzers\nbehind go fix -fixtool. That -fixtool flag is also how you run a custom fixer.\n\ngo fix or modernize?\n\nThe modernizers are developed as a suite in the [modernize package]. The suite updates\ncontinuously, and each Go release freezes a copy into the toolchain. Go 1.26's copy is the\n22-analyzer roster you saw from go tool fix help: eighteen modernizers plus the inline,\nbuildtag, hostport, and plusbuild analyzers.\n\nYou run the standalone modernize command directly with go run:\n\nIts help lists mostly the same analyzers plus five extras:\n\nNone of the five are in Go 1.26's go fix yet. I find it a bit strange that errorsastype\ndidn't make the cut: it rewrites errors.As to the errors.AsType[T] API that Go 1.26\nitself introduced. The newest Go's fixer can't apply a fix for the newest Go API. If you\nwant that rewrite today, you have to run modernize. My guess is it lands in Go 1.27's\ntoolchain.\n\n> [!WARNING]\n>\n> modernize comes with fewer guarantees than go fix. Its docs call it \"not an officially\n> supported interface\", and running it at @latest means the analyzers can change under you\n> with every x/tools release. Read the diff before merging.\n\nThree modernizers are missing from both go fix and the standalone modernize command.\nEach got pulled because its fix could change behavior, the one thing a fix must never do:\n\n- appendclipped rewrote append([]T{}, s...) to slices.Clone(s), but Clone [returns nil\n for an empty slice] and the append form never does\n- slicesdelete rewrote the append-based delete idiom to slices.Delete, which [zeroes the\n vacated tail] to prevent leaks\n- bloop rewrote for range b.N benchmarks to Go 1.24's b.Loop(), which [can shift\n nanosecond-scale benchmark numbers]\n\nAll three still exist in the modernize package as exported analyzers, disabled by default.\nIf the behavior change doesn't bother you, wrap one in the x/tools [unitchecker] driver that\ngo fix itself is built on:\n\nBuild it and pass the binary to go fix:\n\n-fixtool swaps the toolchain's fix tool for yours, which means this run applies bloop\nand nothing else.\n\nThe modernizers themselves have bugs. The Go team ran them across the standard library\nduring development and produced a long [bug list]. mapsloop was [still getting caught]\nlast November, when it turned a valid loop into a maps.Copy call that doesn't compile.\n\nNeither tool has a per-line ignore comment. Disable the offender with its flag and file a\nbug.\n\nAnalyzers also arrive through gopls, which ships new modernizers first and sometimes\nsuggests fixes your toolchain's go fix won't apply yet. There's an open proposal to [fold\na subset of staticcheck's analyzers] into go fix. The Go blog expects it in Go 1.27. And\nfmtappendf shipped in 1.26, but x/tools has [since dropped it from the suite] because its\nfix didn't clearly improve the code.\n\nRun go fix by default. The [release notes] say it outright: a fixer that changes your\nprogram's behavior is a bug to report.\n\nMigrations with //go:fix inline\n\nSo far all the rewrites target the language and the standard library. The same machinery\nworks on your own APIs too.\n\nThe first tool for that is the //go:fix inline directive. The Go blog covers it in depth\nin [//go:fix inline and the source-level inliner]. Say your library renamed greet.Hello to\ngreet.Greet. Keep the old name as a one-line wrapper, deprecate it, and annotate it:\n\nNothing breaks. When a user of your library runs go fix ./...:\n\nIt beats deprecating something and hoping everyone reads the changelog. gopls honors the\ndirective too. Callers see \"Call of greet.Hello should be inlined\" right in the editor and\ncan apply it as a quick fix. gopls can also inline a call on demand, even without a\ndirective.\n\nThe deprecated [golang.org/x/net/context] package carries these annotations today. Run\ngo fix on anything that still imports it and the calls move to the standard context. Its\nvar aliases like Canceled stay behind, because the directive doesn't work on variables.\n\nThe directive handles more than renames. Say the new API added a parameter the old one\nhardcoded, and a type got a better name in the same release:\n\nOne go fix run migrates a caller off both:\n\nThe wrapper's hidden default is spelled out at each call site, and the time import gets\nadded automatically. This is not textual substitution. A wrapper can reorder parameters or\nforward to another package. That's how ioutil.ReadFile calls become os.ReadFile.\n\nThe directive works on functions, type aliases, and constants. Only exported, package-level\nsymbols migrate across packages: an unexported helper's calls get rewritten only inside its\nown package. A function needs a body, a type has to be a true alias, and a constant has to\nrefer to another named constant. A call inside the wrapper's own dedicated test, like\nTestHello for Hello, doesn't get rewritten.\n\nAnnotate a whole deprecated package like that and eventually nobody imports it, so you can\ndelete it. The x/tools [deadcode] command finds the wrappers nobody calls anymore.\n\nThe inliner refuses to inline a callee that contains defer rather than wrap the body in a\nfunction literal. And it doesn't handle generics yet, as the ptr helper showed.\n\nWhen a clean substitution isn't safe, go fix skips the call silently. The one exception is\nopt-in: -inline.allow_binding_decl accepts rewrites that keep argument order safe with an\nextra var params = args line. Directive mistakes are silent too. Run go fix -json to see\nthem.\n\nWhat inline can't do\n\nThe inliner has a hard boundary: it can only put at the call site what the wrapper's body\nalready contains. It has no way to use what's already in scope there.\n\nThe classic case is adding a context.Context parameter. Your library's store.Get(key)\nneeds to become store.GetContext(ctx, key). The forwarding wrapper has no ctx to\nforward, so the best it can write is:\n\nWhen a caller runs go fix, the wrapper's body gets copied to every call site,\ncontext.Background() included:\n\nThere's a ctx in scope one line up, and the rewrite ignores it. Every call site now\ncarries context.Background(), and nothing marks the ones that need attention.\ncontext.TODO() is probably a better candidate here.\n\nThe fix is to use the ctx in scope at each call site. No annotation on the old function\ncan express that, but a custom analyzer can. go fix runs it through the -fixtool flag.\nI'll cover how to write one in a separate post.\n\nYou can get a lot done with just the built-in analyzers. I ran the new go fix on a large\nRPC service at work, and newexpr alone cleaned up a pile of pointer helper calls that had\nbeen accumulating for years. Extremely satisfying.\n\n\n\n\n[GopherCon talk]:\n https://www.youtube.com/watch?v=_VePjjjV9JU&t=254s\n\n[Go 1 compatibility promise]:\n https://go.dev/doc/go1compat\n\n[finally removed]:\n https://github.com/golang/go/issues/73605\n\n[rebuilt on the analysis framework]:\n https://github.com/golang/go/issues/71859\n\n[Using go fix to modernize Go code]:\n https://go.dev/blog/gofix\n\n[any]:\n https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#hdr-Analyzer_any\n\n[rangeint]:\n https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#hdr-Analyzer_rangeint\n\n[stringsseq]:\n https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#hdr-Analyzer_stringsseq\n\n[stringscut]:\n https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#hdr-Analyzer_stringscut\n\n[newexpr]:\n https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#hdr-Analyzer_newexpr\n\n[slicescontains]:\n https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#hdr-Analyzer_slicescontains\n\n[slicessort]:\n https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#hdr-Analyzer_slicessort\n\n[waitgroup]:\n https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#hdr-Analyzer_waitgroupgo\n\n[new(expr)]:\n https://github.com/golang/go/issues/45624\n\n[doesn't handle generic functions yet]:\n https://github.com/golang/go/issues/68236\n\n[never edited]:\n https://github.com/golang/go/issues/76479\n\n[modernizer tracking issue]:\n https://github.com/golang/go/issues/70815\n\n[bug list]:\n https://github.com/golang/go/issues/71847\n\n[still getting caught]:\n https://github.com/golang/go/issues/76380\n\n[release notes]:\n https://go.dev/doc/go1.26\n\n[since dropped it from the suite]:\n https://github.com/golang/go/issues/77581\n\n[golang.org/x/net/context]:\n https://go.googlesource.com/net/+/refs/heads/master/context/context.go\n\n[deadcode]:\n https://pkg.go.dev/golang.org/x/tools/cmd/deadcode\n\n[analysis framework]:\n https://pkg.go.dev/golang.org/x/tools/go/analysis\n\n[analyzers]:\n https://pkg.go.dev/golang.org/x/tools/go/analysis#hdr-Analyzer\n\n[drivers]:\n https://pkg.go.dev/golang.org/x/tools/go/analysis/multichecker\n\n[modernize package]:\n https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize\n\n[unitchecker]:\n https://pkg.go.dev/golang.org/x/tools/go/analysis/unitchecker\n\n[returns nil for an empty slice]:\n https://github.com/golang/go/issues/73557\n\n[zeroes the vacated tail]:\n https://github.com/golang/go/issues/73686\n\n[can shift nanosecond-scale benchmark numbers]:\n https://github.com/golang/go/issues/74967\n\n[fold a subset of staticcheck's analyzers]:\n https://github.com/golang/go/issues/76918\n\n[//go:fix inline and the source-level inliner]:\n https://go.dev/blog/inliner",
"title": "Modernizers & go fix"
}