TIL
Before Go 1.26, go fix did one thing: rewrite calls broken by a stdlib API change. Since the compatibility promise froze the stdlib’s surface, most Go programmers never ran it.
Go 1.26 rebuilt it into a general modernizer: a tool that rewrites old idioms to newer language features across a whole codebase, and re-runnable any time.
It gets this by building on the go/analysis framework, the same modular analyzer/driver split that go vet has used since 2017. Each modernization (minmax, rangeint, stringscut, newexpr, …) is an Analyzer whose Run emits a SuggestedFix instead of or alongside a diagnostic:
// before
if x < 0 {
x = 0
}
if x > 100 {
x = 100
}
// after (minmax)
x := min(max(x, 0), 100)
// before
for i := 0; i < n; i++ {
f()
}
// after (rangeint, Go 1.22+) - i is unused, so it's dropped entirely
for range n {
f()
}
Running it
go fix ./... edits your source files in place. To see the changes first, add -diff.
A few defaults are worth knowing before you run it:
- Generated files are skipped
- Fixes are gated by the Go version a file has actually opted into, via the
godirective ingo.modor a//go:build go1.26-style constraint in the file itself. A modernizer for a 1.26 feature won’t fire in a file or module that hasn’t declared 1.26. - Each modernizer is its own flag, named after the analyzer:
go fix -minmax=false ./...runs everything exceptminmax;go fix -newexpr ./...runs onlynewexpr. Useful for splitting one big modernization into smaller, reviewable commits. - For a project with platform-tagged files, run it once per
GOOS/GOARCHpair you care about and each execution only sees the files selected for the host platform’s build tags.
Because fixes can interact (more on that below), the intended workflow is
- run
go build- run again, repeating until a pass makes no further changes
Deep Dive: The go/analysis Framework
An Analyzer is a plain struct, there is no framework magic, just data describing an analysis and its dependencies:
type Analyzer struct {
Name string
Doc string
Run func(*Pass) (any, error)
ResultType reflect.Type
Requires []*Analyzer
FactTypes []Fact
// ...
}
Run executes once per package, against a Pass, the framework’s unit of work, bundling the parsed files, type info, and a Report func for that one (analyzer, package) pair:
type Pass struct {
Analyzer *Analyzer
Fset *token.FileSet
Files []*ast.File
Pkg *types.Package
TypesInfo *types.Info
ResultOf map[*Analyzer]any
Report func(Diagnostic)
// ...
}
Analyzers compose along two different axes of the same package graph:
- horizontal: other analyzers on the same package
- vertical: the same analyzer across a package’s dependency chain
A single small analyzer shows both at once. Say it flags calls to functions marked // Deprecated:, even when the call crosses a package boundary:
var Analyzer = &analysis.Analyzer{
Name: "deprecated",
Doc: "reports calls to functions marked '// Deprecated:'",
Requires: []*analysis.Analyzer{inspect.Analyzer}, // horizontal
FactTypes: []analysis.Fact{new(isDeprecated)}, // vertical
Run: run,
}
type isDeprecated struct{ Message string }
func (*isDeprecated) AFact() {}
func run(pass *analysis.Pass) (any, error) {
insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) // horizontal
// Export a fact for each of this package's own deprecated functions.
insp.Preorder([]ast.Node{(*ast.FuncDecl)(nil)}, func(n ast.Node) {
decl := n.(*ast.FuncDecl)
if msg, ok := deprecationMessage(decl.Doc); ok { // elided: scans for "Deprecated:"
obj := pass.TypesInfo.ObjectOf(decl.Name)
pass.ExportObjectFact(obj, &isDeprecated{msg}) // vertical
}
})
// Flag calls to anything marked deprecated, including functions defined
// (and compiled, not reparsed) in other packages.
insp.Preorder([]ast.Node{(*ast.CallExpr)(nil)}, func(n ast.Node) {
call := n.(*ast.CallExpr)
if fn, ok := typeutil.Callee(pass.TypesInfo, call).(*types.Func); ok {
var fact isDeprecated
if pass.ImportObjectFact(fn, &fact) { // vertical
pass.Reportf(call.Pos(), "call of deprecated %s: %s", fn.Name(), fact.Message)
}
}
})
return nil, nil
}
Requires is the horizontal axis:
- the driver topologically sorts by
Requiresand runs prerequisites first, sopass.ResultOf[inspect.Analyzer]is already populated by the timerunexecutes. Here it’sinspect.Analyzer, a building-block analyzer that does nothing but hand every other analyzer a pre-built*inspector.Inspectorso the AST only gets walked once per package, not once per analyzer.
FactTypes is the vertical axis:
- a
Factis a small gob-encodable value attached to an object or package viaExportObjectFactthat a later run of the same analyzer can read back withImportObjectFact, even for a function whose source was never parsed in this run, because the fact travels with the package’s compiled export data, mirroring howgo builddoes separate compilation instead of reanalyzing everything from scratch. printfis a real example of this: it declares anisWrapperfact and exports it when it proves “this function wrapsfmt.Printf,” so packages importing it get the same format-string checking without re-deriving that fact themselves.
Validate() rejects a cyclic Requires graph up front, since neither the driver nor a human could make sense of an analyzer that (transitively) requires itself.
A Single Driver for Multiple Tools
go fix and go vet run on the same driver: unitchecker, which turns a suite of analyzers into a subcommand the go command’s incremental build system can invoke directly. Per the Go team’s own framing, the two commands “have converged and are now almost identical in implementation”, the only real difference is the admission bar for the analyzers each one runs, and what it does with the result: go vet’s analyzers “must detect likely mistakes with low false positives” and report diagnostics to the user, while go fix’s analyzers “must generate fixes that are safe to apply without regression in correctness, performance, or style,” and those fixes get applied directly rather than just reported.
More broadly, the Analyzer/Pass/Fact machinery is driver agnostic and the same analyzer binary runs identically inside gopls for live editor diagnostics, under go build’s incremental cache, or under alternative build systems. A minimal standalone driver is genuinely three lines, calling straight into singlechecker.Main:
package main
import (
"golang.org/x/tools/go/analysis/passes/findcall"
"golang.org/x/tools/go/analysis/singlechecker"
)
func main() { singlechecker.Main(findcall.Analyzer) }
Running to a Fixed Point
Overlapping edits to the same file are resolved with a three-way merge that drops a fix if it collides with one already applied (a syntactic conflict). There’s also a semantic one the merge can’t catch: two fixes can each be correct on their own but jointly leave something unused. When that something is an import, go fix runs a final pass to strip it automatically, because the case is common enough to be worth automating; when it’s a variable declaration, nothing removes it for you and that’s left as a compile error for the next go build to surface.
That asymmetry, plus the fact that applying one fix can open up an opportunity for another, is why go fix is meant to be run to a fixed point rather than once.
Why This Matters Now
Go 1.26 already ships the first piece of this: an annotation-driven inliner (passes/inline) that acts on a //go:fix inline comment you add yourself to a function, constant, or type alias, mark something as superseded, and go fix inlines every call site, no analyzer authoring required (see the follow-up post on the inliner for the mechanism).
What’s still exploratory is letting third parties ship and dynamically load full custom analyzers into go fix, golang/go#59869, plus generalizing the existing control-flow checkers.
Worth noting why this appears to have been prioritized: in December 2024, the Go team observed that LLM coding assistants tended to generate Go in the idiom of their training data, meaning the mass of pre-generics, pre-range-over-int code already on the internet was quietly training the next generation of tools to keep writing it that way. Modernizing the open-source corpus itself is part of the motivation.
References
- https://go.dev/blog/gofix
- https://go.dev/blog/inliner
- https://pkg.go.dev/golang.org/x/tools/go/analysis
- https://pkg.go.dev/golang.org/x/tools/go/analysis/singlechecker
- https://pkg.go.dev/golang.org/x/tools/go/analysis/unitchecker
- https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize
- https://go.dev/issue/59869
- Alan Donovan, “Analysis and Transformation Tools for Go Codebase Modernization” — GopherCon 2025