· 8 min read ·

The Inline That Rewrites Your Code: Go's Source-Level Migration Engine

Source: go

When most programmers hear the word “inline,” they think of a compiler optimization: eliminating call overhead by substituting a function body at its call sites, invisible to the source, operating on intermediate representation. C has the inline keyword. Rust has #[inline]. Both are hints to a compiler about machine code generation.

Go 1.26’s //go:fix inline directive is something completely different. It rewrites your source code.

This distinction matters because the two operations have different goals, different constraints, and different failure modes. Understanding why the Go team built a source-level inliner, and what it took to make it correct, reveals a lot about how they think about API evolution at scale.

What the Compiler Inliner Does and Why It’s Not Enough

Go’s compiler has performed function inlining since 1.0, using a budget of 80 AST nodes. Functions that fit within the budget get their bodies substituted at call sites in the compiler’s intermediate representation, eliminating call overhead, enabling further optimizations like escape analysis, and sometimes eliminating allocations entirely. Go 1.21 added profile-guided optimization, allowing the compiler to exceed this budget for hot functions based on runtime profiling data.

None of this is visible to the programmer. The source is unchanged. That invisibility is exactly the point for a performance optimization, but it means the compiler inliner cannot help with a different class of problem: what happens when an API is deprecated?

When the Go team deprecated the io/ioutil package in Go 1.16, they replaced it with equivalent functions in os and io. Every call to ioutil.ReadFile should become os.ReadFile. Every call to ioutil.ReadAll should become io.ReadAll. This is purely mechanical, but doing it across a large codebase requires either a bespoke migration script, manual editing, or just leaving the deprecated calls in place. The compiler inliner cannot help because it does not produce changed source. The migration must happen at the source level.

The //go:fix inline directive, introduced in Go 1.26, makes this migration automatic. A package author marks a deprecated function with the directive, implements its body in terms of the new API, and any user who runs go fix ./... gets the migration applied across their entire codebase in one pass.

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

Running go fix -diff ./... shows:

-import "io/ioutil"
+import "os"

-    data, err := ioutil.ReadFile("hello.txt")
+    data, err := os.ReadFile("hello.txt")

The directive also applies to type aliases and constants:

//go:fix inline
type Rational = newmath.Rational

//go:fix inline
const Pi = newmath.Pi

Why Source-Level Inlining Is Hard

A naive implementation of source-level inlining would be dangerous. The algorithm backing //go:fix inline lives in golang.org/x/tools/internal/refactor/inline, approximately 7,000 lines of code, and it addresses a number of subtle correctness problems that a simple textual substitution would get wrong.

Hazard analysis and side effects. Consider a function like:

//go:fix inline
func add(x, y int) int { return y + x }

Calling add(f(), g()) cannot safely become g() + f(), because that reorders side effects. The inliner performs a hazard analysis to determine whether reordering is safe. When it is not, it emits a parameter binding declaration:

var x, y = f(), g()
_ = y + x

This is a permanent decision. The compiler inliner can use its knowledge of the complete program to prove reordering is safe in a given context. The source-level inliner cannot rely on that kind of ephemeral knowledge, because it is making a change that must be correct in all future versions of the code as well.

Parameters used multiple times. When an argument appears more than once in the function body, substituting it directly would evaluate the expression multiple times. The inliner detects this and emits a binding:

// printPair("[", "one", "two", "]")
var before, after = "[", "]"
fmt.Println(before, "one", after)
fmt.Println(before, "two", after)

Shadowing. Names from the call site must not be captured by the inlined body, and vice versa. If the function body declares a variable x and the caller already has a variable x in scope, naive substitution produces either a compilation error or a semantic change. The inliner detects conflicting bindings and wraps the substituted body in a block.

Constant expression bounds. This one is subtle. If a function takes a string and an index and the body accesses s[i], substituting a constant empty string and a constant zero produces ""[0], which is a compile-time bounds error even though calling the function at runtime would produce a runtime panic. The inliner uses a constraint system to detect when substituting constants would change the program’s compile-time behavior and inserts bindings to preserve variable semantics.

Defer. Functions containing defer cannot be cleanly inlined. The deferred call must fire when the called function returns, not when the outer caller returns. When the inliner encounters defer, it wraps the body in an immediately invoked function literal:

callee()  →  func() { defer f(); ... }()

This is called “literalization.” The go fix batch tool refuses to apply literalized inlining entirely. The gopls interactive “Inline call” refactoring will apply it, because a developer can immediately review and adjust the result. Batch migration across a codebase is a different contract.

Cross-Package Propagation via Analysis Facts

For //go:fix inline to work across package boundaries, the inline analysis pass needs a way to communicate information about one package to analyses of packages that import it, without re-analyzing the source. The mechanism is analysis Facts, part of the go/analysis framework that also powers go vet.

A Fact is a gob-serializable value attached to a symbol or package. When the inline analyzer processes io/ioutil, it exports a goFixInlineFuncFact for each function marked //go:fix inline. The Callee type, which holds the pre-analyzed function body, is serialized and stored as part of this fact.

When a package importing ioutil is later analyzed, the inline pass retrieves these facts and can produce SuggestedFix values for each qualifying call site, without ever re-reading ioutil’s source. This is the same mechanism by which go vet can report type-safety issues that cross package boundaries.

The SuggestedFix values are TextEdit structs, ranges of source code paired with replacement text. Both go fix and gopls consume these: go fix applies them in batch, gopls surfaces them as code actions in the editor.

The Self-Service Paradigm

Before //go:fix inline, adding a migration to go fix required writing a custom analyzer, getting it reviewed, and landing it in the Go toolchain. The Go 1.26 rewrite of go fix changed this entirely. Now any package author can express a migration by adding two lines to their code: a deprecation comment and the //go:fix inline directive.

The implications for the standard library are obvious, but the implications for the ecosystem are larger. A library maintainer can evolve their API without leaving every user to perform the migration manually. The // Deprecated: comment already communicates intent to human readers and tools. //go:fix inline adds machine-executable migration on top of that.

This is also how the Go team has already been working internally. The same underlying algorithm has been used to prepare over 18,000 changelists against Google’s monorepo, migrating away from deprecated functions at a scale that would be impossible to manage by hand. Java, Kotlin, and C++ teams at Google have had equivalent tooling for years. //go:fix inline brings the same capability to the open Go ecosystem.

Contrast with Compiler Inline Hints

It is worth being explicit about what //go:fix inline is not. In C, inline is a linkage hint; modern compilers largely ignore it for optimization decisions and use __attribute__((always_inline)) when a guarantee is needed. In Rust, #[inline] controls whether a function’s body is emitted into the object file for cross-crate inlining by LLVM. Neither of these touches source code.

Go’s compiler inlining is controlled with //go:noinline to suppress inlining, and //go:inline was proposed but never adopted as an affirmative hint. The //go:fix inline directive is not a compiler directive at all; cmd/compile does not read it. It is read exclusively by the inline analysis pass in go/analysis.

These are orthogonal concerns. A function marked //go:fix inline can still be inlined or not inlined by the compiler independently. The source transformation and the compiler optimization have nothing to do with each other except that they share a name.

Historical Context

The original gofix tool was written by Russ Cox in 2011 to handle the rapid API changes during Go’s early development: the reflect package overhaul, changes to os.Open and net.Dial, HTTP handler signature changes. It used go/parser and go/printer and had bespoke rewrite rules for each migration.

The Go 1.26 go fix is a complete replacement built on go/analysis. The difference is architectural: the old tool had hardcoded rules, the new tool is a composable framework where any analyzer can contribute fixes via SuggestedFix, and those fixes compose through a three-way merge algorithm when multiple analyzers modify the same file.

The gopls interactive “Inline call” refactoring, which can inline any function call on demand, has used the same golang.org/x/tools/internal/refactor/inline engine since 2023. The //go:fix inline directive makes that engine batch-applicable, wiring it into go fix so that package authors can trigger it across entire codebases without any user intervention beyond running one command.

What This Changes for Package Authors

If you maintain a public Go package and have ever needed to rename a function, reorder parameters, consolidate two functions into one, or migrate users to a dependency you now re-export, //go:fix inline is worth understanding now.

The constraint is that the function body must be expressible as a single return statement or a simple sequence that the inliner can transform cleanly. Functions with complex control flow, multiple return paths, or defer will either be declined by go fix or literalized in a way that may not produce the cleanest output. The sweet spot is thin wrappers: delegation to a renamed function, argument reordering, default values filled in.

For those cases, the pattern is:

  1. Implement the old function as a one-liner delegating to the new API.
  2. Add // Deprecated: with a reference to the replacement.
  3. Add //go:fix inline.
  4. Communicate to your users that go fix ./... will handle the migration.

The tooling does the rest. Gopls will start showing diagnostics at every call site immediately. Users who run go fix get the migration applied atomically across their entire codebase. Dead code from the old import path gets removed automatically. It is, within the scope of what it can handle, the cleanest model for deprecation that Go has had.

Was this interesting?