07 / 10
Internals / 07

Interfaces

An interface value in Go is a two-word struct: a pointer to a method table and a pointer to the underlying data. That layout explains why type assertions are cheap, why interface conversions sometimes allocate, and why interface{} has slightly different bones from a method-bearing interface. This page walks through the runtime shapes and the performance consequences that fall out of them.


What an interface value is

An interface value in Go is a 16-byte two-word struct: { *itab, *data }. The first word is a pointer to an interface table, which carries the concrete type and a method table. The second word is a pointer to the underlying value — or, for a few word-sized cases, the value itself reinterpreted as a pointer.

The empty interface gets a slightly different shape called eface{ *_type, *data }. There's no method table because interface{} has no methods to dispatch. Just enough to know what the concrete type is and where the data lives.

// runtime/runtime2.go
type iface struct {
    tab  *itab          // method table + types
    data unsafe.Pointer // pointer to concrete value
}

type eface struct {
    _type *_type        // concrete type
    data  unsafe.Pointer
}

The itab

An itab — interface table — exists once per (interface_type, concrete_type) pair. The runtime builds them lazily on first use and caches them in a global hash table keyed by that pair, so subsequent uses of the same combination reuse the same itab.

Inside, an itab holds: a pointer to the interface type, a pointer to the concrete type, a small type hash used for fast switching, and an array of function pointers — the method table, with one entry per method on the interface, each filled in with the address of the concrete type's implementation.

// runtime/runtime2.go (simplified)
type itab struct {
    inter *interfacetype
    _type *_type
    hash  uint32  // copy of _type.hash, for type switches
    _     [4]byte
    fun   [1]uintptr // variable-length method table
}

A type assertion x.(T) is essentially "look up the itab for this interface, check the concrete type pointer matches T". The slow part — building the itab the first time — happens once. Every assertion after that is a pointer comparison.

Dynamic dispatch

Calling a method through an interface is two pointer dereferences: load the itab from the interface value, load the right entry from its method table, call indirect. Add the argument shuffle and you're at roughly 1.5× the cost of a direct call on modern hardware — typically a few nanoseconds, dominated by the indirect branch rather than the loads.

Since Go 1.21 the compiler will sometimes devirtualise interface calls when it can prove the concrete type at the call site — for example, when the interface variable is assigned a known concrete value within the same function. When devirtualisation fires, the indirect call becomes a direct call and may even be inlined. It's a quiet optimisation: rarely visible, occasionally important.

The branch predictor matters more than the loads. A call site that dispatches through a stable concrete type will predict well and run close to direct-call speed. A call site that sees many concrete types alternating will mispredict and pay tens of cycles per call.

Type assertions and type switches

x.(T) compiles to a pointer comparison between the itab's concrete type pointer and the target type. A type switch is just a series of these comparisons — the compiler emits a small dispatch table keyed on the type hash to keep the work down when there are many cases.

Both are cheap. A few nanoseconds per assertion on a hot path. The expensive part is what comes after — if the asserted type is one that doesn't fit in the data word and you go on to box it into another interface, that's where the allocation lives.

// Type switch — one comparison per case
switch v := x.(type) {
case *bytes.Buffer:   // pointer compare against *bytes.Buffer's *_type
    _ = v.Len()
case string:          // pointer compare against string's *_type
    _ = len(v)
case error:           // assertion against an interface — itab lookup
    _ = v.Error()
}

Interface conversions and boxing

Assigning a concrete value to an interface variable is called boxing. If the value fits in a single word and is itself pointer-shaped — actual pointers, channels, maps, function values — the interface's data field can hold it directly, no allocation needed. Anything else needs heap storage.

The runtime has a small family of helpers — runtime.convT, runtime.convT16, convT32, convT64, convTstring, convTslice — that allocate the right amount, copy the value in, and return a pointer for the interface to point at. Each call shows up in pprof as a malloc.

var x interface{} = 42  // calls runtime.convT64, allocates 8 bytes
var y interface{} = "hi" // calls runtime.convTstring, allocates a string header

// A pointer fits in the data word — no allocation:
buf := new(bytes.Buffer)
var z interface{} = buf  // tab + data=buf, no malloc
This is why fmt allocates. Every argument to fmt.Printf is converted to interface{} at the call site. Pass an int and you've allocated 8 bytes you didn't expect. In hot paths, strconv directly into a pre-allocated buffer beats fmt by more than the formatting cost.

iface vs eface

The two interface shapes exist because the empty interface doesn't need a method table. iface carries the full itab — interface type, concrete type, and a method table to dispatch through. eface drops the itab and carries only the concrete type pointer, since there's nothing to dispatch.

interface{} and any (added as an alias in Go 1.18) are the same type, both eface-shaped. Conversion between a typed interface and interface{} changes the first word: a typed interface holds an *itab, an empty interface holds an *_type, and the runtime extracts one from the other as needed.

Generics changed the calculus

Before Go 1.18, polymorphic code meant interfaces. Container types like sorted sets, pools, and caches paid the boxing cost on every operation. With type parameters, the compiler generates code specialised to a set of GC shapes (see internals / generics) — pointer-shaped arguments share one instantiation, but the data flows through as the concrete type, not as interface{}.

For many use cases — generic containers, generic helpers like slices.Map and maps.Keys — generics replace what would have been interface-based code with concrete, monomorphised versions. No boxing, no dispatch, often inlined. Interfaces remain the right tool when you actually need dynamic dispatch over an open set of types; generics handle the cases where you wanted parametric polymorphism but were settling for runtime dispatch.

Common pitfalls

  • Accidental boxing in hot paths. Passing primitives through interface{} — to fmt, to a logger that takes ...any, to a generic-looking helper that's actually interface{}-based — allocates per call. Allocations add up.
  • Nil interface that isn't nil. An interface value holding (*T)(nil) has a non-nil itab and a nil data pointer; comparing it to nil returns false. Returning err as error when err is a typed nil is the canonical source of surprised callers.
  • Type assertions on a nil interface. x.(T) on a nil interface panics. Use the comma-ok form — v, ok := x.(T) — wherever the input might be nil.
  • interface{} where generics now apply. Container or helper code written pre-1.18 with interface{} can usually be ported to type parameters now, dropping allocations and gaining static type safety.
  • Empty interface as an untyped container. Functions that take map[string]interface{} or []interface{} to model "arbitrary data" push the cost of type-checking everywhere they're used. Define a real type if you can.

Production checklist

  • Check allocations on interface-heavy code with go build -gcflags=-m and benchmarks that report -benchmem. Unexpected mallocs are usually boxing.
  • Reach for generics before interface{} when the polymorphism is parametric. Reserve interfaces for genuine dynamic dispatch over an open set of types.
  • Prefer concrete types for performance-critical paths. The dispatch cost is small per call but adds up when the call site sees millions of iterations.
  • When a method exists on an interface "for testability", say so in the comment. It avoids the reader assuming polymorphism was the design intent.
  • Use the comma-ok form for type assertions in any code that might see a nil interface or a different concrete type than expected. The panic-on-mismatch form belongs in code that's already verified the type.

Further reading

Found this useful?