Go 1.26's `//go:fix inline` directive and source-level inlining
Contents
In the previous article about Go 1.26, I wrote about go fix turning into a tool for automatic code modernization: interface{} to any, min/max, new(expr), and so on.
This time I am looking at the other big feature, //go:fix inline. Built-in fixers handle standard-library idioms, while //go:fix inline lets package authors define migration paths for their own APIs, including third-party packages.
What source-level inlining means
Inlining is the process of replacing a function call with the body of the function. Compiler inlining works on intermediate representation to optimize runtime performance. What go fix does here is the source-level version: it rewrites .go files directly and permanently.
graph LR
A["User code<br/>ioutil.ReadFile()"] --> B["run go fix"]
B --> C["The inliner detects<br/>the directive"]
C --> D["Replace the call<br/>with the function body"]
D --> E["Rewritten code<br/>os.ReadFile()"]
gopls already had an “Inline call” refactoring action, and now go fix can apply the same transformation from the command line in bulk.
Basic behavior with ioutil.ReadFile
In Go 1.16, ioutil.ReadFile became a thin wrapper around os.ReadFile and was deprecated.
package ioutil
import "os"
// Deprecated: As of Go 1.16, this function simply calls [os.ReadFile].
func ReadFile(filename string) ([]byte, error) {
return os.ReadFile(filename)
}
Add //go:fix inline:
// 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)
}
Then go fix -diff ./... rewrites the call site:
-import "io/ioutil"
+import "os"
- data, err := ioutil.ReadFile("hello.txt")
+ data, err := os.ReadFile("hello.txt")
The key point is that the package author supplies the migration rule. That is different from built-in fixers such as interface{} to any.
It also works for design mistakes
The mechanism is not limited to renames. It can also handle more complex API changes, such as fixing parameter order.
package oldmath
// Sub returns x - y.
// Deprecated: the parameter order is confusing.
//go:fix inline
func Sub(y, x int) int {
return newmath.Sub(x, y)
}
If users call oldmath.Sub(1, 10), go fix rewrites it to newmath.Sub(10, 1) so the new API is used with the corrected order.
It also supports type aliases and constants:
//go:fix inline
type Rational = newmath.Rational
//go:fix inline
const Pi = newmath.Pi
The six tricky cases the inliner handles
The implementation is about 7,000 lines long because source-level inlining has to preserve correctness and readability.
1. Removing or keeping parameters
If an argument is a literal and a parameter is used only once, the call can be replaced directly.
//go:fix inline
func show(prefix, item string) {
fmt.Println(prefix, item)
}
show("", "hello")
// → fmt.Println("", "hello")
If a parameter is used multiple times, the inliner inserts explicit bindings so it does not scatter literals around the code.
//go:fix inline
func printPair(before, x, y, after string) {
fmt.Println(before, x, after)
fmt.Println(before, y, after)
}
printPair("[", "one", "two", "]")
// →
var before, after = "[", "]"
fmt.Println(before, "one", after)
fmt.Println(before, "two", after)
2. Evaluation order of side effects
func add(x, y int) int { return y + x }
z = add(f(), g())
A naive rewrite would become g() + f(), which changes evaluation order. If the inliner can prove there are no side effects, it rewrites directly; otherwise it inserts bindings.
var x = f()
z = g() + x
This is one of the major differences from compiler inlining.
3. Compile-time constant traps
Sometimes a literal replacement can accidentally trigger compile-time evaluation and produce a build error.
//go:fix inline
func index(s string, i int) byte { return s[i] }
index("", 0)
""[0] is evaluated at compile time and fails. The inliner tracks those cases and keeps bindings when needed.
4. Variable shadowing
If an identifier in an argument expression is shadowed by a variable in the function body, the meaning changes after rewriting.
//go:fix inline
func f(val string) {
x := 123
fmt.Println(val, x)
}
x := "hello"
f(x)
The inliner detects that and isolates the binding in its own block:
x := "hello"
{
var val string = x
x := 123
fmt.Println(val, x)
}
5. Avoiding unused-variable errors
If an argument expression is removed and it was the last reference to a local variable, Go would reject the code as unused. The inliner tracks that too.
//go:fix inline
func f(_ int) { print("hello") }
x := 42
f(x)
// → x := 42 ← error: unused variable: x
// print("hello")
6. defer is out of bounds
Functions containing defer are not candidates. defer runs when the function returns, so inlining would move execution to the caller’s return point and change semantics.
graph TD
A["Detect function with //go:fix inline"] --> B{"Contains defer?"}
B -- Yes --> C["Reject inlining"]
B -- No --> D{"Is side-effect order safe?"}
D -- Yes --> E["Direct replacement"]
D -- No --> F["Insert parameter bindings"]
E --> G{"Any shadowing issues?"}
F --> G
G -- Yes --> H["Isolate in a block"]
G -- No --> I["Done"]
H --> I
Real-world use at Google
This inliner builds on experience from similar tools in Java, Kotlin, and C++. Inside Google, it has already been used to remove millions of calls to deprecated functions. The Go version has been applied to more than 18,000 changelists in the internal monorepo.
The scale matters. Google’s monorepo is huge, and manual API migration would not be realistic. Source-level transformation tools like this are the only practical way to keep moving.
How to use it
In an editor, use the gopls “Inline call” code action. In batch mode, add //go:fix inline to your deprecated function and run:
go fix -diff ./... # preview changes
go fix ./... # apply them
The built-in fixers and //go:fix inline run through the same go fix command, so one invocation can handle both standard modernizations and package-specific migrations.
The built-in fixers were the main story in the last article, but //go:fix inline might matter even more. Package authors can now give their users a one-shot migration path instead of writing long manual upgrade guides.