- Book: The Complete Guide to Go Programming
- Also by me: Pocket Guides for Developers — the 8-book series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
ioutil.ReadFile has been a one-line wrapper around os.ReadFile since Go 1.16. Its doc comment carries a Deprecated: line that points you at os.ReadFile. For years that comment made linters complain and never moved a single call site.
You know the library-author version of this. You ship v1.4.0 with a better API, mark the old function Deprecated:, and then watch the old one get called for the next three years because nobody downstream has a free afternoon to rewrite fifty call sites by hand.
Go 1.26 changed the deal. The release notes describe a completely rebuilt go fix that includes "a source-level inliner that allows users to automate their own API migrations using //go:fix inline directives." You put one comment on the old function. Anyone who runs go fix gets their calls rewritten to the new one.
The directive is half the story. The other half is the list of calls the inliner won't touch, and you need both before you put go fix in a release note.
What changed in Go 1.26
Go 1.26 arrived in February 2026. The old go fix fixers were all obsolete and got removed. The new command is built on the same analysis framework as go vet, so an analyzer that can report a problem in go vet can also apply the fix in go fix.
Most of those analyzers are modernizers: any replaces interface{}, minmax turns if/else chains into min/max, and so on. Alan Donovan's go fix post on the Go blog (17 February 2026) lists them. You can print the list yourself:
go tool fix help
go tool fix help inline
One analyzer is different: inline applies whatever migration you wrote down as a //go:fix inline directive on your own API. Donovan's follow-up post on the inliner (10 March 2026) uses the ioutil.ReadFile case above as its example: the directive goes on the wrapper, and callers end up calling os.ReadFile directly.
The library: write the old API in terms of the new one
Here is a small module, example.com/shop, with go 1.26 in its go.mod. The money package started out with FormatCents. The new API is Format, which takes a Locale so callers outside the US can get a comma.
package money
import "fmt"
// Locale controls how amounts are rendered.
type Locale struct {
Decimal string
}
// DefaultLocale uses a dot as the decimal separator.
var DefaultLocale = Locale{Decimal: "."}
// Format renders an amount in cents, e.g. "19.99".
func Format(cents int64, loc Locale) string {
sign := ""
if cents < 0 {
sign, cents = "-", -cents
}
return fmt.Sprintf("%s%d%s%02d",
sign, cents/100, loc.Decimal, cents%100)
}
// FormatCents renders an amount in cents, e.g. "19.99".
//
// Deprecated: use Format with DefaultLocale.
//go:fix inline
func FormatCents(cents int64) string {
return Format(cents, DefaultLocale)
}
The important move is the body of FormatCents. It is the old API expressed as a call to the new one. The inline analyzer docs recommend exactly this: when you deprecate something, write the old function as a thin wrapper over its replacement. The inliner's job is to paste that body into each call site, with parameters replaced by arguments and names qualified for the caller's package.
If FormatCents did its own formatting with its own logic, the inliner would still inline it, but callers would receive a copy of that logic instead of a call to Format. That's a migration to nowhere. Make the wrapper thin first, then add the directive.

The old function becomes a signpost: its body says where every caller should go.
Preview, then apply, on a consumer
A consumer package in the same module:
package main
import (
"fmt"
"example.com/shop/money"
)
type Order struct {
ID string
Cents int64
}
func main() {
o := Order{ID: "A-17", Cents: 1999}
total := o.Cents * 3
fmt.Println(o.ID, money.FormatCents(o.Cents))
fmt.Println("total", money.FormatCents(total))
}
Start from a clean git tree. The go fix post recommends this so the resulting change "consists only of edits from go fix." Then preview without touching files:
go fix -diff ./...
To run only the inliner and skip the modernizers, the analyzer docs give go fix -inline ./.... Add -diff to preview:
go fix -inline -diff ./...
I haven't run this exact module myself. Going by the inliner's documented rules, the change you should see is this: both arguments are plain expressions used once in the body, so they get substituted directly, and Format and DefaultLocale get the money. qualifier.
- fmt.Println(o.ID, money.FormatCents(o.Cents))
- fmt.Println("total", money.FormatCents(total))
+ fmt.Println(o.ID, money.Format(o.Cents, money.DefaultLocale))
+ fmt.Println("total", money.Format(total, money.DefaultLocale))
It has the same shape as the diff the inliner post shows for ioutil, where the import gets swapped as well:
-import "io/ioutil"
+import "os"
- data, err := ioutil.ReadFile("hello.txt")
+ data, err := os.ReadFile("hello.txt")
When the diff looks right, drop -diff. On success go fix "silently updates your source files," per the go fix post. Run go build ./... && go test ./..., review, commit.
The same thing works across module boundaries. Calls from other packages get flagged too, not only the package that declares the function (analyzer docs). So when a downstream team bumps to your v1.4.0 and runs go fix ./..., their FormatCents calls migrate in their own repo, and nobody from your side has to open a PR.
Functions aren't the only thing you can mark. The directive also works on a constant that refers to another named constant (the docs' example is const Ptr = Pointer) and on type aliases, whose uses get replaced by the right-hand-side type. That covers the "we renamed it" and "we moved it to v2" migrations.
What the inliner refuses to touch
The inliner's design rule is that it must not change what your program does. When it can't prove a rewrite is safe, it either does something clumsier or does nothing. The cases worth knowing before you rely on it:
Unexported names in the body. If FormatCents returned Format(cents, defaultLocale) with a lowercase defaultLocale, a caller in another package couldn't legally write that name. The x/tools inliner rejects that call with "cannot inline call to %s because body refers to non-exported %s." Everything the wrapper's body touches has to be exported, or the migration only works inside your own package.
Arguments with side effects. When substitution could reorder effects, or run an effect once per loop iteration instead of once, the inliner keeps the argument in a binding declaration (var x = f()) and uses x in the inlined body. The inliner post walks through those cases. The result is correct, but it can look odd. If you'd rather skip those call sites, the analyzer docs give -inline.allow_binding_decl=false.
Bodies with defer. Paste a deferred call into a caller and it runs when the caller returns, which is too late. The inliner in gopls handles that by wrapping the body in func() { ... }(). go fix refuses to inline such "literalized" calls, so a wrapper that uses defer won't migrate anyone through go fix.
Your own tests. Uses of a symbol inside its dedicated test (TestFormatCents, say) are skipped, and so are uses in money_test.go of something declared in money.go (analyzer docs). The tests for the deprecated function keep testing the deprecated function.
Generated files. go fix "discards any fix that touches generated files," per the go fix post, since the real fix belongs in the generator.

The inliner rewrites what it can prove safe and leaves the rest for you to handle.
Files outside the current build. Like go build and go vet, each go fix run analyzes one build configuration. A call in a _windows.go file won't move when you run on a Mac. Repeat the run with GOOS/GOARCH set for each platform you ship.
Conflicts. If two fixes edit overlapping text, go fix keeps the first, discards the other, and warns you to rerun. The go fix post says "twice is usually enough." Semantic conflicts are worse: two edits that are each fine but break the build together, for example two inlinings that each remove the second-to-last use of a variable. Those show up as compile errors and you fix them by hand.
A release checklist for library authors
Put together, deprecating a function in Go 1.26 looks like this:
- Ship the new API first, in the same release or an earlier one.
- Rewrite the old function's body as a single call to the new API. Keep it free of
deferand make every name in it exported. - Add
Deprecated:as its own paragraph in the doc comment, then//go:fix inline. - In your release notes, tell users to run
go fix -inline -diff ./..., thengo fix -inline ./..., and to rerun until it settles. - Keep the old function. The directive migrates callers who run
go fix. Anyone who doesn't will still compile against it, and removing it is a breaking change that needs a new major version.
Remove FormatCents in v2, not before. Until then it is the thing that lets each user migrate with one command and one diff, on their own schedule.
If this was useful
Whether a //go:fix inline migration can cross your package boundary comes down to Go's rules for packages and exported names. The Complete Guide to Go Programming covers the language, the runtime, the standard library and concurrency from the ground up, including those rules. If this post is the first time you thought hard about who can call what, start with the book.

Top comments (0)