go:fix inline You Should Know in Golang

Go 1.26’s Source-Level Inliner — Self-Service API Migration at Scale

image.png|300

1. Introduction

Every Go developer has seen the deprecation notice: “Deprecated: use os.ReadFile instead.” But actually migrating all those ioutil.ReadFile calls across a large codebase has historically been a manual, error-prone process.

Go 1.26 solves this with a redesigned go fix command and a new //go:fix inline directive. Together, they enable package authors to embed migration logic directly in their code, letting users run a single command to automatically rewrite deprecated API calls to their modern equivalents.

In this article, we’ll explore how the source-level inliner works, the six technical challenges it solves, and what this means for Go API evolution.

2. The Problem: Deprecation Without Migration

Before Go 1.26, deprecating an API in Go looked like this:

1
2
3
4
// Deprecated: Use os.ReadFile instead.
func ReadFile(filename string) ([]byte, error) {
return os.ReadFile(filename)
}

Users saw the deprecation notice in their IDE, but migrating required:

  1. Manually finding all call sites (grep, IDE search)
  2. Rewriting each one by hand
  3. Potentially adjusting import statements
  4. Reviewing the diff

For large codebases or popular packages, this is an enormous amount of friction. The result? Deprecated APIs persist for years, and libraries hesitate to make breaking changes.

3. The Solution: //go:fix inline

Go 1.26 introduces the //go:fix inline directive. You place it on a function that is a thin wrapper, and go fix will automatically replace calls to that function with its body.

Here’s the basic usage:

1
2
3
4
5
6
7
8
9
package ioutil

import "os"

// Deprecated: Use os.ReadFile instead.
//go:fix inline
func ReadFile(filename string) ([]byte, error) {
return os.ReadFile(filename)
}

Now, running go fix -fix=inline ./... on any codebase that imports ioutil will automatically rewrite:

1
2
3
4
5
// Before
data, err := ioutil.ReadFile("config.json")

// After
data, err := os.ReadFile("config.json")

Note that go fix also handles import cleanup — removing ioutil from imports and adding os if not already present.

4. Six Technical Challenges

The implementation sounds simple, but correctly inlining a function call is surprisingly complex. The Go team documented six challenges they had to solve:

Challenge 1: Parameter Elimination

When a parameter appears multiple times in the function body, naively substituting the argument would evaluate it multiple times:

1
2
3
4
5
6
7
8
//go:fix inline
func Double(x int) int { return x + x }

// Naive substitution of Double(expensiveCall())
// Would become: expensiveCall() + expensiveCall() ← WRONG

// Correct: inserts an explicit binding
// result := func() int { v := expensiveCall(); return v + v }()

The inliner detects multi-use parameters and inserts explicit bindings to ensure single evaluation.

Challenge 2: Side Effects

Function call arguments are evaluated left-to-right. Reordering them during inlining can change observable behavior:

1
2
3
4
5
//go:fix inline
func Swap(a, b int) (int, int) { return b, a }

result1, result2 := Swap(sideEffect1(), sideEffect2())
// Must preserve evaluation order of side effects

The inliner analyzes whether safe reordering is possible, falling back to parameter bindings when it cannot be proven safe.

Challenge 3: Fallible Constant Expressions

Some substitutions would create compile-time errors that didn’t exist before:

1
2
3
4
5
6
//go:fix inline
func FirstChar(s string) byte { return s[0] }

// If s is a constant "", then s[0] becomes ""[0] at compile time
// ""[0] is a compile error (index out of range)
// The original call would have been a runtime panic, not a compile error

The inliner avoids creating constant expressions that could fail at compile time, preserving runtime semantics.

Challenge 4: Shadowing

Identifiers in the function body must refer to the same symbols after inlining:

1
2
3
4
5
6
7
8
package pkg

var os = "not the os package"

//go:fix inline
func ReadFile(name string) ([]byte, error) {
return os.ReadFile(name) // refers to "os" package, not pkg.os
}

When inlined into a caller that has a local os variable, os.ReadFile would resolve incorrectly. The inliner inserts bindings to preserve the original namespace.

Challenge 5: Unused Variables

Go’s compiler rejects unused variables. A naive inlining might create variables that are never used in the caller’s context:

1
2
3
4
//go:fix inline
func Noop(x int) {} // discards x

Noop(compute()) // compute()'s result needs to be "used"

The inliner tracks the last reference to each local variable and generates code that prevents “declared and not used” errors.

Challenge 6: Defer Statements

Functions containing defer cannot be straightforwardly inlined because defer semantics are tied to function scope:

1
2
3
4
5
6
//go:fix inline
func WithLock(mu *sync.Mutex, f func()) {
mu.Lock()
defer mu.Unlock()
f()
}

For these cases, the inliner wraps the body in an immediately-invoked function literal:

1
2
3
4
5
6
// Inlined as:
func() {
mu.Lock()
defer mu.Unlock()
f()
}()

5. Real-World Impact

The Go team has already used this mechanism to prepare over 18,000 changelists for automated code modernization in Google’s internal monorepo. This represents the practical scale at which this feature operates.

For the broader Go ecosystem, this changes how library authors can think about API evolution:

1
2
3
4
5
6
7
8
9
10
// v1 API (deprecated)
//go:fix inline
func OldAPI(x, y int) int {
return NewAPI(y, x) // note: parameter order fixed in new API
}

// v2 API (current)
func NewAPI(a, b int) int {
return a - b
}

Users just run go fix and get a correct migration, even when parameter semantics changed.

6. Using It Today

To apply inline fixes in your project:

1
2
3
4
5
# Fix all inline-annotated deprecations in current module
go fix -fix=inline ./...

# Preview what would change (dry run)
go fix -fix=inline -diff ./...

To mark your own deprecated functions for auto-migration:

1
2
3
4
5
6
7
// YourPackage/compat.go

// Deprecated: Use NewFunction instead.
//go:fix inline
func OldFunction(x int) int {
return NewFunction(x * 2) // encodes the migration logic
}

Note that //go:fix inline only works on functions that are pure wrappers — their entire body must be a single return statement calling the replacement.

7. Conclusion

The //go:fix inline directive and the redesigned go fix command represent a significant step forward in how Go handles API evolution. Package authors can now embed migration paths directly in their code, and users can execute those migrations automatically. It shifts the burden of deprecation from “every user manually migrates” to “the package author writes the migration once.”

The six technical challenges the team solved — side effects, shadowing, defer, unused variables, fallible constants, and multi-use parameters — show just how much correctness work goes into what looks like a simple text substitution.

Have you ever manually migrated ioutil calls across a large codebase? Would //go:fix inline have saved you hours of work? Share your thoughts!


More in the “You Should Know In Golang” series:
https://wesley-wei.medium.com/list/you-should-know-in-golang-e9491363cd9a

Heartbeats in Distributed Systems You Should Know in Golang Go Regex You Should Know in Golang

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×