· 8 min read ·

go fix's New Inline Engine Changes How Go Handles Deprecated APIs

Source: go

For most of Go’s history, deprecating an API meant writing a // Deprecated: comment in the docs, maybe mentioning a replacement, and then waiting for developers to notice. The social contract worked tolerably well inside Google, where large-scale automated refactoring tooling exists. For everyone else, deprecated functions lingered in codebases for years because the tooling to migrate away from them simply did not exist at the language level.

Go 1.26 changes that in two ways. The first is a complete rewrite of the go fix tool, rebuilt on the go/analysis framework that also powers go vet and staticcheck. The second is the //go:fix inline directive, a machine-readable annotation that library authors can place on deprecated wrapper functions to tell the new go fix exactly how to rewrite call sites automatically. Together, these changes represent a shift in how Go thinks about API evolution.

A Brief History of go fix

The original go fix was written by Russ Cox in 2011, primarily to help users migrate through the breaking changes of early Go development. The reflect package was being overhauled, os.Open was splitting into os.Open and os.OpenFile, HTTP handler signatures were changing. The tool handled these cases with hardcoded rewrite rules built on go/parser and go/printer, and it worked well enough for what it needed to do at the time.

But the architecture did not scale. Each new migration required a new hardcoded rule, the rules had no type information so they could produce incorrect rewrites in ambiguous cases, and there was no mechanism for third-party libraries to participate in the system at all. After Go’s APIs stabilized in the 1.x era, go fix mostly became a tool you ran once when updating Go versions and then forgot about.

The Go 1.26 rewrite puts go fix on the same go/analysis infrastructure that powers the modern Go toolchain. This means rewrites are type-aware, composable, and open to contributions from any package that wants to declare its migration path.

The //go:fix inline Directive

The new directive is straightforward in intent. A library author implements a deprecated function as a thin wrapper over its replacement, adds the standard // Deprecated: doc comment pointing to the new API, and then adds //go:fix inline immediately before the function declaration:

// Deprecated: As of Go 1.16, this function simply calls [os.ReadFile].
// Use os.ReadFile directly.
//go:fix inline
func ReadFile(filename string) ([]byte, error) {
    return os.ReadFile(filename)
}

Running go fix ./... on a codebase that calls ioutil.ReadFile will then rewrite each call site, update the import block, and remove any now-unused io/ioutil imports automatically:

// Before
import "io/ioutil"

data, err := ioutil.ReadFile("config.json")

// After
import "os"

data, err := os.ReadFile("config.json")

The io/ioutil package has been deprecated since Go 1.16. That means there is nearly a decade of Go code that could now be migrated with a single command, assuming the standard library adds the //go:fix inline annotations.

The directive also works on constant declarations and type aliases. For constants, go fix substitutes the value at call sites. For type aliases, it rewrites type expressions throughout the affected scope.

Why Inlining Is Not Simple

The obvious implementation of //go:fix inline is textual substitution: find the function body, replace each parameter reference with the corresponding argument expression, and splice the result into the call site. This produces correct code for trivial wrappers, but fails in several important ways for anything more complex.

The underlying inliner lives in golang.org/x/tools/internal/refactor/inline and runs to approximately 7,000 lines of code. Much of that complexity comes from a handful of correctness problems that naive substitution does not handle.

The first is multi-use parameters. If a function body references a parameter more than once and the caller passes an expression with side effects, naive substitution evaluates that expression multiple times. The inliner detects this case and introduces a local variable binding to ensure the expression is evaluated exactly once.

The second is hazard analysis. Go evaluates function arguments left to right and in full before any of them are passed to the function. Inlining can change the relative evaluation order of arguments and the first statement of the function body, which matters when either contains side effects. The inliner analyzes these hazards and introduces explicit sequencing when needed.

The third is name shadowing. The inlined body enters a scope that may already contain names conflicting with variables declared inside the function. The inliner wraps substituted bodies in blocks and renames variables as needed to prevent collisions.

There is also a subtle problem with constant expressions. When function arguments are constants and the function body performs arithmetic or indexing on them, inlining can produce constant expressions that trigger compile-time errors. The inliner runs a constraint solver over constant-valued arguments to detect these cases before emitting the rewrite.

For functions that contain defer, the semantics are particularly tricky. The defer runs when the enclosing function returns, but after inlining there is no longer a distinct function frame. The inliner handles this by wrapping deferred bodies in immediately-invoked function literals, though batch go fix ./... conservatively refuses to apply this transformation without user review.

The Bundled Modernizers

Aside from the inlining mechanism, Go 1.26’s go fix ships with a set of built-in modernizers that bring code up to recent Go idioms. These operate independently of //go:fix inline and do not require library annotations:

rangeint converts three-clause for loops over integer ranges to Go 1.22’s for range n form:

// Before
for i := 0; i < n; i++ { ... }

// After
for i := range n { ... }

minmax replaces manual minimum and maximum patterns with the min and max builtins added in Go 1.21:

// Before
if x < y { x = y }

// After
x = max(x, y)

stringscut replaces the common strings.Index plus conditional slice pattern with strings.Cut, which was added in Go 1.18 and handles the no-match case cleanly:

// Before
if i := strings.Index(s, ":"); i >= 0 {
    host, port = s[:i], s[i+1:]
}

// After
if host, port, ok := strings.Cut(s, ":"); ok {
    _ = host; _ = port
}

interface to any replaces interface{} with any, the alias introduced in Go 1.18 that most style guides now prefer.

Each of these modernizers is type-aware, which is what distinguishes the new implementation from a regex-based approach. The rangeint rewrite, for example, verifies that n has an integer type before applying the transformation; it will not incorrectly rewrite a loop where n is a custom named type with different semantics.

The Cross-Package Discovery Problem

One non-obvious engineering challenge is that //go:fix inline annotations live in the source of library packages, but go fix runs on the calling code. The tool cannot simply read the source of every dependency to look for annotations; it needs a compiled, version-stable way to propagate this information.

The solution uses the Analysis Facts mechanism from the go/analysis framework. When a package is compiled, its exported //go:fix inline annotations are serialized as analysis facts into the build cache alongside the compiled package. When go fix analyzes a calling package, it loads these facts to discover which functions in dependencies are annotated, without re-parsing or re-typechecking the dependency source.

This is the same mechanism that go vet uses to propagate information about printf-family functions across package boundaries, and it scales to large dependency graphs without significant overhead.

IDE Integration via gopls

The //go:fix inline system is also integrated into gopls, the official Go language server. When gopls encounters a call to a function annotated with both // Deprecated: and //go:fix inline, it surfaces a diagnostic at the call site and offers a code action to apply the inline transformation interactively.

This is meaningful for cases where batch go fix ./... is too conservative. The defer-in-function case mentioned above is one example; gopls shows the user exactly what the rewrite would look like and lets them decide whether to apply it. The interactive path can also apply literalizations, where the inlined constant or argument value is substituted directly rather than through a binding, in situations where go fix would conservatively introduce a variable.

The three-way merge algorithm used when multiple analyzers want to modify the same file ensures that applying one code action does not invalidate others pending in the editor.

What This Means for Library Authors

The practical question for anyone maintaining a Go library is when and how to add //go:fix inline annotations.

The requirements are strict: the annotated function must be a thin wrapper that delegates to the replacement in a way the inliner can mechanically reverse. Functions with multiple statements, conditional logic, or side effects in the body are not candidates. The inliner will reject annotations on functions it cannot safely inline and report an error rather than silently producing wrong code.

The pattern works best for the common deprecation case where a function was renamed, moved to a different package, or split into more specific variants. The io/ioutil deprecations are the canonical example: ioutil.ReadFile is exactly os.ReadFile, ioutil.WriteFile is exactly os.WriteFile, and so on. Adding //go:fix inline to these functions gives every Go user a one-command migration path.

For deprecations that involve more than a simple delegation, the appropriate approach is still a // Deprecated: comment pointing to the replacement, but without //go:fix inline. Users will need to migrate manually or with a custom codemod.

Compared to how other languages handle this problem, the Go approach is notable for its conservatism and its integration with the type system. Java’s @Deprecated and Rust’s #[deprecated] are both warning-only annotations with no automated migration mechanism. C#‘s [Obsolete] attribute can carry a message but also has no migration tooling. Go 1.26 is the first mainstream language to ship a standard, type-aware inline migration mechanism that library authors can invoke with a single annotation.

Running It

For a project on Go 1.26 or later, running the full set of modernizers is:

go fix ./...

To preview changes without applying them:

go fix -diff ./...

To run only specific fixers:

go fix -fix rangeint,minmax ./...

The output of go fix -list shows all available fixers and their minimum Go version requirements, so the tool applies only rewrites that are valid for the module’s declared go directive.

For codebases that have been on Go for several years and never migrated away from ioutil or manual for i := 0; i < n loops, a single go fix ./... run can clean up a substantial amount of dated code in seconds. Whether the resulting diff is worth reviewing carefully before committing is a different question, but the capability is there in a way it never was before.

Was this interesting?