Go's Interfaces Are Two Words and a Cached itab
A Go interface value is two words: a pointer to a type descriptor for the empty interface, a pointer to an itab for the rest, and the itab is either a static symbol or a cached runtime lookup.
TL;DR: A non-empty interface value is two words: a pointer to an
itaband a pointer to the data. The empty interface swaps the itab for a plain type descriptor. The itab itself is a small table of method pointers, and for a concrete type the compiler emits it as a static symbol, so the conversion costs nothing at run time. When the type is not known statically – an interface-to-interface conversion, a type switch on a value of unknown type – the runtime builds the itab and caches it in a global hash table.
Go’s interfaces are famous for being two words. That part is easy. The part that catches people out is where the first word points, and what happens when the compiler cannot know the concrete type.
This is a walk through the layout and the lookup paths, based on reading the
Go 1.28 development tree: src/runtime/iface.go, src/internal/abi/iface.go,
and the compiler’s walk/convert.go and reflectdata/reflect.go.
The two layouts
The runtime keeps two interface representations. The first word of an empty interface is a type descriptor; the first word of a non-empty interface is an itab:
// src/runtime/runtime2.go:184
type iface struct {
tab *itab
data unsafe.Pointer
}
type eface struct {
_type *_type
data unsafe.Pointer
}The internal/abi package gives the same thing a name and documents the
difference:
// src/internal/abi/iface.go:12
// The first word of every non-empty interface type contains an *ITab.
// It records the underlying concrete type (Type), the interface type it
// is implementing (Inter), and some ancillary information.
//
// allocated in non-garbage-collected memory
type ITab struct {
Inter *InterfaceType
Type *Type
Hash uint32 // copy of Type.Hash. Used for type switches.
Fun [1]uintptr // variable sized. fun[0]==0 means Type does not implement Inter.
}and:
// src/internal/abi/iface.go:33
// EmptyInterface describes the layout of a "interface{}" or a "any."
// These are represented differently than non-empty interface, as the first
// word always points to an abi.Type.
type EmptyInterface struct {
Type *Type
Data unsafe.Pointer
}
// NonEmptyInterface describes the layout of an interface that contains any methods.
type NonEmptyInterface struct {
ITab *ITab
Data unsafe.Pointer
}
// CommonInterface describes the layout of both [EmptyInterface] and [NonEmptyInterface].
type CommonInterface struct {
// Either an *ITab or a *Type, unexported to avoid accidental use.
_ unsafe.Pointer
Data unsafe.Pointer
}CommonInterface is the interesting one: it is the common part of the two
layouts, and it is what a generic function or an unsafe cast sees. The
first word is deliberately untyped, because at that level you cannot know
which of the two you have.
So, in memory:
var x error = myError{} // non-empty interface
+-----------------------+
| *itab ---------------> { Inter, Type, Hash, Fun[0], Fun[1], ... }
+-----------------------+
| data ---------------> the myError value (or a pointer to it)
+-----------------------+
var y any = 42 // empty interface
+-----------------------+
| *_type ---------------> the runtime type descriptor for int
+-----------------------+
| data ---------------> pointer to a copy of 42
+-----------------------+The data word: direct or boxed
The data word is a pointer, but not always to a separate object. If the concrete type is pointer-shaped, the value is the pointer and is stored directly. The runtime caches that decision in a flag on the type descriptor:
// src/internal/abi/type.go:124
// TFlagDirectIface means that a value of this type is stored directly
// in the data field of an interface, instead of indirectly.
// This flag is just a cached computation of Size_ == PtrBytes == goarch.PtrSize.
TFlagDirectIface TFlag = 1 << 5So boxing a *T, a map, a channel, or a func into an interface costs
nothing: no allocation, no copy. Boxing an int costs an allocation, unless
the compiler can find a cheaper home for it.
The compiler’s dataWord function is where all of that is decided, and it
is worth reading because the list of special cases is long:
// src/cmd/compile/internal/walk/convert.go:153
// Try a bunch of cases to avoid an allocation.
var value ir.Node
byteVal, isConst := smallIntConst(n)
switch {
case fromType.Size() == 0:
// n is zero-sized. Use zerobase.
diagnose("using global for zero-sized interface value", n)
w.cheapExpr(n, init) // Evaluate n for side-effects. See issue 19246.
value = ir.NewLinksymExpr(base.Pos, ir.Syms.Zerobase, types.Types[types.TUINTPTR])
case isBool || isInteger && (fromType.Size() == 1 || isConst):
// n is a bool, a single-byte integer, or a compile-time constant in [0, 255].
// Use staticuint64s[n * 8] on little-endian and staticuint64s[n * 8 + 7] on big-endian.
diagnose("using global for small integer/boolean interface value", n)The staticuint64s global is a 2 KiB table of pre-boxed small values. An
int constant between 0 and 255 is boxed by pointing into that table, with
no allocation at all.
If none of the special cases apply, the compiler calls into the runtime to box the value:
// src/cmd/compile/internal/walk/convert.go:207
// Time to do an allocation. We'll call into the runtime for that.
fnname, argType, needsaddr := dataWordFuncName(fromType)
var fn *ir.Nameand the runtime’s boxing helpers are small and specialised – one per shape of value:
// src/runtime/iface.go:409
// convT64 should be an internal detail,
// but widely used packages access it using linkname.
// Notable members of the hall of shame include:
// - github.com/bytedance/sonic
//
// Do not remove or change the type signature.
// See go.dev/issue/67401.
//
//go:linkname convT64
func convT64(val uint64) (x unsafe.Pointer) {
if val < uint64(len(staticuint64s)) {
x = unsafe.Pointer(&staticuint64s[val])
} else {
x = mallocgc(8, uint64Type, false)
*(*uint64)(x) = val
}
return
}So convT64 has its own small-value cache, convTstring has a zero-value
special case, convTslice avoids allocating a nil slice. These are the
“conversion” routines that older Go versions called convT2E. In this tree
they have been renamed and specialised, but the shape of the idea is the
same: the data word is a pointer to a copy, and the runtime tries hard to
avoid the copy.
Where the first word comes from
Now the interesting part. The compiler decides, for each conversion, what the first word should be:
// src/cmd/compile/internal/reflectdata/helpers.go:95
// ConvIfaceTypeWord asserts that n is conversion to interface type,
// and returns an expression that yields the *runtime._type or
// *runtime.itab value necessary for implementing the conversion.
//
// - *runtime._type for the destination type, for I2I conversions
// - *runtime.itab, for T2I conversions
// - *runtime._type for the source type, for T2E conversions
func ConvIfaceTypeWord(curfunc *ir.Func, pos src.XPos, n *ir.ConvExpr) ir.Node {
assertOp(n, ir.OCONVIFACE)
src, dst := n.X.Type(), n.Type()
base.AssertfAt(dst.IsInterface(), n.Pos(), "want interface type, have %L", n)
if hasRType(n, n.TypeWord, "TypeWord") {
return n.TypeWord
}
if dst.IsEmptyInterface() {
return concreteRType(curfunc, pos, src) // direct eface construction
}
if !src.IsInterface() {
return ITabAddrAt(curfunc, pos, src, dst) // direct iface construction
}
return TypePtrAt(curfunc, pos, dst) // convI2I
}Three cases:
- Concrete type to empty interface. The first word is a static pointer to the concrete type’s descriptor. No run-time work at all.
- Concrete type to non-empty interface. The first word is a static pointer to an itab. No run-time work at all – but the itab has to exist in the binary first.
- Interface to interface. The first word is the destination interface’s descriptor, and the conversion happens at run time, because the compiler does not know whether the source’s dynamic type implements the destination.
That third case is the one people remember as “every interface conversion costs a runtime call”. It is still true. But the first two cases, which is most of them, do not.
The static itab
The compiler emits the itab for a statically-known pair as an ordinary read-only global, and the runtime never sees it:
// src/cmd/compile/internal/reflectdata/reflect.go:1077
// dump empty itab symbol into i.sym
// type itab struct {
// inter *interfacetype
// _type *_type
// hash uint32 // copy of _type.hash. Used for type switches.
// _ [4]byte
// fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
// }
c := rttype.NewCursor(lsym, 0, rttype.ITab)
c.Field("Inter").WritePtr(writeType(iface))
c.Field("Type").WritePtr(writeType(typ))
c.Field("Hash").WriteUint32(types.TypeHash(typ)) // copy of type hash
var delta int64
c = c.Field("Fun")
if !completeItab {
// If typ doesn't implement iface, make method entries be zero.
c.Elem(0).WriteUintptr(0)
} else {
var a rttype.ArrayCursor
a, delta = c.ModifyArray(len(entries))
for i, fn := range entries {
a.Elem(i).WritePtrWeak(fn) // method pointer for each method
}
}
// Nothing writes static itabs, so they are read only.
objw.Global(lsym, int32(rttype.ITab.Size()+delta), int16(obj.DUPOK|obj.RODATA))That comment block is the canonical description of the itab layout, and it
matches the ITab struct in internal/abi exactly: Inter, Type, Hash,
then a variable-length array of function pointers, one per interface method,
in the interface’s sorted method order.
The itab is written into the read-only data section, so it is shared across the whole binary and costs one pointer load at run time.
The dynamic itab: getitab
When the compiler does not know the concrete type – an interface-to-interface
conversion, or a type switch on a value whose type is not known – the
runtime has to find or build the itab. That is getitab:
// src/runtime/iface.go:44
func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
if len(inter.Methods) == 0 {
throw("internal error - misuse of itab")
}
// easy case
if typ.TFlag&abi.TFlagUncommon == 0 {
if canfail {
return nil
}
name := toRType(&inter.Type).nameOff(inter.Methods[0].Name)
panic(&TypeAssertionError{nil, typ, &inter.Type, name.Name()})
}
var m *itab
// First, look in the existing table to see if we can find the itab we need.
// This is by far the most common case, so do it without locks.
// Use atomic to ensure we see any previous writes done by the thread
// that updates the itabTable field (with atomic.Storep in itabAdd).
t := (*itabTableType)(atomic.Loadp(unsafe.Pointer(&itabTable)))
if m = t.find(inter, typ); m != nil {
goto finish
}
// Not found. Grab the lock and try again.
lock(&itabLock)
if m = itabTable.find(inter, typ); m != nil {
unlock(&itabLock)
goto finish
}
// Entry doesn't exist yet. Make a new entry & add it.
m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.Methods)-1)*goarch.PtrSize, 0, &memstats.other_sys))
m.Inter = inter
m.Type = typThe structure is the classic “read without a lock, take the lock only to mutate” pattern. The fast path is a lock-free hash-table probe.
The table itself is a single global, swapped out when it grows:
// src/runtime/iface.go:15
const itabInitSize = 512
var (
itabLock mutex // lock for accessing itab table
itabTable = &itabTableInit // pointer to current table
itabTableInit = itabTableType{size: itabInitSize} // starter table
)
// Note: change the formula in the mallocgc call in itabAdd if you change these fields.
type itabTableType struct {
size uintptr // length of entries array. Always a power of 2.
count uintptr // current number of filled entries.
entries [itabInitSize]*itab // really [size] large
}The hash is the xor of the two type hashes:
// src/runtime/iface.go:30
func itabHashFunc(inter *interfacetype, typ *_type) uintptr {
// compiler has provided some good hash codes for us.
return uintptr(inter.Type.Hash ^ typ.Hash)
}and the probe sequence is quadratic:
// src/runtime/iface.go:105
// find finds the given interface/type pair in t.
// Returns nil if the given interface/type pair isn't present.
func (t *itabTableType) find(inter *interfacetype, typ *_type) *itab {
// Implemented using quadratic probing.
// Probe sequence is h(i) = h0 + i*(i+1)/2 mod 2^k.
// We're guaranteed to hit all table entries using this probe sequence.
mask := t.size - 1
h := itabHashFunc(inter, typ) & mask
for i := uintptr(1); ; i++ {
p := (**itab)(add(unsafe.Pointer(&t.entries), h*goarch.PtrSize))
// Use atomic read here so if we see m != nil, we also see
// the initializations of the fields of m.
// m := *p
m := (*itab)(atomic.Loadp(unsafe.Pointer(p)))
if m == nil {
return nil
}
if m.Inter == inter && m.Type == typ {
return m
}
h += i
h &= mask
}
}Growth happens under the lock, at a 75% load factor, and the whole table is replaced rather than rehashed in place:
// src/runtime/iface.go:130
// itabAdd adds the given itab to the itab hash table.
// itabLock must be held.
func itabAdd(m *itab) {
// Bugs can lead to calling this while mallocing is set,
// typically because this is called while panicking.
// Crash reliably, rather than only when we need to grow
// the hash table.
if getg().m.mallocing != 0 {
throw("malloc deadlock")
}
t := itabTable
if t.count >= 3*(t.size/4) { // 75% load factor
// Grow hash table.
// t2 = new(itabTableType) + some additional entries
// We lie and tell malloc we want pointer-free memory because
// all the pointed-to values are not in the heap.
t2 := (*itabTableType)(mallocgc((2+2*t.size)*goarch.PtrSize, nil, true))
t2.size = t.size * 2Note the persistentalloc in getitab: itabs live outside the garbage
collected heap, because a program can create them at any time and they must
never be collected.
Filling in the itab
Building an itab means matching the interface’s methods against the concrete type’s methods, in name order:
// src/runtime/iface.go:209
func itabInit(m *itab, firstTime bool) string {
inter := m.Inter
typ := m.Type
x := typ.Uncommon()
// both inter and typ have method sorted by name,
// and interface names are unique,
// so can iterate over both in lock step;
// the loop is O(ni+nt) not O(ni*nt).
ni := len(inter.Methods)
nt := int(x.Mcount)The O(ni+nt) not O(ni*nt) comment is a nice piece of runtime engineering:
both sides are sorted by method name, so a single merge pass suffices.
Type assertions
A type assertion to a concrete type does not call the runtime either. The compiler inlines it as a pointer comparison:
// src/cmd/compile/internal/ssagen/ssa.go:6585
// Converting to a concrete type.
direct := types.IsDirectIface(dst)
itab := s.newValue1(ssaop.OpITab, byteptr, iface) // type word of interface
if base.Debug.TypeAssert > 0 {
base.WarnfAt(pos, "type assertion inlined")
}
var wantedFirstWord *ssa.Value
if src.IsEmptyInterface() {
// Looking for pointer to target type.
wantedFirstWord = target
} else {
// Looking for pointer to itab for target type and source interface.
wantedFirstWord = targetItab
}For an empty interface, the comparison is against the type descriptor; for a non-empty one, against the itab. Either way it is a single pointer compare, and on failure the runtime is only consulted to produce the panic.
An assertion to another interface does need the runtime, because the runtime has to find the itab for the dynamic type and the destination interface:
// src/runtime/iface.go:482
// typeAssert builds an itab for the concrete type t and the
// interface type s.Inter. If the conversion is not possible it
// panics if s.CanFail is false and returns nil if s.CanFail is true.
func typeAssert(s *abi.TypeAssert, t *_type) *itab {
var tab *itab
if t == nil {
if !s.CanFail {
panic(&TypeAssertionError{nil, nil, &s.Inter.Type, ""})
}
} else {
tab = getitab(s.Inter, t, s.CanFail)
}and because that is a hot path, the runtime caches the answers in a small per-site hash table:
// src/runtime/iface.go:495
if !abi.UseInterfaceSwitchCache(goarch.ArchFamily) {
return tab
}
// Maybe update the cache, so the next time the generated code
// doesn't need to call into the runtime.
if cheaprand()&1023 != 0 {
// Only bother updating the cache ~1 in 1000 times.
return tab
}One call in a thousand rebuilds the cache; the rest of the time the
generated code hits the cache directly. Type switches work the same way, via
interfaceSwitch, which walks the cases calling getitab for each.
The special case of error
error is not special to the runtime. It is an ordinary interface with one
method:
// src/builtin/builtin.go:315
// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
Error() string
}What is special is that the compiler and the runtime both have a canonical
descriptor for it, so that every error in the program shares one itab
layout and one type descriptor. The runtime’s own errors use a tiny
predeclared type:
// src/runtime/error.go:86
// An errorString represents a runtime error described by a single string.
type errorString string
func (e errorString) RuntimeError() {}
func (e errorString) Error() string {
return "runtime error: " + string(e)
}The RuntimeError() method is a marker: runtime.Error is an interface with
that method, and it is how code distinguishes a runtime error from an
ordinary one.
The famous footgun with error is not about the interface at all. It is
about the data word. Take any concrete error type with a pointer receiver,
such as the one errors.New hands back:
// src/errors/errors.go:68
// errorString is a trivial implementation of error.
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}Storing a nil *errorString in an error gives you an interface whose
itab word is non-nil (it points at the *errorString itab) and whose data
word is a nil *errorString. The interface as a whole is non-nil, and
err != nil is true. Only the data word is nil.
That is the direct-iface rule in action: a pointer-shaped concrete type is stored directly in the data word, so a nil pointer becomes a nil data word inside a non-nil interface.
errors.Is and errors.As navigate this world for you, and they are careful
about it:
// src/errors/wrap.go:45
func Is(err, target error) bool {
if err == nil || target == nil {
return err == target
}
isComparable := reflectlite.TypeOf(target).Comparable()
return is(err, target, isComparable)
}errors.As refuses to assign to a target that is not a pointer to an error
type, and it walks the Unwrap chain, so it is the tool to reach for rather
than a hand-written type switch.
Trade-offs
What the two-word layout buys: an interface value is the same size as a pointer, it can be passed in a register, and assigning one is a two-word copy with no allocation.
What it costs: the first word is a pointer to shared, read-only data that has to be found somehow. For statically-known pairs the compiler finds it for free. For dynamic ones the runtime has to look it up, and that lookup is a hash-table probe.
The data word’s polymorphism costs too. Boxing a non-pointer value means an
allocation, and the compiler works hard to avoid it – the staticuint64s
table, the zerobase global, the “stack temporary if it does not escape”
case. Each of those is a special case in dataWord.
The itab table is a global mutable structure, which means it needs a lock and careful publication. The runtime solves that with an atomic pointer to the table, so readers never take the lock; only the rare insert does.
Caching is the other lever. The runtime keeps a per-site cache for type
assertions and type switches, updated only about once in a thousand misses,
so the steady-state cost of an interface-to-interface assertion is a hash
lookup in a small table rather than a trip through getitab.
Caveats
Converting a concrete type to an interface is not free in general. It is free if the value is pointer-shaped, or a small integer constant, or zero-sized. Otherwise it allocates.
Converting an interface to another interface is a runtime call. The compiler cannot know whether the dynamic type implements the destination.
The itab lookup happens once per pair, then forever. The first interface-to-interface conversion of a given pair builds an itab; every later one finds it in the table.
getitab takes a global lock on a miss. Two goroutines converting the
same unknown type at the same time will contend. The lock is not held for
lookups that hit.
The itab table grows forever. Every distinct (interface, concrete type) pair that is converted dynamically at run time adds an entry. A program that generates types dynamically can grow this table without bound.
A nil pointer in an interface is not a nil interface. This is the single most common interface bug in Go, and it is a consequence of the direct-iface rule.
The data word of an interface holding a non-pointer points to a copy. If you mutate the original value after boxing it, the interface still sees the old copy.
Comparing two interface values compares the type words first. Two interfaces holding equal values of different dynamic types are not equal, and the comparison does not panic.
reflect on an interface value gives you the dynamic type. That is the
type descriptor in the first word, and it is why fmt.Printf("%T", x) works
without any cooperation from the value.
Wrapping up
The one thing to remember: an interface value is two words, and the first word is a pointer to a table that says what the concrete type is and where its methods live. For a statically-known concrete type, that table is a read-only symbol in the binary; for a dynamic one, it is looked up in a global hash table and cached forever.
The part people get wrong: interfaces are not “free” in the sense of “never cost anything”, but the expensive case is not the one people think. Converting a concrete type to an interface is a static pointer load; converting an interface to another interface is the one that calls into the runtime.
What to go do about it: if a hot path is spending time in getitab or
runtime.typeAssert, look for interface-to-interface conversions and type
switches over values of unknown type. If a hot path is allocating on
interface conversion, look for non-pointer values being boxed, and consider
passing a pointer instead.
Further reading
src/runtime/iface.go–getitab,find,itabAdd,itabInit,typeAssert,buildTypeAssertCache,interfaceSwitch, and theconvT*boxing helpers.src/runtime/runtime2.go– theifaceandefacestructures.src/internal/abi/iface.go–ITab,EmptyInterface,NonEmptyInterface, andCommonInterface.src/internal/abi/type.go– the runtimeTypestruct andTFlagDirectIface.src/cmd/compile/internal/reflectdata/helpers.go–ConvIfaceTypeWord, the three conversion cases.src/cmd/compile/internal/reflectdata/reflect.go–ITabLsym,writeITab, and the itab layout comment.src/cmd/compile/internal/walk/convert.go–walkConvInterfaceanddataWord, the rules for building the data word.src/cmd/compile/internal/ssagen/ssa.go– the inlined type assertion.src/runtime/error.go–errorStringand theRuntimeErrormarker.src/errors/wrap.go–IsandAs, and how they handle the nil interface.