AI & ML

Go Source-Level Inlining: `go:fix inline` for Code Optimization

· 5 min read
The Go team, led by Alan Donovan, rolled out a major update on March 10, 2026, on The Go Blog, focusing on the latest evolution of the `go fix` subcommand in Go 1.26. This isn't just a minor tweak; we're talking about an entirely new implementation designed to keep Go codebases modern and aligned with best practices. While `go fix` has always been a useful utility for specific language changes, the real headline here is the introduction of a new source-level inliner. This feature is a big step towards "self-service" modernization tools for Go developers, offering a far more powerful way to manage API migrations and upgrades than we've had before.

Understanding Source-Level Inlining

So, what exactly *is* source-level inlining? Essentially, it means replacing a function call directly in your source code with a copy of that function's body, meticulously substituting arguments for parameters. It's a durable modification to the code you write. This differs significantly from the inlining many of us are familiar with in compilers, including Go's own. Compiler-level inlining operates on an ephemeral intermediate representation to optimize the generated machine code, a temporary transformation. Source-level inlining, however, permanently alters your `.go` files. If you've ever used `gopls`' "Inline call" interactive refactoring – say, in VS Code – you've already experienced this technology in action. (You can find more on that feature here.) Consider this simple example, where a call to `sum` within the `six` function gets inlined:
This isn't just a party trick. This inliner forms a critical foundation for various source transformation tools, with `gopls` already leveraging it for "Change signature" and "Remove unused parameter" refactorings. The reason? It adeptly handles many of the subtle correctness issues that pop up when you start messing with function calls.

Automated API Migration with `//go:fix inline`

The true power of this new inliner shines within the updated `go fix` command, primarily through a new directive: `//go:fix inline`. This simple comment tells the tool to automatically inline any call to the annotated function. Take the `ioutil.ReadFile` function as a classic example. Deprecated in Go 1.16 in favor of `os.ReadFile`, its continued presence is a testament to Go's compatibility promise, preventing the outright removal of old names. Ideally, every Go program would transition to `os.ReadFile`. By adding `//go:fix inline` to `ioutil.ReadFile`'s definition: ```go 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` then yields the desired replacement, effectively changing the import and the function call: ``` $ go fix -diff ./... -import "io/ioutil" +import "os" - data, err := ioutil.ReadFile("hello.txt") + data, err := os.ReadFile("hello.txt") ``` This transformation's critical distinction is its safety. Because the inliner replaces a call with the *called function's body*, the program's behavior shouldn't change (unless, of course, your code inspects the call stack). This is a stark contrast to tools like `gofmt -r`, which allow arbitrary rewrites that are incredibly powerful but demand a far higher degree of scrutiny. This isn't some experimental concept. Our Google colleagues, working with Java, Kotlin, and C++ codebases, have been using similar source-level inliner tools for years. They've eliminated millions of calls to deprecated functions across a monorepo spanning billions of lines of code. Developers simply add directives, and automated systems prepare, test, and submit batches of changes. Go's inliner is the new kid on the block, but it's already facilitated over 18,000 changelists within Google's monorepo, a clear validation of its utility at scale.

Beyond Renames: Tackling API Design Flaws

The `//go:fix inline` directive isn't limited to simple renames. With a bit of strategic implementation, it can resolve deeper API design issues. Consider a hypothetical `oldmath` package with problems like `Sub` having its parameters reversed, `Inf` being ambiguous, and `Neg` being redundant. By deprecating these functions and implementing them in terms of a `newmath` package, then adding the `//go:fix inline` directive: ```go // 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) } ``` Now, when users of `oldmath` run `go fix`, calls like `oldmath.Sub(1, 10)` will transform into `newmath.Sub(10, 1)`, correcting the argument order. If you're using `gopls`, you'll even get diagnostics and suggested fixes in your editor the moment you add those `//go:fix inline` directives. This functionality extends to types and constants as well, allowing for seamless migration of declarations like `type Rational = newmath.Rational` or `const Pi = newmath.Pi`. The goal, of course, is to eventually remove the deprecated `oldmath` package entirely once all call sites are gone.

Under the Hood: The Inliner's Complexities

While the concept of source inlining seems straightforward – swap a call for a function body, manage parameters – the actual implementation is anything but. This isn't a simple regex replacement. The inliner contains roughly 7,000 lines of dense, compiler-like logic to correctly handle all the intricacies. Let's look at a few of the challenges that make this process so tricky.

Parameter Elimination

One of the inliner's primary jobs is to replace parameter occurrences within the callee's body with the corresponding arguments from the call. Simple literals like `0` or `""` are easy. However, if a non-trivial literal, such as `404`, appears multiple times, simply copying the value could obscure its original intent and lead to inconsistencies if only one instance is later changed. In these cases, the inliner wisely inserts an explicit "parameter binding" declaration to maintain clarity and prevent scattered magic values. Consider `printPair`:
//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)

Side Effects

A function call can modify variables, impacting subsequent operations. The inliner has to be acutely aware of argument evaluation order, especially when functions like `f()` and `g()` might have side effects. A naive substitution in `z = add(f(), g())` that reorders `g()` before `f()` would be incorrect if their effects depend on each other. The inliner attempts to prove that arguments have no interdependencies. If it can't, it defaults to a safer explicit parameter binding, like `var x = f(); z = g() + x`. The complexity doesn't stop with arguments. The order in which parameters are used within the function body also matters. If a parameter is used inside a loop, direct substitution could alter the number of times an effect occurs. The inliner employs a novel hazard analysis to model effect ordering. That said, its ability to construct these safety proofs is understandably limited. For instance, while an optimizing compiler might know `start()` has no effect today and delete its calls, the inliner cannot, as the function's implementation might change tomorrow. This often means the inliner produces conservative results that a maintainer might want to manually clean up for better style.

"Fallible" Constant Expressions

You might assume replacing a parameter with a constant argument is always safe. Surprisingly, it isn't. Certain runtime checks can shift to compile-time and fail. Consider `index("", 0)`. A direct inline yields `""[0]`, which is a compile-time error in Go because it's an out-of-bounds index on a constant string. The original code would only fail if executed. The inliner must track expressions that might become constant during substitution and trigger new compile-time checks. It builds a constraint system, adding explicit bindings for parameters if constraints aren't met.

Shadowing

Identifiers in argument expressions must refer to the same symbols after substitution. If a name in the caller's scope would be *shadowed* by a name in the callee's body, the inliner needs to step in. It inserts parameter bindings to ensure the original binding is preserved. For example, when inlining `f(x)` where `x` is defined in the caller, but also internally in `f`:
//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)
}
Conversely, names within the callee's body must also refer to the same entities when moved to the call site. If a name from the callee is missing in the caller's scope, the inliner might even need to insert additional imports.

Unused Variables

Finally, eliminating an argument expression that has no side effects and corresponds to an unused parameter can unexpectedly break your code. If that expression contained the *last* reference to a local variable in the caller, that variable suddenly becomes unused, leading to a compile error. Consider:
//go:fix inline
func f(_ int) { print("hello") }
x := 42
f(x)
x := 42 // error: unused variable: x
print("hello")
The `go fix` team has clearly put a ton of thought into these edge cases, proving that building a "safe" automated refactoring tool is anything but simple.For anyone working with automated code refactoring, the Go team's inliner project offers a compelling lesson in the limits of automation. They've framed the inliner not just as a refactoring tool, but as an "optimizing compiler whose goal is not speed but *tidiness*." That's a powerful analogy, acknowledging the inherent intelligence built into these systems while tempering expectations with a dose of reality. ## The Inevitable Limits of "Tidiness Optimizations" Here's the thing: while a compiler aims for correct execution and an *optimizing* compiler targets speed without compromising that correctness, the inliner seeks to make code cleaner without altering its behavior. The article points out that the inliner has managed to handle "half a dozen examples" of "tricky semantic edge cases correctly," a testament to the efforts of contributors like Rob Findley, Jonathan Amsterdam, Olena Synenka, and Lasse Folger. The goal, clearly, is for developers to confidently apply refactorings like "Inline call" in their IDEs or use `//go:fix inline` directives, trusting that only a "cursory review" will be needed. And yet, the project managers are frank: they've made "good progress," but haven't "fully attained" this goal, and "it is likely that we never will." This isn't a failure; it’s a recognition of a fundamental constraint. They draw a direct parallel to an optimizing compiler, which is, according to Rice's theorem, "provably never done." The core issue is that proving two different programs are equivalent is an undecidable problem. What this means for us is that there will always be situations where a human expert intuitively knows a code transformation is safe and tidy, but an automated tool can't definitively prove it. The inliner will inevitably produce output that's "too fussy or otherwise stylistically inferior to that of a human expert," leaving a perpetual queue of "tidiness optimizations" yet to be added. It’s a crucial insight: automated tools can get you most of the way there, but human judgment remains irreplaceable for true elegance. ## Navigating `defer` Statements and Local Variables This philosophical outlook grounds how the inliner tackles specific technical hurdles. Take the challenge of `defer` statements. It's simply "impossible to inline away the call" to a function that uses `defer` without fundamentally altering its execution timing. If the inliner were to eliminate the call, the deferred function would run when the *caller* returns, which is too late. The safe, if slightly awkward, workaround is to wrap the callee's body in an immediately-called function literal, `func() { … }()`. This clever construction creates a new scope, effectively delimiting the `defer` statement's lifetime correctly.
//go:fix inline
func callee() {
defer f()
…
}
callee()
func() {
defer f()
…
}()
The interesting design choice here is how different Go tools handle this. If you invoke the inliner via `gopls` in your IDE, you'll see this function literal appear. This makes sense in an interactive environment: developers can immediately review, tweak, or undo the change. However, for a batch tool like `go fix`, this "literalized" output is "rarely desirable." As a result, the `go fix` analyzer, "as a matter of policy," refuses to inline such calls. It's a pragmatic decision that prioritizes predictability and clean output for large-scale, non-interactive transformations. Beyond `defer`, the inliner also has to carefully manage local variables, ensuring it never removes the *last* reference to one. The project team also notes a more subtle issue: "semantic conflicts," where two separate inlining fixes might each remove the *second*-to-last reference to a variable, creating a valid individual fix but a conflict when combined. In such scenarios, "manual cleanup is inevitably required," reinforcing that human oversight remains the ultimate arbiter of correctness and style. ## The Path Forward for Automated Refactoring Ultimately, this deep dive into the inliner gives us a clear picture of the ongoing effort to create "sound, self-service code transformation tools." It's a journey riddled with nuanced semantic challenges and inherent theoretical limitations, yet one that yields immense practical benefits for developers. The call to action is clear: try out the inliner yourself. Whether you're using it interactively in your IDE or automating fixes with `//go:fix inline` and `go fix`, the team wants to hear about your experiences. Share your ideas for improvements or entirely new tools. This collaborative feedback loop is critical for pushing the boundaries of what automated refactoring can achieve, even as we acknowledge that the human touch will always have the final say in truly tidy code.

Next article: Type Construction and Cycle Detection
Previous article: Allocating on the Stack
Blog Index

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