Interface Internals

A Go interface value is, at the machine level, two machine words. For an empty interface (any / interface{}) the runtime type is eface — a *_type (a pointer to type metadata) and a data pointer. For a non-empty interface (one with methods) the type is iface — a *itab (a pointer to an interface table) and a data pointer (runtime iface/eface; internal/abi). The first word answers “what concrete type is in here, and how do I call its methods”; the second word holds (or points to) the concrete value. This two-word design is why an interface can hold any type, why method calls through an interface cost one extra indirection, and — crucially — why a nil-typed interface and a typed-but-nil-pointer interface are not equal (Russ Cox, Go Data Structures: Interfaces).

This is the canonical internals note for the two-word representation, the itab, and method dispatch. The user-facing semantics — defining interfaces, satisfaction, type assertions — live in Interfaces in Go and Type Assertions and Type Switches; the famous nil trap has its own gotcha note, Nil Interface vs Nil Pointer.

Mental Model

flowchart TD
  subgraph EFACE["eface — empty interface (any)"]
    ET["_type: *_type →"]
    ED["data: unsafe.Pointer →"]
  end
  subgraph IFACE["iface — non-empty interface"]
    IT["tab: *itab →"]
    ID["data: unsafe.Pointer →"]
  end
  ET --> TY1["type metadata\n(name, size, hash, kind, equal fn...)"]
  IT --> ITAB
  subgraph ITAB["itab (interface table)"]
    II["Inter: *interfacetype"]
    ITY["Type: *_type  → concrete type"]
    IH["Hash: uint32"]
    IF["Fun[...]: method code pointers"]
  end
  ITY --> TY1
  ED --> V1["the concrete value\n(boxed on heap if it doesn't fit a word)"]
  ID --> V1

Diagram: the two interface representations. The insight: both are two words wide, but the first word differseface points straight at type metadata (no methods to dispatch), while iface points at an itab that combines the concrete type with a precomputed array of method code pointers. Calling a method through an iface is: load tab, load Fun[k], call — one indirection more than a direct call.

Mechanical Walk-through

The two structs

From runtime/runtime2.go:

type iface struct {        // non-empty interface (has methods)
    tab  *itab
    data unsafe.Pointer
}
 
type eface struct {        // empty interface: interface{} / any
    _type *_type
    data  unsafe.Pointer
}

internal/abi exposes the same shapes as NonEmptyInterface{ITab, Data} and EmptyInterface{Type, Data}. The compiler picks eface vs iface purely on whether the interface type declares methods. On a 64-bit platform an interface value is 16 bytes regardless.

The itab

The interface table (internal/abi/iface.go):

type ITab struct {
    Inter *InterfaceType // the interface type this itab is for
    Type  *Type          // the concrete (dynamic) type stored
    Hash  uint32         // copy of Type.Hash — used by type switches
    Fun   [1]uintptr     // method table; variable-length. Fun[0]==0 ⇒ Type does NOT implement Inter
}

An itab is a (interface type, concrete type) pair plus the resolution of that pairing. Fun is a variable-length array of code pointers — one per method in the interface, in the interface’s method order. So calling iface.Method2() is: load iface.tab, load tab.Fun[1], indirect-call it with iface.data as the receiver. The itab is computed once per (interface, concrete type) pair and cached, so the per-call cost is just the two loads, not a method search.

itabs are allocated in non-garbage-collected memory (persistentalloc) — they live for the program’s lifetime, like type metadata. They are interned in a global hash table, itabTable, keyed on hash(interfaceType, concreteType).

How an itab is built

Two paths. Statically: when the compiler sees a conversion from a known concrete type to a known interface type, it emits the itab into the binary and the linker fills Fun by matching method names — these are registered into itabTable at startup by itabsinit. Dynamically: when the pair is only known at runtime (e.g. a value of dynamic type flowing into an interface), the runtime calls getitab(inter, typ, canfail) (runtime/iface.go). getitab first does a lock-free lookup in itabTable; on a miss it takes itabLock, re-checks, then builds a new itab with itabInit, which walks the concrete type’s method set and the interface’s method list and, for each interface method, finds the matching concrete method by name and signature, storing its code pointer into Fun. If any interface method is unmatched, Fun[0] is set to 0. A non-failing getitab then panics with a TypeAssertionError; a failing one (canfail==true, used by the comma-ok assertion) returns the incomplete itab so the caller can report ok == false.

Putting a value into the data word

The data word holds the concrete value — but a word is only 8 bytes. The rule:

  • If the concrete value is a pointer, data is that pointer.
  • If it is a non-pointer that does not fit in a word (a struct, a large value), the runtime heap-allocates a copy (this is “boxing”) and data points at the box. This is an escape — see Escape Analysis.
  • Several fast paths avoid the allocation by pointing data at shared static storage. The convT64/convT32/convT16 conversion helpers in runtime/iface.go special-case small unsigned integers: if val < 256 (val < uint64(len(staticuint64s))) they set data to &staticuint64s[val] — an interned array of the integers 0–255 — so var x any = 7 allocates nothing (runtime/iface.go). Likewise convTstring and convTslice point an empty string or empty slice at &zeroVal[0] rather than allocating.

This is the present-day rule (verified against Go 1.26-era source): for a non-pointer value, data is always a pointer to storage — never the value stored inline. Historically Go stored word-sized non-pointer values directly in data, but since ~1.4 — when the garbage collector required every interface data word to be unambiguously a pointer so it could trace the heap precisely — the runtime always indirects. So var x any = 42 materialises a data pointer (here into staticuint64s, since 42 < 256); a larger or non-constant integer such as var x any = 1_000_000 heap-allocates a box. This is why interfaces can trigger heap allocations you did not expect.

Method dispatch and devirtualization

A call r.Read(buf) where r is an io.Reader interface is dynamic dispatch: tab.Fun[k] is loaded and called. The compiler cannot inline it in general. But when the compiler can prove the concrete type — through escape/flow analysis, or with Profile-Guided Optimization data — it performs devirtualization: it replaces the indirect call with a direct (and possibly inlined) call to the concrete method. See Devirtualization.

nil interfaces — the load-bearing subtlety

An interface value is nil iff both words are zero — i.e. its type word is nil. A freshly declared var err error is nil because tab/_type is nil and data is nil.

Now the trap. Take a typed nil pointer and assign it to an interface:

var p *MyError = nil   // a nil POINTER, but it has a type
var err error = p      // err.tab = itab(error, *MyError);  err.data = nil
fmt.Println(err == nil) // false! — the TYPE word is non-nil

The interface’s first word now holds the itab for (error, *MyError)non-nil — even though the second word (data) is nil. So err != nil. The interface is “a nil *MyError”, which is not the same as “no value at all”. This is the single most-reported Go gotcha; it has its own note, Nil Interface vs Nil Pointer. The fix is to never assign a typed nil pointer into an interface return — return a literal nil instead.

Code: Dispatch and the nil Trap

package main
 
import "fmt"
 
type Stringer interface{ String() string }
 
type Point struct{ X, Y int }
func (p Point) String() string { return fmt.Sprintf("(%d,%d)", p.X, p.Y) }
 
func main() {
    var s Stringer = Point{1, 2}   // 1: iface — tab = itab(Stringer, Point), data → boxed Point
    fmt.Println(s.String())        // 2: dynamic dispatch via tab.Fun[0]
 
    var a any = 42                 // 3: eface — _type = *_type(int); 42 < 256 so data → &staticuint64s[42] (no alloc)
    n, ok := a.(int)               // 4: type assertion — compares a._type to int's *_type
    fmt.Println(n, ok)             // 5: 42 true
 
    var e error                    // 6: nil interface — both words zero
    fmt.Println(e == nil)          // 7: true
 
    var p *Point                   // 8: typed nil pointer
    var s2 Stringer = p            // 9: tab non-nil, data nil
    fmt.Println(s2 == nil)         // 10: FALSE — the famous trap
}

Line 1 builds an iface: the Point value is boxed on the heap (it is not a pointer), data points at the box, tab is the (Stringer, Point) itab. Line 2 dispatches through tab.Fun[0]. Line 3 builds an eface; because 42 < 256 the runtime points data at the interned staticuint64s[42] rather than allocating — a larger or non-constant int would instead box on the heap, and either way the GC sees a clean pointer. Line 4’s assertion is a pointer comparison of a._type against int’s type descriptor — cheap. Lines 8–10 are the trap: s2 holds a non-nil itab and a nil data, so s2 == nil is false.

Failure Modes and Common Misunderstandings

The typed-nil trap (lines 8–10 above). The number-one cause is a function returning a concrete *T error that is nil, assigned into an error return. See Nil Interface vs Nil Pointer.

“Interfaces are free.” They are not. Storing a non-pointer value in an interface boxes it (a heap allocation); dynamic dispatch defeats inlining. Hot loops that funnel everything through any allocate and run slower than concrete code — visible in pprof heap profiles.

Interface equality can panic. Comparing two interface values compares dynamic type then dynamic value. If both hold the same uncomparable dynamic type (a slice, map, or func), the comparison panics at runtime with “comparing uncomparable type”. See Comparing Structs and Interfaces.

Method set surprises with pointer receivers. If String() has a pointer receiver (p *Point), then Point does not satisfy Stringer — only *Point does, because the value method set excludes pointer-receiver methods. The itab build (itabInit) will leave Fun[0] == 0 and the conversion fails to compile (static) or panics (dynamic). See Method Sets.

itabs leak by design. They are persistentalloc’d and never freed. A program that dynamically creates astronomically many (interface, type) pairs (rare — typically only via heavy reflection/codegen) grows itabTable unboundedly.

Alternatives and When to Choose Them

Use interfaces for genuine polymorphism — decoupling a caller from concrete implementations (io.Reader, error). Since Go 1.18, generics (Generics and Type Parameters) are the better tool when you want one algorithm over many types without boxing or dynamic dispatch — a generic function is stenciled/dictionary-compiled and keeps values concrete. Use a concrete type directly in hot paths where you do not need polymorphism. Use any sparingly: it is maximally flexible but always boxes and always needs a type assertion or reflection to get the value back (Go blog, The Laws of Reflection).

Production Notes

The two-word interface shows up everywhere in profiles: a surprising heap allocation is often a value being boxed into an any, and a hot dynamic call that “won’t inline” is interface dispatch. Devirtualization with Profile-Guided Optimization is the compiler’s mitigation. The reflection package is built directly on eface/ifacereflect.TypeOf/ValueOf essentially unpack the two words (Go blog, The Laws of Reflection). Russ Cox’s Go Data Structures: Interfaces remains the authoritative deep-dive on the itab and predates only minor changes (the move to always-pointer data) (research.swtch.com).

See Also