AI & ML

Go's Inline Directive Evolution: How //go:fix inline Transforms Source-Level Optimization

Mar 10, 2026 5 min read views

The Go Blog

//go:fix inline and the source-level inliner

Alan Donovan
10 March 2026

Go 1.26 ships a completely redesigned go fix subcommand aimed at helping developers keep their Go code current and idiomatic. For background, see our recent post introducing the feature. Here, we focus on one specific capability: the source-level inliner.

While go fix includes several targeted modernizers for specific language and library changes, the source-level inliner represents our first step toward "self-service" modernization — a mechanism that lets any package author express simple API migrations in a safe, structured way. We'll start with what the inliner does and how to use it, then explore the engineering challenges behind it.

Source-level inlining

In 2023, we developed an algorithm for source-level function call inlining in Go. Inlining a call means replacing it with a copy of the called function's body, substituting the call's arguments for the function's parameters. We call this "source-level" inlining because it produces a permanent change to the source code — as opposed to the inlining performed by compilers like Go's own, which applies a similar transformation to an ephemeral intermediate representation purely to improve runtime performance.

If you've used gopls' "Inline call" refactoring — available in VS Code under the "Source Action…" menu — you've already used this inliner. The screenshots below illustrate the effect: inlining the call to sum from within the function six.

The inliner is a foundational building block for several of gopls' source transformation tools. It underpins the "Change signature" and "Remove unused parameter" refactorings, precisely because it handles the many subtle correctness issues that arise when restructuring function calls.

This same inliner is also integrated into the redesigned go fix command, where it powers self-service API migration via the new //go:fix inline directive comment. The following examples show what this looks like in practice.

Example: renaming ioutil.ReadFile

In Go 1.16, ioutil.ReadFile was deprecated in favor of os.ReadFile. The function was effectively renamed, though Go's compatibility guarantee means the old name can never be removed. The current implementation simply forwards to the new one:

package ioutil
import "os"

// ReadFile reads the file named by filename…
// Deprecated: As of Go 1.16, this function simply calls [os.ReadFile].
func ReadFile(filename string) ([]byte, error) {
    return os.ReadFile(filename)
}

Ideally, every Go program would migrate away from ioutil.ReadFile and call os.ReadFile directly. The inliner makes this tractable. Adding //go:fix inline to the function signals that any call to it should be automatically inlined:

package ioutil
import "os"

// ReadFile reads the file named by filename…
// 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 on code that calls ioutil.ReadFile produces the following transformation:

$ go fix -diff ./...
-import "io/ioutil"
+import "os"

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

The call has been inlined, effectively swapping one function call for another. Because the transformation replaces the call with an exact copy of the function body — rather than allowing arbitrary rewrites like gofmt -r — it cannot change the program's behavior (setting aside edge cases like call stack inspection). This makes it substantially safer than more general rewriting tools, which require closer review.

Teams at Google maintaining Java, Kotlin, and C++ codebases have used source-level inliners like this for many years, eliminating millions of calls to deprecated functions across a monorepo of billions of lines of code. The workflow is straightforward: authors add the directives, and automated systems prepare, test, and submit batches of changes overnight. Go's inliner is newer to this ecosystem, but has already been used to prepare more than 18,000 changelists to Google's internal monorepo.

Example: fixing API design flaws

With some creativity, inlining can address a wider range of migration scenarios. Consider this hypothetical oldmath package with several design problems:

// Package oldmath is the bad old math package.
package oldmath

// Sub returns x - y.
func Sub(y, x int) int

// Inf returns positive infinity.
func Inf() float64

// Neg returns -x.
func Neg(x int) int

Sub has its parameters in the wrong order; Inf silently assumes positive infinity; and Neg duplicates functionality already available via Sub. A replacement newmath package corrects all of these. The migration path: re-implement the old API in terms of newmath, mark each function deprecated, and add the inliner directive:

// Package oldmath is the bad old math package.
package oldmath

import "newmath"

// Sub returns x - y.
// Deprecated: the parameter order is confusing.
//go:fix inline
func Sub(y, x int) int {
    return newmath.Sub(x, y)
}

// Inf returns positive infinity.
// Deprecated: there are two infinite values; be explicit.
//go:fix inline
func Inf() float64 {
    return newmath.Inf(+1)
}

// Neg returns -x.
// Deprecated: this function is unnecessary.
//go:fix inline
func Neg(x int) int {
    return newmath.Sub(0, x)
}

When users run go fix, all calls to the old functions are replaced with their corrected equivalents. Notably, gopls already includes the inline analyzer in its suite, so editors using gopls will show a diagnostic at each call site — for example, "call of oldmath.Sub should be inlined" — as soon as the //go:fix inline directives are present, along with a one-click suggested fix.

For example, this code:

import "oldmath"

var nine = oldmath.Sub(1, 10) // diagnostic: "call to oldmath.Sub should be inlined"

becomes:

import "newmath"

var nine = newmath.Sub(10, 1)

After the fix, the arguments to Sub appear in their logical order. If the inliner successfully eliminates every call into oldmath, the package can potentially be dropped as a dependency entirely.

The inline analyzer also applies to type aliases and constants. If oldmath had originally defined a rational number type and a constant for π, these forwarding declarations would migrate references transparently:

package oldmath

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

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

Any reference to oldmath.Rational or oldmath.Pi will be updated to refer to the newmath equivalents.

Under the hood of the inliner

At first glance, source inlining sounds mechanical: replace the call with the function body, introduce variables for parameters, bind the arguments to them. In practice, handling all the edge cases correctly while producing readable, idiomatic output is a significant engineering challenge. The inliner runs to around 7,000 lines of dense, compiler-like logic. Below are six aspects of the problem that account for much of that complexity.

1. Parameter elimination

One of the inliner's most important tasks is substituting each parameter reference in the callee body with the corresponding argument from the call site. When the argument is a trivial literal — 0, "", and so on — this is straightforward, and the parameter can be eliminated entirely.

//go:fix inline
func show(prefix, item string) {
    fmt.Println(prefix, item)
}
show("", "hello")
fmt.Println("", "hello")

For less trivial literals like 404 or "go.dev", substitution is still safe — as long as the parameter appears only once in the body. If it appears multiple times, duplicating the literal would scatter magic values throughout the code, obscuring their relationship and making future edits error-prone.

In those cases, the inliner takes a more conservative approach. When one or more parameters cannot be fully substituted, the inliner emits an explicit parameter binding declaration rather than repeating the value inline:

//go:fix inline
func printPair(before, x, y, after string) {
fmt.Println(before, x, after)
fmt.Println(before, y, after)
}
printPair("[", "one", "two", "]")
// a "parameter binding" declaration
var before, after = "[", "]"
fmt.Println(before, "one", after)
fmt.Println(before, "two", after)

2. Side effects

Function calls in Go—like all imperative languages—can produce side effects that modify variables and influence subsequent behavior. Take this add function call:

func add(x, y int) int { return y + x }
z = add(f(), g())

A straightforward inline substitution would swap x for f() and y for g(), yielding:

z = g() + f()

This transformation breaks correctness. The evaluation order has reversed—g() now executes before f(). When these functions produce side effects, the altered sequence can change program behavior. While relying on argument evaluation order is poor practice, real-world code does it, and refactoring tools must handle it correctly.

The inliner must verify that f() and g() don't interfere with each other. When this proof succeeds, the simplified form is safe. Otherwise, explicit parameter binding preserves correctness:

var x = f()
z = g() + x

Side effect analysis extends beyond argument expressions. The callee's parameter usage matters too. Consider add2:

//go:fix inline
func add2(x, y int) int {
return x + other() + y
}
add2(f(), g())

Here parameters appear in declaration order, so f() + other() + g() preserves the relationship between f() and g()—but alters when other() executes relative to g(). Loop-bound parameters introduce another wrinkle: substitution might change how many times an effect occurs.

The inliner employs a specialized hazard analysis to model effect ordering within callees. Still, its proof capabilities have inherent limits. Simple accessor functions like f() and g() could safely execute in any order. An optimizing compiler might leverage internal knowledge to reorder such calls. But the inliner makes permanent source changes, not ephemeral object code, so it can't rely on transient implementation details. Consider this extreme case:

func start() { /* TODO: implement */ }

A compiler can eliminate calls to start() today because it's currently effect-free. The inliner cannot—tomorrow's implementation might matter.

The inliner often produces overly conservative results that project maintainers could safely simplify. Manual cleanup can improve style in these situations.

3. "Fallible" constant expressions

Replacing a parameter with a constant argument of matching type seems inherently safe. Surprisingly, it's not—runtime checks can become compile-time failures. Consider this index function:

//go:fix inline
func index(s string, i int) byte {
return s[i]
}
index("", 0)

Naive substitution of s with "" and i with 0 produces ""[0]—an illegal Go expression. This specific index exceeds bounds for this specific string. Since ""[0] contains only constants, Go evaluates it at compile time, and programs containing it won't build. The original code fails only if execution reaches this index call, which presumably doesn't happen in working programs.

The inliner tracks expressions and operands that might become constant through parameter substitution, triggering additional compile-time validation. It constructs a constraint system and solves it. Unsatisfied constraints require explicit parameter bindings.

4. Shadowing

Argument expressions typically contain identifiers referencing symbols in the caller's file. The inliner must ensure each name continues referencing the same symbol after substitution—that caller names aren't shadowed in the callee. When this check fails, parameter bindings become necessary:

//go:fix inline
func f(val string) {
x := 123
fmt.Println(val, x)
}
x := "hello"
f(x)
x := "hello"
{
// another "parameter binding" declaration
// to read the caller's x before shadowing it
var val string = x
x := 123
fmt.Println(val, x)
}

The reverse also applies: each callee name must reference the same entity when spliced into the call site. Callee names can't be shadowed or missing in the caller. Missing names may require additional imports.

5. Unused variables

Effect-free argument expressions with unused parameters can be eliminated. However, if the expression contains a local variable's last reference, removal triggers a compile error for the now-unused variable.

//go:fix inline
func f(_ int) { print("hello") }
x := 42
f(x)
x := 42 // error: unused variable: x
print("hello")

The inliner tracks local variable references to avoid removing the final one. (Two separate inline fixes might each remove a second-to-last reference, creating valid individual transformations that conflict when combined—see semantic conflicts from the previous post. Manual cleanup becomes unavoidable here.)

6. Defer

Some calls simply can't be inlined away. Functions using defer statements present this challenge: eliminating the call would cause deferred functions to execute when the caller returns—too late. The safe approach wraps the callee body in an immediately-invoked function literal. This func() { … }() construct delimits the defer statement's lifetime:

//go:fix inline
func callee() {
defer f()
…
}
callee()
func() {
defer f()
…
}()

In gopls, the inliner performs this transformation and introduces the function literal. This suits interactive use—you'll likely tweak or undo the change immediately. For batch tools, it's rarely desirable, so go fix refuses to inline such "literalized" calls as policy.

An optimizing compiler for "tidiness"

We've examined six ways the inliner handles semantic edge cases correctly. (Thanks to Rob Findley, Jonathan Amsterdam, Olena Synenka, and Lasse Folger for insights, discussions, reviews, features, and fixes.) By embedding this intelligence in the inliner, users can confidently apply "Inline call" refactorings in their IDE or add //go:fix inline directives, knowing the resulting transformations need only cursory review.

We've made solid progress toward this goal, though complete achievement remains elusive—and likely always will. Consider compilers: sound compilers produce correct output for any input, never miscompiling code. This is the fundamental user expectation. An optimizing compiler carefully selects code for speed without sacrificing safety. Similarly, an inliner resembles an optimizing compiler targeting tidiness rather than speed: inlining must never alter program behavior, ideally producing maximally clean code. Unfortunately, optimizing compilers are provably never finished: proving program equivalence is undecidable, and experts will always know safe improvements the compiler can't prove. The inliner faces identical constraints: cases will always exist where output is overly fussy or stylistically inferior to human expertise, and "tidiness optimizations" will always remain to add.

Try it out!

We hope this inliner tour conveys some implementation challenges and our priorities for sound, self-service code transformation tools. Try the inliner—interactively in your IDE or through //go:fix inline directives with go fix—and share your experiences and improvement ideas.

Source: Alan Donovan · https://go.dev/blog/inliner