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 itab and 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:

go
// 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:

go
// 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:

go
// 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:

text
   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:

go
// 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 << 5

So 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:

go
// 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:

go
// 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.Name

and the runtime’s boxing helpers are small and specialised – one per shape of value:

go
// 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:

go
// 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:

  1. Concrete type to empty interface. The first word is a static pointer to the concrete type’s descriptor. No run-time work at all.
  2. 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.
  3. 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:

go
// 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:

go
// 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 = typ

The 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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 * 2

Note 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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 the convT* boxing helpers.
  • src/runtime/runtime2.go – the iface and eface structures.
  • src/internal/abi/iface.go – ITab, EmptyInterface, NonEmptyInterface, and CommonInterface.
  • src/internal/abi/type.go – the runtime Type struct and TFlagDirectIface.
  • 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 – walkConvInterface and dataWord, the rules for building the data word.
  • src/cmd/compile/internal/ssagen/ssa.go – the inlined type assertion.
  • src/runtime/error.go – errorString and the RuntimeError marker.
  • src/errors/wrap.go – Is and As, and how they handle the nil interface.

GoInternalsInterfacesRuntimePerformance