· 6 min read ·

Why Go's Source-Level Inliner Required 7,000 Lines to Do Something That Sounds Simple

Source: go

When the Go team published the //go:fix inline blog post on March 10, 2026, the most interesting thing about it was not the directive syntax. It was the implementation: roughly 7,000 lines of dense, compiler-like logic to perform an operation that, on the surface, sounds trivial. Take a function call, replace it with the function’s body, adjust imports as needed, and move on. The complexity lurking beneath that description is worth unpacking, because it illuminates something important about the difference between compiler inlining and source-level inlining, and about what it takes to automate library migrations correctly at scale.

The Two Kinds of Inlining

The Go compiler has inlined functions since Go 1.0. The rules are well-known: if a function’s body contains fewer than 80 AST nodes, does not contain defer or recover, and is not marked //go:noinline, the compiler substitutes the body at the call site during compilation. You can observe the decisions with go build -gcflags='-m'.

But compiler inlining is ephemeral. It happens to IR, it disappears after compilation, and it leaves source code unchanged. That is fine when the goal is performance; it is useless when the goal is migration.

Source-level inlining is a different operation with a different goal. The output must be valid, idiomatic Go source code that a human could have written, that compiles independently without any knowledge of the original function, and that preserves the original program’s semantics exactly. The compiler inliner can get away with transformations the source-level inliner cannot, because it has full IR access and can reason about types, escape analysis, and control flow at a level that source text cannot represent.

The source-level inliner lives in golang.org/x/tools/internal/refactor/inline, was built starting in 2023, and forms the foundation for //go:fix inline in Go 1.26.

What the Directive Looks Like in Practice

The syntax is straightforward. Place //go:fix inline immediately before a function, constant, or type alias declaration:

package ioutil
import "os"

// 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)
}

Run go fix ./... and every call to ioutil.ReadFile in your codebase becomes a call to os.ReadFile, with the import rewritten accordingly:

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

Constants and type aliases work too, with the restriction that a constant can only bear the directive if it refers to another named constant, not a literal or expression.

The feature also integrates with gopls. Once a library author adds //go:fix inline to a function, gopls surfaces a diagnostic at every call site in the editor the moment you update the dependency, with a one-click suggested fix. You do not have to wait for a batch run.

Why the Implementation Is Hard

The 7,000 lines exist because naive substitution breaks programs in subtle ways. There are six categories of difficulty.

Parameter elimination and multi-use parameters. Consider a function that uses a parameter more than once:

func printPair(before, after string, a, b string) {
    fmt.Println(before, a, after)
    fmt.Println(before, b, after)
}

Calling printPair("[", "]", "one", "two") cannot be naively inlined by textual substitution if before and after are non-trivial expressions, because those expressions would then be evaluated multiple times. The inliner must insert a binding declaration:

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

Single-use parameters with simple literal arguments can be substituted directly. Everything else requires reasoning about whether materialization changes program behavior.

Side effects and evaluation order. Arguments can have side effects that interact. Go evaluates function arguments left-to-right, so if a callee body is return x + y and the caller passes f() for x and g() for y, the inliner must preserve that f() runs before g(). A hazard analysis algorithm models effect ordering through the callee body and inserts bindings where needed. Arguments used inside loops require additional care because the cardinality of effects changes.

Fallible constant expressions. When arguments happen to be constants, an expression that was safe at runtime can become a compile-time error after inlining. If a function body indexes a string parameter, and the caller passes a constant empty string, the inlined result ""[0] is a compile-time out-of-bounds error. The inliner includes a constraint solver called “falcon” (in falcon.go) that detects potentially fallible expressions and prevents constant folding from triggering them.

Name shadowing. The callee’s local variable names might shadow names in the caller’s scope, or vice versa. The inliner wraps the body in a block when necessary, renames references accordingly, and adds import statements for any packages the callee references that are not already imported in the caller.

Defer wrapping. defer cannot be eliminated because it changes control flow. When the callee contains defer, the body must be wrapped in an immediately-invoked function literal:

func() {
    defer cleanup()
    // ...
}()

The batch go fix tool refuses to emit this “literalized” form. The interactive gopls refactoring will perform it, but automated batch application is treated as too risky. That distinction is a deliberate conservative design choice.

Unused variables. If the only reference to a caller-side variable was the argument being inlined away, removal of the call site can create an “unused variable” compile error. The inliner tracks variable reference counts to catch this before it emits a broken result.

The one acknowledged exception to the “no behavior change” guarantee: inlining can change call-stack reflection results, since the callee frame disappears. The team treats this as an acceptable trade-off for the migration use case.

The Broader go fix Ecosystem

The //go:fix inline analyzer is part of a larger modernizer suite shipped with Go 1.26’s rewritten go fix command. The tool now runs on the go/analysis framework, the same infrastructure powering go vet. This enables interprocedural analysis via the facts mechanism: the inline analyzer can discover //go:fix inline directives in dependencies and apply them to your code even when the deprecated function lives in a different package.

The full suite includes transformations like replacing interface{} with any, rewriting strings.Index + slice patterns with strings.Cut, swapping manual min/max patterns with the Go 1.21 builtins, and converting three-clause for loops to for range n. You can run everything at once with go fix ./..., preview as a diff with go fix -diff ./..., or target a single analyzer by name.

The inline analyzer differs from the others in one important respect: it is driven by library authors, not by the Go team. When a package author marks a function //go:fix inline, they publish a machine-readable migration instruction that travels with the package. Every consumer can run go fix ./... and automatically apply the migration. This is a qualitatively different model from deprecation comments, which require a human to read and interpret them.

What This Means for Library Authors

If you maintain a Go package and have functions you want to eventually remove, //go:fix inline gives you a tool for announcing deprecation in a way that tools can act on. The pattern: mark the old function with the directive, keep it around for a few major versions while users run go fix, then remove it once adoption is high.

The proposal behind this feature has been open since 2019. The inliner engine took years to build correctly. Google’s internal application of the technology produced more than 18,000 changelists across their monorepo before the public feature shipped. That scale was only achievable because the correctness bar was high: a tool that occasionally introduces subtle bugs is worse than no tool at all.

The conservative defaults reflect that philosophy. The batch tool refuses to produce deferred-wrapper forms. The -inline.allow_binding_decl=false flag lets you opt out of inlinings that require introducing new variable declarations. The go fix -diff mode lets you audit before applying. Every design choice is oriented around making automated migration something you can run on a large codebase without reviewing every output manually.

For Go library authors, the practical takeaway is straightforward: if you are deprecating a function in favor of one that is a direct replacement or a simple rewrapping, //go:fix inline gives your users a path to migrate automatically rather than waiting for a lint warning they might never see. The machinery to make that work correctly is already in the toolchain.

Was this interesting?