skip to content

What does calling a method through a Go interface value cost compared with calling it on the concrete type?

level: juniorimportance: must knowfreq 55%

answer

  1. who picks the function, and when
  2. the jump is not the expensive part
  3. an opaque callee cannot be inlined
  4. the compiler also loses escape analysis

basics

~20 s

An interface method call is indirect: the target comes from the interface value's method table at run time, so the compiler cannot inline it or optimise across it. The lost inlining usually costs far more than the extra jump.

solid answer

~50 s

Calling `b.Scale(px)` on a concrete `Box` value is a static call: the compiler knows the body, so it can inline it, keep values in registers, propagate constants through it, and let escape analysis see exactly what the callee does with the arguments. Calling the same method through an interface variable is an indirect call through a method table that was filled in when the concrete value was stored, so the callee is opaque: no inlining, no optimisation across the call, and arguments conservatively assumed to escape. The indirect jump itself is a couple of nanoseconds and predicts well; the real cost is the optimisations it blocks, plus any allocation from boxing the value into the interface in the first place. For ordinary code this is irrelevant — reach for a concrete type only where a benchmark says the call site is hot.

code

go · 17 lines
go
type Scaler interface {
	Scale(px []byte, w, h int) []byte
}

type Box struct{ quality int }

func (b Box) Scale(px []byte, w, h int) []byte { return px[:w*h] }

// Static call: the compiler knows Box.Scale and may inline it.
func direct(b Box, px []byte) []byte {
	return b.Scale(px, 64, 64)
}

// Indirect call: the target is read from the interface value at run time.
func viaInterface(s Scaler, px []byte) []byte {
	return s.Scale(px, 64, 64)
}

go deeper

for a junior

Be ready to say that a call on a concrete type is fixed when the code is compiled, while a call through an interface picks the function at run time from whatever value was stored in it.

for a middle

Explain what the compiler gives up: inlining, optimisation across the call, and precise escape analysis of the arguments. Mention that boxing the value into the interface is a separate cost.

for a senior

Show that you would prove the cost with a benchmark and the compiler's inlining and escape output before rewriting anything, and that you keep the interface at the package edge while specialising only the measured loop.

for a principal

Own the tension between a testable seam and a hot path: state what evidence would justify removing an interface, and what substitutability the team gives up when you do.

## Two kinds of call Go reaches a method in one of two ways. If the receiver's type is known when the code is compiled — a `Box` value, a `*Box` pointer — the compiler emits a **static call**: a jump to a function it can name. Very often it emits no call at all, because the body is small enough to be *inlined*, meaning the callee's instructions are pasted into the caller and then optimised together with the surrounding code. If the receiver is an interface variable, the compiler does not know which function to jump to. The interface value carries, alongside the data, a table of the concrete type's method implementations, built when the concrete value was stored into the interface. The call loads the function pointer for the requested method out of that table and jumps through it. That is an **indirect call**, and which function actually runs is decided at run time. ## The jump itself is cheap The extra work at the machine level is small: one load of a function pointer and an indirect branch instead of a direct one. Modern CPUs predict indirect branches well, especially when the same target repeats, so on a call site that runs millions of times the branch is usually not what shows up in a CPU profile. Candidates who answer only *dynamic dispatch is slower* have not said anything useful yet. ## What it blocks is expensive The cost that matters is everything the compiler stops doing because it cannot see the callee: - **Inlining.** A one-line accessor called directly usually disappears entirely. Called through an interface, it stays a real function call with a real frame. - **Optimisation across the call.** Constants known in the caller cannot be folded into the callee's body; bounds checks the callee would have eliminated stay in; values that could have lived in registers get spilled around the call. - **Escape analysis.** This is usually the biggest one. Escape analysis decides whether a value can live in the stack frame or must be heap-allocated. For a direct call the compiler analyses the callee and learns whether it retains its arguments. For an indirect call it cannot, so it assumes the worst: arguments may be retained, therefore they escape, therefore they are heap-allocated. A slice passed into an interface method can drag its whole backing array to the heap for this reason. ## Boxing is a separate cost Getting the value *into* the interface has its own price. An interface value holds a pointer to the data, so a value that is not already pointer-shaped is copied to some address the interface can point at. If the compiler cannot prove that copy stays in the frame, the copy is a heap allocation — one per conversion, in the middle of your hot loop. ## Things that are not the cost - Method names are not looked up by string at run time; the slot is fixed when the value is stored in the interface. - There is no lock, no reflection and no runtime type search on a plain interface call. - A value-receiver method copies the receiver into its frame, but it does that on a direct call too — that is a value-receiver cost, not an interface cost. ## How to decide whether you care Measure. Write two benchmarks over the same input, one taking the concrete type and one taking the interface, and compare time and allocations per operation. Then read `go build -gcflags=-m`, which prints the compiler's inlining and escape decisions: for the concrete version you typically see the callee inlined and the arguments reported as not escaping; for the interface version you see neither. The healthy shape in real code is to keep the interface where it buys substitutability — at the package boundary, where the call happens once per request — and to use the concrete type inside the loop that runs a million times per request. That keeps the seam that makes the package testable and pluggable, and pays for dynamic dispatch at a rate that does not matter.

  • Is the indirect jump itself really the main cost?
    Rarely. The CPU predicts the indirect branch well when one target repeats. What hurts is the compiler's lost visibility: it cannot inline the callee, cannot propagate constants through it, and must assume the arguments are retained. A call that would have vanished into the caller becomes a real call plus, often, a heap allocation.
  • How would you measure the difference instead of guessing?
    Write two benchmarks over identical input, one over the concrete type and one over the interface, and compare nanoseconds and allocations per operation. Then read `go build -gcflags=-m` for both: the concrete version typically reports the callee inlined and its arguments not escaping, and the interface version reports neither.
  • Does the cost depend on how many different concrete types flow through one call site?
    Less than you might expect. Go builds no per-site inline cache, so there is no run-time cliff between one type and many; the branch predictor simply does better when one target repeats. What a single dominant type does change is what a profile-guided build can do at that site.

A direct call is a phone number you dialled yourself; an interface call is dialling a switchboard extension. The extra second on the switchboard is nothing — the loss is that you can no longer plan the conversation, because you do not know who will answer.

saying these in an interview costs you the question

  • Says interface calls cost the same as direct calls, full stop
  • Claims Go looks methods up by name string at run time
  • Names the jump as the cost and never mentions lost inlining
  • Believes the receiver is deep-copied on every interface call
  • Removes all interfaces for speed without ever benchmarking