What makes the Go compiler refuse to inline a function, and how does the `cannot inline` line say so?
answer
- the compiler counts, then compares
- a fixed number, not a runtime measurement
- nodes in the body, and calls cost a lot
- eighty, and the message prints both numbers
- split hot from cold instead of shrinking
basics
~20 sGo's inliner scores a function body against a fixed cost budget of 80 and refuses anything over it, printing cannot inline f: function too complex. A //go:noinline directive, an assembly body, or a construct it cannot handle also blocks inlining.
solid answer
~50 sThe compiler walks the function's syntax tree and assigns a cost to each node; if the total exceeds the inliner's budget — 80 — the function is never inlined anywhere, and `-gcflags=-m` says `cannot inline f: function too complex: cost N exceeds budget 80`. Calls inside the body are expensive in that scoring, which is why one call to a big helper can push an otherwise tiny function over. Other refusals are categorical rather than budgetary: a `//go:noinline` directive, a body implemented in assembly, or a construct the inliner does not handle, and each is named on the same line. Separately, a call whose target is not statically known — an interface method or a variable of function type — has nothing to inline at all. The usual fix is not to shrink the whole function but to split it: keep a small hot path that the compiler will inline and move the cold work into a separate function it calls.
code
go · 13 lines// Small enough to score well under the inliner's budget.
func (e *Encoder) grow(n int) {
if cap(e.buf)-len(e.buf) < n {
e.growSlow(n)
}
}
// Bulky and rarely taken; the compiler reports it on a cannot inline line.
func (e *Encoder) growSlow(n int) {
buf := make([]byte, len(e.buf), 2*cap(e.buf)+n)
copy(buf, e.buf)
e.buf = buf
}go deeper
Know that the compiler decides inlining by itself, that a function can be too big for it, and that go build -gcflags=-m tells you which functions it turned down and why.
Explain the cost model: nodes in the body summed against a fixed budget of 80, calls charged heavily, and categorical blockers such as //go:noinline or recover. Be able to say why Go's mid-stack inliner is not limited to leaf functions.
Demonstrate the hot/cold split as the response to an over-budget hot function, and be able to argue when you would leave a cannot inline line alone. Interviewers want the tradeoff — code size and cache pressure against a saved call — not enthusiasm for inlining.
Own the standard for when this kind of restructuring is allowed into a codebase: which packages are hot enough to justify shapes that exist for the compiler rather than the reader, and how that intent is recorded so the next person does not 'simplify' the split away.
## What inlining is, in Go's terms Inlining replaces a call with a copy of the callee's body. It removes the call overhead, but the bigger prize in Go is what it enables: once the body sits in the caller's frame, the compiler can constant-fold across the boundary, eliminate branches that are now provably dead, and — crucially — run escape analysis on the callee's locals in the caller's context, often proving that a value it would otherwise have had to heap-allocate never gets away. Go's inliner is **mid-stack**: a function that itself contains calls can still be inlined, so inlining composes up a few levels rather than only flattening leaf calls. It also works across package boundaries — the compiler records inlinable function bodies in a package's export data, which is why a small accessor in another package of your module can still vanish at the call site. ## The cost budget Inlining everything would explode compile time and binary size, so the decision is made by a crude, deliberately predictable heuristic: walk the callee's syntax tree and add up a cost per node. If the total is at or under the budget — **80** in current toolchains — the function becomes an inlining candidate; above it, the function is rejected once, for all call sites, and the compiler prints: ``` ./encode.go:22:6: cannot inline (*Encoder).encodeValue: function too complex: cost 214 exceeds budget 80 ``` At `-m=2` you also see the successes with their scores: `can inline (*Encoder).grow with cost 62 as: method(*Encoder) func(int)`. Two consequences follow from the shape of this heuristic and both matter in practice. First, the cost is about the **body's node count, not its runtime**. A function containing one enormous switch is expensive to the inliner even if every arm is trivial; a tight loop over a million elements is cheap, because a loop is a handful of nodes. Second, **calls dominate the score**. A call to a function the compiler cannot inline is charged heavily, so a three-line function whose middle line calls a big helper can itself blow the budget. This is what makes inlining failures propagate upward: one over-budget function can un-inline its callers, and you see a cluster of `cannot inline` lines rather than one. ## Refusals that are not about size Some functions are rejected regardless of how small they are, and `-m` names the reason on the same line: - A **`//go:noinline`** directive immediately above the declaration. - A body **implemented in assembly**, or otherwise having no Go body the compiler can copy. - A construct the inliner **does not handle**, most notably `recover`, whose semantics depend on the exact frame it runs in. And there is a fourth case that never produces a `cannot inline` line about your function at all, because it is a property of the *call site*: if the callee is not statically known — a method invoked through an interface value, or a variable of function type — there is no body to substitute. Recursive calls likewise stop at the first level, since the compiler will not expand a function into itself indefinitely. ## What to do about it The wrong instinct is to shrink the function until it slips under the budget, which usually means damaging code that was clear. The right pattern is to **split along the hot/cold line**. Keep the fast path — the bounds check, the common case, the early return — in a small function that scores well under the budget, and move the rare, bulky work into a separate function that the fast path calls. The cold function is never inlined, and that is fine: it rarely runs. The hot wrapper is inlined at every call site, and its inlined body is where the compiler gets to prove the interesting things. This is the shape of `append`-style growth helpers throughout the standard library: a tiny capacity check that inlines, delegating to a `grow`-shaped function that does not. ## Keeping perspective Inlining is not free even when it succeeds. Every inlined copy adds code, which costs binary size, instruction-cache pressure and compile time, and it makes stack traces less literal (Go's runtime reconstructs inlined frames in tracebacks and profiles, so you do still see them, but the mapping is no longer one function per frame). The budget exists because more inlining is not monotonically better. So treat `cannot inline` as information, not as a defect to fix. It matters only when a measurement already told you a specific call site is hot, or when it is the explanation for an allocation you can see in `allocs/op`. Restructuring readable code to win an inline the profile never asked for is the most common way this knowledge gets misused.
- Can the Go compiler inline a function declared in a different package?Yes. The compiler records the bodies of inlinable functions in the package's export data, so an importer can substitute them. That is why a small accessor or a tiny helper in another module package can disappear at the call site. It is also why an inlining change in a dependency can shift your allocation counts without any change to your own code.
- Why does a call inside a function count so heavily against the inlining budget?Because the cost model is measuring how much code substitution would drag in, and a call to a non-inlinable function represents an unbounded amount of work at a fixed, deliberately large charge. The practical effect is that inlining failures propagate: one over-budget helper can push each of its callers over too, which is why `-gcflags=-m` output often shows a cluster of `cannot inline` lines rather than a single one.
- If a function is inlined, does it disappear from stack traces and profiles?Not in practice. The compiler records inlining information in the binary and the runtime reconstructs the inlined frames, so panics and profiles still attribute work to the original function. What you lose is the literal one-frame-per-function correspondence — and if you are stepping through code, that is exactly why a debugging build disables inlining.
- Does a bigger budget always give faster code?No, and that is why the budget is fixed and small. Every inlined copy costs binary size, instruction-cache footprint and compile time, and past a point the cache pressure outweighs the saved calls. The wins come from a few hot call sites where inlining unlocks a further optimisation — usually keeping a value off the heap — not from inlining more code in general.
saying these in an interview costs you the question
- Thinks the inliner measures how long a function runs rather than its body size
- Believes only leaf functions can be inlined in Go
- Says cross-package functions can never be inlined
- Treats every cannot inline line as a bug to fix without a measurement
- Suggests raising the budget as the normal remedy
- Assumes an interface method call can be inlined like a direct call