skip to content

Why can storing a concrete value in a Go interface variable allocate on the heap?

level: middleimportance: should knowfreq 48%

answer

  1. the interface holds one word
  2. that word is a pointer
  3. so non-pointers need somewhere to point
  4. escape analysis picks stack or heap

basics

~20 s

An interface value holds a pointer to its data, so a value that is not pointer-shaped must be copied somewhere that pointer can point at. If the compiler cannot prove the copy stays in the frame, it goes on the heap.

solid answer

~50 s

An interface value keeps the dynamic type alongside a single data word, and that word is a pointer. Types that are already pointer-shaped — a `*T`, a map, a channel, a func value, an `unsafe.Pointer` — are stored in it directly and cost nothing extra. Anything else, such as a struct or an `int64` computed at run time, must be copied to an address the interface can reference; that copy is the boxing, and if escape analysis cannot prove the interface value dies with the frame, it lands on the heap. So `var s Scaler = boxScaler{...}` can allocate while `var s Scaler = &boxScaler{...}` does not add a boxing allocation. Small constant integers are cheap because the conversion reuses static storage rather than allocating. On a hot path you spot these with `go build -gcflags=-m` and remove them by storing a pointer, or by not going through an interface at all.

code

go · 12 lines
go
type Size struct{ W, H int }

// Set elsewhere, so the compiler cannot see what it does with its argument.
var sink func(any)

func box(px []byte) {
	// Struct value: copied into the interface value, and the copy may be heap-allocated.
	sink(Size{W: 128, H: 128})

	// Pointer: stored in the interface's data word directly, no boxing copy.
	sink(&px)
}

go deeper

for a junior

Know that putting a value into an interface variable is not always free: the value may be copied, and that copy can end up on the heap.

for a middle

Explain that the interface's data word is a pointer, so pointer-shaped types go in directly while other values are copied, and that escape analysis decides whether the copy is on the stack or the heap.

for a senior

Demonstrate finding one of these with the compiler's escape output on a real hot path and removing it without changing behaviour — hoisting the conversion, storing a pointer, or keeping the loop off the interface entirely.

for a principal

Judge when an allocation per call is worth an API change at all, and set the expectation that such a claim arrives with a benchmark rather than an assertion about interfaces being slow.

## What has to be true for an interface to hold anything An interface variable must be able to hold a value of any type that satisfies it — an eight-byte `int64` today, a 300-byte struct tomorrow — while itself staying a fixed size. Go solves that the way most languages do: the interface value stores the dynamic type information plus one **data word**, and that data word is a pointer. Every concrete value reachable through an interface is therefore reachable *through a pointer*. (The exact field layout is a runtime detail; what matters here is the consequence.) ## Pointer-shaped values are free; everything else is copied If the concrete type is already a single pointer at machine level, it can be dropped into the data word as it is. That covers `*T`, maps, channels, function values and `unsafe.Pointer`. Converting one of those into an interface copies eight bytes and allocates nothing. Every other type — a struct, an array, a `float64`, a run-time `int64`, a string, a slice — is not a single pointer, so the compiler must put the value somewhere and store *that address* in the interface. This copy is what people mean by **boxing**. Two things follow. First, the copy is a real copy of the whole value. Putting a 300-byte struct into an interface copies 300 bytes; putting a `*Struct` in copies eight. Second, the copy needs storage with the right lifetime. If the interface value cannot outlive the current function, the copy can live in the stack frame and costs nothing. If the compiler cannot prove that — the interface is returned, stored in a longer-lived structure, or handed to a callee the compiler cannot see — the copy is heap-allocated, once per conversion. ## Where escape analysis decides it Escape analysis is the compile-time pass that answers *can this value die with the frame?* You can read its verdicts: ``` go build -gcflags=-m ./... ``` The interesting lines are the ones naming your value: an argument reported as not escaping means the boxed copy stayed on the stack; a value reported as escaping to the heap is an allocation per execution of that line. On a path that runs per request or per frame, that single line is your allocation-per-call. The recurring cause on hot paths is an *indirect* call. When you pass a value into a method reached through an interface, the compiler has no body to analyse, so it must assume the callee keeps what it was given. Everything passed there escapes. That is why the boxing allocation so often appears exactly where you also lost inlining. ## Cases that do not allocate - Pointer-shaped types, as above. - Small integer conversions: the runtime keeps static storage for small values, so putting a small constant into an `any` typically reuses it rather than allocating. Do not build a rule on it — the same code with a run-time value outside that range does allocate. - Zero-sized values such as `struct{}{}`, which need no distinct storage. - Any boxing the compiler can prove is frame-local. ## Removing it when it matters In rough order of preference on a measured hot path: 1. **Do not cross the interface in the loop.** Take the concrete type in the inner function and keep the interface at the package boundary, where the conversion happens once per request rather than once per item. 2. **Store a pointer, not a value.** `&Thumb{...}` in the interface boxes nothing extra, at the cost of sharing mutable state and of the pointee itself likely being heap-allocated — which is a win only if you were going to allocate it anyway. 3. **Hoist the conversion.** If the same value is boxed inside a loop, convert once above the loop and reuse the interface value. 4. **Reuse storage.** If the boxed thing is a large buffer, have the caller own it and hand it in, so the loop stops creating new values to box. And then verify: re-read `go build -gcflags=-m` and re-run the benchmark with allocation reporting. An allocation you removed by reasoning is an allocation you have not removed.

  • Does converting a small constant integer to `any` allocate?
    Usually not. The runtime keeps static storage for small integer values, so that conversion reuses it, and the compiler often resolves the whole thing at compile time. The lesson does not generalise: the same conversion of a run-time value outside that small range allocates like any other boxed value.
  • Can escape analysis ever save a boxing allocation?
    Yes. If the interface value provably cannot outlive the frame — it is passed to a callee the compiler can see and that does not retain it — the boxed copy is stack-allocated and `go build -gcflags=-m` reports it as not escaping. The allocation appears precisely when the compiler loses that proof, which is why an opaque interface method call so often reintroduces it.
  • How much data does boxing actually copy for a large struct?
    All of it. Putting a 300-byte struct into an interface copies 300 bytes and allocates a 300-byte object; putting a pointer to it in copies eight bytes and adds no boxing allocation. That is the usual argument for pointers in interfaces on a hot path, paid for with shared mutable state and a pointee that is itself likely heap-allocated.

saying these in an interview costs you the question

  • Says converting to an interface never allocates
  • Says every interface conversion always allocates
  • Thinks the interface stores the value inline whatever its size
  • Believes a pointer in an interface copies the pointed-to value
  • Cannot name a way to see the decision the compiler made