Tools 6 min read 1114 words

go fix is back: modernizing Go code with a single command

ES
go fix is back: modernizing Go code with a single command

If you’ve been writing Go for a few years, your code has layers. There’s an interface{} from before any existed, a for i := 0; i < n; i++ from before range over integers, some x := x inside a loop to dodge the famous loop variable problem, and more than one chained if that today would be a min(). None of that is wrong. The language simply moved on and the code stayed where it was.

That’s what go fix is back for. Alan Donovan explained it on the official Go blog in Using go fix to modernize Go code: with Go 1.26 the subcommand has been completely rewritten, and it now does something very specific: it finds places where your code could use modern language or standard library features, and applies the change for you.

A bit of history

go fix isn’t new. It dates back to before Go 1.0, when the language changed enough that you needed a tool to rewrite old code. After 1.0 and its compatibility promise it was left with almost nothing to do, and for years it was one of those commands nobody ran.

The new version reuses the analysis infrastructure already used by go vet and gopls. What used to be editor suggestions (those dotted underlines offering to “simplify this”) can now be applied to an entire project at once.

How to use it

The basics are what you’d expect:

# Apply every fix to the module
go fix ./...

# See what would change, without touching anything
go fix -diff ./...

# List the available fixers and the help for a specific one
go tool fix help
go tool fix help forvar

You can also enable or disable specific analyzers:

go fix -any ./...        # only the "any" fixer
go fix -any=false ./...  # everything except "any"

One piece of advice from the article that I fully agree with: run it from a clean git tree. That way the result is a diff you can review calmly, and if something doesn’t convince you, you throw it away. Generated files are skipped automatically.

Some of the modernizers

go tool fix help gives you the full list, but these are the ones that caught my attention:

FixerWhat it does
anyReplaces interface{} with any
forvarRemoves the redundant x := x in loops (Go 1.22+)
minmaxReplaces if/else with min() and max()
rangeintTurns 3-clause loops into for range n (Go 1.22+)
stringscutUses strings.Cut instead of strings.Index and slicing
fmtappendfReplaces []byte(fmt.Sprintf(...)) with fmt.Appendf
mapsloopUses the maps package instead of explicit loops
newexprTakes advantage of new(expr), new in Go 1.26
inlineApplies inlining driven by //go:fix inline directives

Some examples are easier to understand by seeing them. Here’s minmax:

// Before
x := f()
if x < 0 {
    x = 0
}
if x > 100 {
    x = 100
}

// After
x := min(max(f(), 0), 100)

rangeint, when the index isn’t even used:

// Before
for i := 0; i < n; i++ {
    f()
}

// After
for range n {
    f()
}

And stringscut, one you appreciate because the original code was easy to get wrong:

// Before
eq := strings.IndexByte(pair, '=')
result[pair[:eq]] = pair[1+eq:]

// After
before, after, _ := strings.Cut(pair, "=")
result[before] = after

My favorite is newexpr. In Go 1.26, new accepts an expression and not just a type, so all those helper functions we end up writing in every project to get a pointer to a literal are no longer needed:

// Before
func newInt(x int) *int { return &x }

cfg := Config{Attempts: newInt(10)}

// After
cfg := Config{Attempts: new(10)}

Anyone who has worked with configuration structs or generated APIs where optional fields are pointers knows how many times they’ve written (or copied) that function.

Things worth knowing

The tool is cautious, and I like that. A few details:

  • It respects the module’s Go version. It only applies a change if the file requires the necessary version, either through the go directive in go.mod or a //go:build go1.X constraint. It won’t drop a for range n into a project that still builds with Go 1.21.
  • It cleans up imports that become unused after the fixes are applied.
  • A second pass may be worth it. One fix sometimes opens the door to another, so the article recommends running it more than once; two passes are usually enough.
  • Semantic conflicts aren’t resolved automatically. If two independent fixes clash in meaning, the code may not compile and you’ll have to fix it by hand. It’s rare, but it can happen.
  • Each platform is analyzed separately. If you have files with OS or architecture build tags, it’s worth running it with different GOOS/GOARCH values:
GOOS=linux   GOARCH=amd64 go fix ./...
GOOS=darwin  GOARCH=arm64 go fix ./...
GOOS=windows GOARCH=amd64 go fix ./...

Some modernizers were deliberately left out because they change behavior in subtle ways. The example they give is slices.Clone: it returns nil for an empty slice, while the code it would replace returns an empty but non-nil slice. For most programs it doesn’t matter, but for some it does, and a go fix that silently breaks things is no use to anyone.

Where it’s heading

The last part of the article is the one I find most interesting in the medium term. The idea is to move to a self-service model: library maintainers could publish their own modernizers alongside their code, and go fix or gopls would load and run them safely. The //go:fix inline directive already points in that direction, since it lets you mark a deprecated function so that calls to it get rewritten to its replacement.

They also talk about generalizing control-flow checks of the “don’t forget to do X after Y” kind: closing a file, cancelling a context, unlocking a mutex. Today these are specific checks, and the idea is that anyone could apply them to their own types through annotations.

Why I think it matters

What’s interesting about go fix isn’t any particular change, since you could make almost all of them by hand. What’s interesting is that nobody does. In any project that’s a few years old, updating interface{} to any or simplifying loops is never a priority, and so the code piles up styles from five different versions of the language.

With a command that respects the module version, produces a clean diff and doesn’t change behavior, keeping code up to date stops being a project that never finds its slot. It becomes one more step every time you bump the Go version in go.mod. The article is a few months old now, but it’s still well worth reading in full.