Skip to content

An interface is two words

Level: 301 · anyone who wants to know what an any costs and what == on two of them compares

One line: unsafe.Sizeof of an any and of an error is 16 on both CI machines: a type word — a type descriptor for any, an itab for an interface with methods — and a data word that points at a copy of the value, or is the value when the value is a pointer. So boxing allocates for most values, and not for a pointer, a zero-size or empty value, or an integer from 0 to 255.

The first program reads the two words of several interface values through unsafe and reports what they hold — whether a word is nil, what it points at, whether two values share one — never the numbers themselves, which change between runs. The second counts heap allocations per boxing with testing.AllocsPerRun.

Verified output of an_interface_is_two_words_go.go — regenerated by tools/run_examples.py, never hand-typed.

unsafe.Sizeof(any)     = 16
unsafe.Sizeof(error)   = 16
unsafe.Sizeof(uintptr) = 8 -- so an interface is two words
nil any:              type word nil? true  data word nil? true
any holding int 7:    type word nil? false  data word points at a 7? true  at the variable quantity? false
  after quantity = 8, the any still holds 7 -- the data word points at a copy
any holding *Order:   data word == the pointer itself? true
two anys holding ints: same type word? true  same data word? false
error and any holding the same *ValidationError: same data word? true  same type word? false (an itab for error, a type for any)
any(7) == any(7)          true
any(int64(7)) == any(7)   false
any([]int{1}) == any([]int{1}) panics: runtime error: comparing uncomparable type []int

Reading the output

16 bytes, two pointers. unsafe.Sizeof(uintptr(0)) is 8, so an interface is two words, and the runtime's own definition is two pointers: runtime2.go ↗ declares eface (the empty interface) as a *_type and a data pointer, and iface (an interface with methods) as an *itab and a data pointer. Russ Cox's Go Data Structures: Interfaces ↗ is the walk-through, and the itab is the piece that makes a method call on an error a table lookup, not a search.

A nil interface has both words nil. That is the whole of x == nil — and why A nil pointer in an interface is not nil: a typed nil has the type word set.

The data word points at a copy. For any holding 7, the data word points at a 7 that is not the variable quantity; after quantity = 8 the interface still holds 7. An interface owns an immutable copy, which is why a value in an interface is not addressable and why a pointer method cannot be called on it (A pointer receiver changes the method set).

A pointer is stored as itself. For any holding *Order, the data word equals the pointer. Nothing was copied and, as the second program shows, nothing was allocated.

One type word per type; the itab is not the type. Two any values holding ints share the type word. An error and an any holding the same *ValidationError share the data word but not the type word: the error holds an itab, which records the type and the method table for error; the any holds the type descriptor alone. Converting one interface to the other swaps that word.

== compares the type word, then the values. any(7) == any(7) is true; any(int64(7)) == any(7) is false because the type words differ. And when the dynamic type is not comparable, the check happens at run time: runtime error: comparing uncomparable type []int. The spec ↗ says two interface values are equal if they have identical dynamic types and equal dynamic values, and that comparing values whose dynamic type is not comparable causes a run-time panic. A map[any]T keyed with a slice panics at insertion for the same reason.

What boxing costs

Verified output of an_interface_is_two_words_boxing_go.go — regenerated by tools/run_examples.py, never hand-typed.

value boxed into any     allocations per boxing
int 7                    0
int 255                  0
int 256                  1
int 300                  1
int -1                   1
byte 255                 0
float64 0                0
float64 3.14             1
string ""                0
string "invoice"         1
Order{1, 2}              1
struct{}{}               0
*Order                   0
nil                      0
the constant 300         0

Most values: one allocation. int 300, float64 3.14, string "invoice" and the two-field Order each cost one heap allocation per boxing — the copy the data word will point at. The runtime's conversion functions are in iface.go ↗: convT, convT64, convTstring and their siblings.

Integers 0–255: none. int 7, int 255 and byte 255 allocated nothing; int 256 and int -1 allocated once. convT64 in iface.go checks whether the value is below the length of staticuint64s, a 256-entry table of the values 0 to 255 that lives in the runtime, and points the data word into that table instead. -1 as a uint64 is not small. float64 0 shares the trick, because its bit pattern is zero; 3.14's is not.

Empty and zero-size values: none. string "" and struct{}{} point at a shared zero location; there is nothing to copy. nil is two nil words.

Pointers: none. The *Order row is the one to remember: a pointer in an interface costs no allocation at the boxing, because the data word is the pointer.

The constant 300: none — a compile-time effect. sink = 300 allocated nothing where sink = v300 allocated once, because the compiler builds the boxed constant as static data. That is why every value in the table comes through a //go:noinline function: without that, the first version of this program measured 0 for int 300 and string "invoice" as well, since the compiler had folded the variables into constants. go build -gcflags=-m shows the other side of this — the vet page's -gcflags=-m entry prints quantity escapes to heap for the boxing and leaking param: order for the pointer. When the interface value does not escape the function, the copy can live on the stack and the count is 0 there too; Escape analysis decides stack or heap is that story, and AllocsPerRun counts allocations the tool.

an_interface_is_two_words_go.go and an_interface_is_two_words_boxing_go.go

an_interface_is_two_words_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// An interface value is two machine words: a type word and a data word. For
// the empty interface the type word points at the dynamic type's descriptor;
// for an interface with methods it points at an itab that carries the type
// and the method table. The data word is a pointer to the value -- or, for
// a value that is itself a pointer, the pointer itself. This program reads
// the two words through unsafe and reports what they hold, never the numbers.
//
//  go build an_interface_is_two_words_go.go && ./an_interface_is_two_words_go
package main

import (
    "fmt"
    "unsafe"
)

type Order struct{ ID, Qty int }

type ValidationError struct{ Field string }

func (e *ValidationError) Error() string { return "invalid " + e.Field }

// words is the layout of every interface value in the runtime: two pointers.
type words struct {
    typ  unsafe.Pointer
    data unsafe.Pointer
}

func wordsOf(p unsafe.Pointer) words { return *(*words)(p) }

func panicOf(f func()) (value any) {
    defer func() { value = recover() }()
    f()
    return "none"
}

func main() {
    var empty any
    var err error
    fmt.Println("unsafe.Sizeof(any)     =", unsafe.Sizeof(empty))
    fmt.Println("unsafe.Sizeof(error)   =", unsafe.Sizeof(err))
    fmt.Println("unsafe.Sizeof(uintptr) =", unsafe.Sizeof(uintptr(0)), "-- so an interface is two words")

    w := wordsOf(unsafe.Pointer(&empty))
    fmt.Println("nil any:              type word nil?", w.typ == nil, " data word nil?", w.data == nil)

    quantity := 7
    var boxed any = quantity
    w = wordsOf(unsafe.Pointer(&boxed))
    fmt.Println("any holding int 7:    type word nil?", w.typ == nil, " data word points at a 7?", *(*int)(w.data) == 7, " at the variable quantity?", w.data == unsafe.Pointer(&quantity))
    quantity = 8
    fmt.Println("  after quantity = 8, the any still holds", boxed, "-- the data word points at a copy")

    order := &Order{ID: 1, Qty: 2}
    var boxedPointer any = order
    w = wordsOf(unsafe.Pointer(&boxedPointer))
    fmt.Println("any holding *Order:   data word == the pointer itself?", w.data == unsafe.Pointer(order))

    var a, b any = 1, 2
    wa, wb := wordsOf(unsafe.Pointer(&a)), wordsOf(unsafe.Pointer(&b))
    fmt.Println("two anys holding ints: same type word?", wa.typ == wb.typ, " same data word?", wa.data == wb.data)

    verr := &ValidationError{Field: "amount"}
    err = verr
    var asAny any = verr
    we, wn := wordsOf(unsafe.Pointer(&err)), wordsOf(unsafe.Pointer(&asAny))
    fmt.Println("error and any holding the same *ValidationError: same data word?", we.data == wn.data, " same type word?", we.typ == wn.typ, "(an itab for error, a type for any)")

    // == compares both words: the type first, then the values they point at.
    fmt.Println("any(7) == any(7)         ", any(7) == any(7))
    fmt.Println("any(int64(7)) == any(7)  ", any(int64(7)) == any(7))
    fmt.Println("any([]int{1}) == any([]int{1}) panics:", panicOf(func() { _ = any([]int{1}) == any([]int{1}) }))
}

an_interface_is_two_words_boxing_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// Putting a value into an interface -- boxing -- copies it to the heap so the
// data word has something to point at, unless the runtime already has that
// value: integers 0 to 255 come from a static table, zero-size values and
// empty strings need no storage, and a pointer is stored in the data word
// itself. testing.AllocsPerRun counts the heap allocations per boxing. Each
// value comes through a //go:noinline function so that the compiler cannot
// fold it into a constant, which it turns into static data at compile time
// (the last row).
//
//  go build an_interface_is_two_words_boxing_go.go && ./an_interface_is_two_words_boxing_go
package main

import (
    "fmt"
    "testing"
)

type Order struct{ ID, Qty int }

var sink any

//go:noinline
func intValue(n int) int { return n }

//go:noinline
func byteValue(b byte) byte { return b }

//go:noinline
func floatValue(f float64) float64 { return f }

//go:noinline
func stringValue(s string) string { return s }

//go:noinline
func orderValue(o Order) Order { return o }

func row(what string, f func()) {
    fmt.Printf("%-24s %v\n", what, testing.AllocsPerRun(1000, f))
}

func main() {
    seven, v255, v256, v300, minusOne := intValue(7), intValue(255), intValue(256), intValue(300), intValue(-1)
    b255 := byteValue(255)
    zero, pi := floatValue(0), floatValue(3.14)
    empty, invoice := stringValue(""), stringValue("invoice")
    order := orderValue(Order{ID: 1, Qty: 2})
    pointer := &order
    var nothing struct{}

    fmt.Printf("%-24s %s\n", "value boxed into any", "allocations per boxing")
    row("int 7", func() { sink = seven })
    row("int 255", func() { sink = v255 })
    row("int 256", func() { sink = v256 })
    row("int 300", func() { sink = v300 })
    row("int -1", func() { sink = minusOne })
    row("byte 255", func() { sink = b255 })
    row("float64 0", func() { sink = zero })
    row("float64 3.14", func() { sink = pi })
    row("string \"\"", func() { sink = empty })
    row("string \"invoice\"", func() { sink = invoice })
    row("Order{1, 2}", func() { sink = order })
    row("struct{}{}", func() { sink = nothing })
    row("*Order", func() { sink = pointer })
    row("nil", func() { sink = nil })
    row("the constant 300", func() { sink = 300 })
}

What to do

  • Hold pointers in interfaces that are made often. A []any of structs allocates once per element on the way in; a []any of pointers to them allocates nothing at the boxing. The same goes for an error built from a struct: return &ValidationError{...}, the pointer.
  • Do not design around the 0–255 table. It is an implementation detail of iface.go at this version, and a program that depends on it will not say so. Measure a hot boxing with testing.AllocsPerRun or b.ReportAllocs() and decide from the number — AllocsPerOp is a key, ns/op is not.
  • Compare interface values, or key a map with them, only when every dynamic type is comparable. The panic is at run time and only on the run that meets the slice.
  • Read the two words through unsafe only to learn. The layout is the runtime's, not the language's; unsafe.Pointer reinterprets memory has the rules for the one tool that can see it.

Practice

A pipeline keeps sensor readings in a []any. With testing.AllocsPerRun, measure how many heap allocations storing one Reading value into a slot costs, and how many storing a *Reading costs. Take the reading from a //go:noinline function so the compiler cannot fold it. Print unsafe.Sizeof of a Reading and of an any, the two counts, and a sentence saying which of the two the pipeline should store and why.

Solution

an_interface_is_two_words_kata_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// Kata: a pipeline keeps sensor readings in a []any. Measure with
// testing.AllocsPerRun how many heap allocations storing one Reading value
// costs against storing a *Reading, print both counts with the sizes of a
// Reading and of an any, and print which of the two you would store.
//
//  go build an_interface_is_two_words_kata_go.go && ./an_interface_is_two_words_kata_go
package main

import (
    "fmt"
    "testing"
    "unsafe"
)

type Reading struct {
    Sensor  int
    Celsius float64
    Tick    int
}

//go:noinline
func readingValue(r Reading) Reading { return r }

func main() {
    slot := make([]any, 1)
    reading := readingValue(Reading{Sensor: 3, Celsius: 21.5, Tick: 40})
    pointer := &reading

    byValue := testing.AllocsPerRun(1000, func() { slot[0] = reading })
    byPointer := testing.AllocsPerRun(1000, func() { slot[0] = pointer })

    fmt.Println("unsafe.Sizeof(Reading) =", unsafe.Sizeof(reading), " unsafe.Sizeof(any) =", unsafe.Sizeof(slot[0]))
    fmt.Println("allocations per store of a Reading: ", byValue)
    fmt.Println("allocations per store of a *Reading:", byPointer)
    switch {
    case byPointer < byValue:
        fmt.Println("store *Reading: the data word holds the pointer, so nothing is copied to the heap per store")
    default:
        fmt.Println("store Reading: no difference measured")
    }
}

Verified output of an_interface_is_two_words_kata_go.go — regenerated by tools/run_examples.py, never hand-typed.

unsafe.Sizeof(Reading) = 24  unsafe.Sizeof(any) = 16
allocations per store of a Reading:  1
allocations per store of a *Reading: 0
store *Reading: the data word holds the pointer, so nothing is copied to the heap per store

In other languages

  • The Rust library's Wide pointers ↗ and Static vs dynamic dispatch ↗ — a &dyn Trait or Box<dyn Trait> is also two words, a data pointer and a vtable pointer, so Rust's trait object is Go's iface with the itab called a vtable. The difference is where the copy comes from: Rust never boxes a value for you — Box::new(v) is written by the programmer, and &v borrows without any allocation — while Go's conversion to an interface copies the value to the heap unless a table already holds it.
  • A C++ object with virtual functions carries its vtable pointer inside the object, so a Base* is one word (cppreference ↗); Java likewise keeps the class pointer in the object header and an interface reference is one word. Go's design keeps the type outside the value, which is what lets a type satisfy an interface it never heard of.

Sources

  • Russ Cox, Go Data Structures: Interfaces ↗, 1 December 2009 — the two-word layout, the itab, and why method calls are one indirection.
  • The runtime: src/runtime/runtime2.go at go1.25.5 ↗ (iface, eface) and src/runtime/iface.go at go1.25.5 ↗ (convT, convT64, convTstring, staticuint64s).
  • The Go specification: Comparison operators ↗ (interface equality and the run-time panic) and Interface types ↗.
  • unsafe.Sizeof ↗ and testing.AllocsPerRun ↗ — the latter's source, src/testing/allocs.go ↗, shows it counting MemStats.Mallocs with integer division, which is why the table holds whole numbers.
  • Alan A. A. Donovan and Brian W. Kernighan, The Go Programming Language (Addison-Wesley, 2015), chapter 7, "Interfaces" — section 7.5, "Interface Values" (the type and value pair, and comparing interface values).
  • Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 7, "Types, Methods, and Interfaces" — "Interfaces Are Comparable" and "The Empty Interface Says Nothing".
  • Adam Freeman, Pro Go (Apress, 2022), chapter 11, "Using Methods and Interfaces" — "Comparing Interface Values" and "Using the Empty Interface".