Interface internals: itabs and nil

Go · Expert · 14 min

These internals explain interface allocations, comparison panics and the famous non-nil nil error.

An interface value is two machine words:

  • An empty interface (any, eface in the runtime) holds a pointer to the dynamic type descriptor and a pointer to the data.
  • A non-empty interface (iface) holds a pointer to an itab and the data pointer. The itab records the interface type, the concrete type, and a table of function pointers for the interface's methods, in a fixed order. Itabs are computed once and cached.

A method call through an interface loads the function pointer from the itab and makes an indirect call. It's cheap but can't be inlined, which is the real cost — the compiler can sometimes devirtualise calls when it can see the concrete type, and PGO does this for hot call sites.

The data word must be a pointer. Storing a pointer-shaped value (a pointer, map, channel or func) in an interface is free; storing anything else — an int, a struct, a string — normally boxes it: the value is copied to the heap. The runtime avoids allocation for zero-sized values, single-byte values and constants, but in hot code repeated boxing shows up in profiles.

Consequences:

  • An interface equals nil only when its type word is nil. A var p *MyErr = nil returned as error has a type word, so err != nil is true. Return a literal nil.
  • Comparing interfaces compares the types, then the values with the type's ==. If the dynamic type is not comparable (a slice, map or func), == panics at run time, including when used as a map key.
  • Type assertion to a concrete type is a single pointer comparison; assertion to another interface needs an itab lookup.

Example

package main

import (
	"fmt"
	"testing"
)

type MyErr struct{}

func (*MyErr) Error() string { return "boom" }

func mayFail(fail bool) error {
	var p *MyErr // typed nil pointer
	if fail {
		p = &MyErr{}
	}
	return p // WRONG: interface holds (*MyErr, nil)
}

var sink any

func main() {
	fmt.Println(mayFail(false) != nil) // true — the typed-nil trap

	n := 1000
	allocs := testing.AllocsPerRun(100, func() { sink = n }) // int is boxed
	fmt.Println("allocs per boxing:", allocs)                // 1

	defer func() { fmt.Println("recovered:", recover()) }()
	var a, b any = []int{1}, []int{1}
	fmt.Println(a == b) // panics: comparing uncomparable type []int
}

Remember

  • Interfaces are (itab or type, data pointer) pairs
  • Non-pointer values are usually boxed on the heap when stored in an interface
  • nil interface ≠ interface holding a nil pointer; uncomparable dynamic types panic on ==

Watch out for

  • Declaring var err *MyErr and returning it as error — callers always see a non-nil error.

Check yourself

  1. `var a, b any = map[string]int{}, map[string]int{}` — what does `a == b` do?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: go.dev/doc/faq#nil_error