Go Internals - Interfaces
Introduction
In Go Internals - Built-in Types we looked at concrete layouts like slice headers and Swiss-table maps. Interfaces are the opposite side of that story: they hide concrete types at the language level, but the runtime still needs exact type metadata and call targets to execute method calls safely.
If you have ever wondered why assigning to interface{} can allocate, why some type assertions are almost free while others are not, or why dynamic dispatch shows up as extra indirection in CPU profiles, this is the layer to read.
Two runtime shapes: eface and iface
At runtime, interface values are represented by two words, but there are two variants:
efacefor empty interfaces (interface{}/any)ifacefor non-empty interfaces (io.Reader,fmt.Stringer, custom method sets)
Roughly:
// conceptual, simplified from runtime2.go
type eface struct {
_type *_type
data unsafe.Pointer
}
type iface struct {
tab *itab
data unsafe.Pointer
}
eface points directly to type metadata (_type) plus data. iface uses itab, which combines concrete type + interface type + method dispatch metadata. Either way, the second word is a pointer to where the concrete value lives (or the value bits themselves for some tiny pointer-free cases after compiler lowering).
flowchart LR
E["eface: _type + data"] --> T["_type metadata"]
E --> D["concrete value"]
I["iface: itab + data"] --> IT["itab: interface+concrete mapping"]
I --> D2["concrete value"]
The important point is that interface values are not "magic boxes." They are concrete structs that the compiler and runtime agree on.
How an itab is used
For a non-empty interface assignment, the runtime must answer:
- Does the concrete type implement this interface method set?
- If yes, where are the function entry points for each interface method?
That mapping is held in itab (see runtime/iface.go). When available, method calls through an interface become indexed function pointer calls through that table.
sequenceDiagram
participant C as concrete value
participant R as runtime/iface
participant I as iface value
C->>R: convert to interface type
R->>R: resolve or build itab
R->>I: store itab pointer + data pointer
I->>I: call method via itab function slot
itab lookup is cached. First-time conversions for a concrete/interface pair are more expensive than repeated conversions on hot paths.
What "cached itab lookup" means in runtime terms
In runtime/iface.go, Go keeps a global itab table keyed by the pair:
- interface type
- concrete type
Conversion to a non-empty interface usually follows this shape:
- Fast path (lock-free read): probe the current table for
(interfacetype, *_type). - Hit: reuse existing
itaband continue. - Miss: fall back to slow path, take runtime lock, validate method set compatibility, allocate/install new
itab, then publish it. - Future conversions: hit fast path again.
That means the expensive compatibility work is typically paid once per type pair in a process lifetime, then amortized across calls.
flowchart TB
A["convert concrete -> interface"] --> B{"itab in table?"}
B -->|yes| C["reuse cached itab"]
B -->|no| D["slow path: lock + build/insert itab"]
D --> E["publish table entry"]
E --> C
Why this matters for hot services
Two practical consequences:
- Cold start / first-hit spikes: endpoints that exercise many concrete types behind the same interface can pay a burst of slow-path
itabcreation early. - Steady state is cheaper: once common pairs are materialized, conversion overhead drops to mostly table probe + pointer loads.
This is one reason short microbenchmarks can mislead: if a run repeatedly measures only first conversions, it overstates interface conversion cost versus long-lived servers.
Caching does not remove all interface overhead
Even with a hot itab cache:
- method calls still dispatch indirectly through function pointers,
- boxed values may still escape depending on lifetime,
- assertions and switches still perform runtime checks.
So the cache removes repeated resolution cost, not all dynamic behavior cost.
Boxing: where values go
When you assign a concrete value to an interface, the runtime stores a reference to that value in the interface data word. Whether that implies heap allocation depends on escape analysis and value shape.
type Big struct {
a, b, c, d, e, f, g, h int64
}
func box() any {
v := Big{a: 1}
return v // often escapes: interface outlives local frame
}
If the compiler proves the boxed value does not outlive the frame, it can stay on stack. If not, boxing goes through allocation paths you already saw in the allocator article.
Useful check:
go build -gcflags='-m=2' ./...
Look for messages around "escapes to heap" near interface conversions. That output is often the fastest way to explain a surprising allocation in a benchmark.
Method dispatch cost in practice
A direct concrete call is usually one static target. An interface call has extra steps:
- Load
itab - Load function pointer from method slot
- Call indirectly with receiver data pointer
This overhead is usually small but real in very hot loops.
type Adder interface {
Add(int, int) int
}
type impl struct{}
func (impl) Add(a, b int) int { return a + b }
func viaInterface(x Adder, n int) int {
s := 0
for i := 0; i < n; i++ {
s += x.Add(i, i+1)
}
return s
}
The bigger cost is often not the indirect call itself, but missed optimization opportunities around it (for example, less inlining compared to monomorphic direct calls).
Type assertions and type switches
Type assertion from interface to concrete type checks runtime type metadata:
v, ok := x.(MyType)
For eface, the runtime compares concrete _type. For iface, it may involve interface/concrete relation checks and cached paths. A failed assertion has branching cost but no panic in comma-ok form.
Type switches are a series of such checks with compiler/runtime help:
switch v := x.(type) {
case string:
_ = v
case int:
_ = v
default:
}
On critical paths, repeated assertions can dominate more than dispatch itself if they are inside deeply nested loops.
Nil pitfalls: interface nil vs typed nil
One of the most common production bugs around interfaces:
var p *MyError = nil
var err error = p
fmt.Println(err == nil) // false
err is not nil because the interface value has:
- non-nil type metadata (
*MyError) - nil data pointer
Both words must be nil for the interface itself to be nil.
flowchart TB
A["interface value"] --> B["type word != nil"]
A --> C["data word == nil"]
B --> D["interface != nil"]
C --> D
This is why returning (*MyError)(nil) as error can break if err != nil logic.
Empty interface vs constrained interface
Choosing between any and a specific interface changes runtime behavior:
| Choice | Runtime shape | Typical trade-off |
|---|---|---|
any / interface{} |
eface |
More generic, often needs type assertions later |
interface with methods |
iface + itab |
Direct polymorphism via method set, method dispatch overhead |
any is great for generic containers and reflection-heavy boundaries, but if your call site immediately asserts to one of two types, a narrower interface or generics may be clearer and cheaper.
Interface dispatch vs generics
Both solve abstraction, but they pay different costs:
| Topic | Interfaces | Generics (monomorphized stenciling/dictionaries depending case) |
|---|---|---|
| Dispatch | Runtime dynamic (itab) |
Usually static at instantiation sites |
| Type checks | Runtime assertions possible | Compile-time constraints + generated paths |
| Code size | Smaller shared code | Can increase with many instantiations |
| Hot loop perf | May lose inlining/devirt | Often closer to concrete calls |
There is no universal winner. For plugin-like boundaries and heterogeneous values, interfaces are the right tool. For tight numeric/data loops with predictable types, generics can remove dynamic dispatch from the hot path.
Practical guidelines
- Keep interface boundaries at package seams, not every internal helper.
- Prefer small behavior-focused interfaces (
Read,Write,Do) over giant "god interfaces". - Benchmark before replacing interfaces with generics or concrete types.
- Use
-gcflags='-m=2'andpproftogether: one explains escapes, the other shows where time actually goes.
Conclusion
Interfaces in Go are concrete runtime objects: eface and iface, with itab powering method dispatch for non-empty interfaces. The core costs come from boxing/escapes, indirect calls, and assertion patterns, not from a single hidden penalty switch.
Next in the series is Reflection — how reflect.Type and reflect.Value layer on top of these same runtime type descriptors, and why reflection-heavy paths often allocate more than expected.