Skip to content

Reflection costs allocations

Level: 301 · anyone whose profile says reflect or encoding/json and who wants to know which call to blame

One line: On Go 1.25 reflect.ValueOf(x).Kind() allocates nothing, like a type switch; what allocates is boxing a value into an interface, Interface() on an addressable field (a copy — Go 1.25's reflect.TypeAssert avoids it), and every encoder that builds a result — json.Marshal of a struct is 1 or 2 against 0 for hand-written appends into a reused buffer. Counts are the key; nanoseconds are in a fence.

testing.AllocsPerRun runs a function a hundred times and returns the average number of heap allocations per call (AllocsPerRun counts allocations). The program puts twenty reflection and encoding calls through it on one Invoice, using package-level variables so that the compiler cannot fold the values into constants. The counts do not depend on the machine — both CI runners and the Linux container print the same table — but they do depend on the Go version, and the page says which.

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

allocations per call
box an int into any                                1
box a 4-field struct into any                      1
type switch on the boxed int                       0
reflect.ValueOf(boxed int).Kind()                  0
reflect.ValueOf(int).Kind(), boxing inside         0
reflect.TypeOf(boxed struct).Kind()                0
ValueOf(boxed int).Interface() round trip          0
ValueOf(boxed struct).Field(0).Interface()         0
ValueOf(&invoice).Elem().Field(0).Interface()      1
ValueOf(&invoice).Elem().Field(0).String()         0
TypeAssert[string](same Field(0)), Go 1.25         0
TypeAssert[Invoice](ValueOf(&invoice).Elem())      0
ValueOf(&invoice).Elem().Interface().(Invoice)     1
TypeOf(invoice).Field(0).Tag.Get("json")           0
json.Marshal(invoice)                              2
json.Marshal(&invoice)                             1
appendInvoice into a reused 128-byte buffer        0
appendInvoice into nil, growing as it goes         4
fmt.Sprintf("%T", invoice)                         2
reflect.TypeOf(invoice).String()                   0
hand-written and json.Marshal agree byte for byte: true

Reading the output

Boxing is the first allocation, not reflection. Storing an int or a struct in an any allocates once: the interface's second word must point at a copy (An interface is two words). Once the value is boxed, a type switch and reflect.ValueOf(boxed).Kind() both cost 0, and so does reflect.ValueOf(orderCount).Kind() with the boxing inside — because since Go 1.21 ↗ ValueOf no longer forces its argument onto the heap, and a Value whose content stays on the stack is free. Before 1.21 that line was 1. The version matters more than the machine.

Interface() copies an addressable value. ValueOf(boxed struct).Field(0).Interface() is 0, but ValueOf(&invoice).Elem().Field(0).Interface() is 1: the second Value is addressable, and handing out an interface that aliased the live variable would let the caller see later writes through a value that is supposed to be a copy, so packEface ↗ allocates a copy. String() on the same field is 0, because it returns the string header without boxing. Go 1.25's reflect.TypeAssert[T] ↗ converts a Value straight to a T — 0 for the string field and 0 for the whole Invoice where Interface().(Invoice) is 1 — which the Go 1.25 release notes ↗ describe as avoiding unnecessary memory allocations.

Reading a tag costs nothing. TypeOf(invoice).Field(0).Tag.Get("json") is 0: the type information is static data, and Get scans a string. Reflection that only reads types is cheap; what costs is producing values.

json.Marshal: 1 for the result, 1 for the box. json.Marshal(&invoice) allocates once — the returned []byte, copied out of a pooled encoder buffer. json.Marshal(invoice) allocates twice, the extra one being the struct boxed into the any parameter. The hand-written appendInvoice into a reused buffer allocates 0 and produces the same bytes (the last line checks); into nil it allocates 4 as append grows the slice. The reflect-driven encoder is not the expensive part; the result and the growth are.

fmt.Sprintf("%T") is 2, reflect.TypeOf(x).String() is 0. Same answer, and fmt boxes the argument and builds a string; Type.String returns a name the linker already stored.

Real runs

Counts are the key; time is not. The benchmarks in demo/ do the same work under go test -bench:

Real runs — go1.25.5, x86-64 Mac, 1 run, 2026-09-23
cpu: Intel(R) Core(TM) i5-10500 CPU @ 3.10GHz
BenchmarkTypeSwitch-12              809447187            1.500 ns/op           0 B/op          0 allocs/op
BenchmarkReflectKind-12             422698201            2.849 ns/op           0 B/op          0 allocs/op
BenchmarkFieldInterfaceCopy-12      29520829            38.09 ns/op       16 B/op          1 allocs/op
BenchmarkJSONMarshal-12              3501051           370.8 ns/op        80 B/op          1 allocs/op
BenchmarkAppendInvoice-12            5565796           232.9 ns/op         0 B/op          0 allocs/op
BenchmarkSelectStatement-12         13578633            92.99 ns/op        0 B/op          0 allocs/op
BenchmarkReflectSelect-12            8378013           144.6 ns/op        16 B/op          2 allocs/op

The allocs/op column repeats the program's counts, and B/op says what they weigh: 16 bytes for the copied field, 80 for the JSON. The ns/op column is the part that changes with the machine, the load and the run, which is why it lives here and not in the key (allocs/op is a key, ns/op is not). To run them: bash demo/bench.sh from this lesson's folder; it copies demo/reflection_costs_bench_test.go into a throwaway module, so -count 5 or -benchtime can be added inside the script.

reflection_costs_allocations_go.go

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

// Allocations per call, counted with testing.AllocsPerRun: a type switch
// against reflect.ValueOf(x).Kind(), Interface() round trips, and json.Marshal
// against hand-written formatting. Counts, never nanoseconds: the counts are
// the same on every machine running the same Go version.
//
//  go build reflection_costs_allocations_go.go && ./reflection_costs_allocations_go
package main

import (
    "encoding/json"
    "fmt"
    "reflect"
    "strconv"
    "testing"
)

type Invoice struct {
    Number   string  `json:"number"`
    Customer string  `json:"customer"`
    Amount   float64 `json:"amount"`
    Paid     bool    `json:"paid"`
}

// Package-level, so the compiler cannot fold them into constants.
var (
    orderCount = 1000
    invoice    = Invoice{Number: "INV-1001", Customer: "Ada Lovelace", Amount: 1250.5, Paid: true}

    sinkAny     any
    sinkKind    reflect.Kind
    sinkStr     string
    sinkBytes   []byte
    sinkInvoice Invoice
)

func kindBySwitch(x any) reflect.Kind {
    switch x.(type) {
    case int:
        return reflect.Int
    case string:
        return reflect.String
    case Invoice:
        return reflect.Struct
    }
    return reflect.Invalid
}

func appendInvoice(buf []byte, inv Invoice) []byte {
    buf = append(buf, `{"number":`...)
    buf = strconv.AppendQuote(buf, inv.Number)
    buf = append(buf, `,"customer":`...)
    buf = strconv.AppendQuote(buf, inv.Customer)
    buf = append(buf, `,"amount":`...)
    buf = strconv.AppendFloat(buf, inv.Amount, 'f', -1, 64)
    buf = append(buf, `,"paid":`...)
    buf = strconv.AppendBool(buf, inv.Paid)
    return append(buf, '}')
}

func main() {
    count := func(what string, f func()) {
        fmt.Printf("%-50s %v\n", what, testing.AllocsPerRun(100, f))
    }
    var boxedInt any = orderCount
    var boxedInvoice any = invoice
    buf := make([]byte, 0, 128)

    fmt.Println("allocations per call")
    count("box an int into any", func() { sinkAny = orderCount })
    count("box a 4-field struct into any", func() { sinkAny = invoice })
    count("type switch on the boxed int", func() { sinkKind = kindBySwitch(boxedInt) })
    count("reflect.ValueOf(boxed int).Kind()", func() { sinkKind = reflect.ValueOf(boxedInt).Kind() })
    count("reflect.ValueOf(int).Kind(), boxing inside", func() { sinkKind = reflect.ValueOf(orderCount).Kind() })
    count("reflect.TypeOf(boxed struct).Kind()", func() { sinkKind = reflect.TypeOf(boxedInvoice).Kind() })
    count("ValueOf(boxed int).Interface() round trip", func() { sinkAny = reflect.ValueOf(boxedInt).Interface() })
    count("ValueOf(boxed struct).Field(0).Interface()", func() { sinkAny = reflect.ValueOf(boxedInvoice).Field(0).Interface() })
    count("ValueOf(&invoice).Elem().Field(0).Interface()", func() { sinkAny = reflect.ValueOf(&invoice).Elem().Field(0).Interface() })
    count("ValueOf(&invoice).Elem().Field(0).String()", func() { sinkStr = reflect.ValueOf(&invoice).Elem().Field(0).String() })
    count("TypeAssert[string](same Field(0)), Go 1.25", func() { sinkStr, _ = reflect.TypeAssert[string](reflect.ValueOf(&invoice).Elem().Field(0)) })
    count("TypeAssert[Invoice](ValueOf(&invoice).Elem())", func() { sinkInvoice, _ = reflect.TypeAssert[Invoice](reflect.ValueOf(&invoice).Elem()) })
    count("ValueOf(&invoice).Elem().Interface().(Invoice)", func() { sinkInvoice = reflect.ValueOf(&invoice).Elem().Interface().(Invoice) })
    count("TypeOf(invoice).Field(0).Tag.Get(\"json\")", func() { sinkStr = reflect.TypeOf(invoice).Field(0).Tag.Get("json") })
    count("json.Marshal(invoice)", func() { sinkBytes, _ = json.Marshal(invoice) })
    count("json.Marshal(&invoice)", func() { sinkBytes, _ = json.Marshal(&invoice) })
    count("appendInvoice into a reused 128-byte buffer", func() { sinkBytes = appendInvoice(buf[:0], invoice) })
    count("appendInvoice into nil, growing as it goes", func() { sinkBytes = appendInvoice(nil, invoice) })
    count("fmt.Sprintf(\"%T\", invoice)", func() { sinkStr = fmt.Sprintf("%T", invoice) })
    count("reflect.TypeOf(invoice).String()", func() { sinkStr = reflect.TypeOf(invoice).String() })

    same := string(appendInvoice(nil, invoice)) == string(must(json.Marshal(invoice)))
    fmt.Println("hand-written and json.Marshal agree byte for byte:", same)
}

func must(b []byte, err error) []byte {
    if err != nil {
        panic(err)
    }
    return b
}

What to do

  • Measure the count, on your Go version, before rewriting. testing.AllocsPerRun is ten lines and the answer for ValueOf changed in 1.21; the answer for Interface() on a field changed in 1.25 with TypeAssert.
  • Blame the box, not the Kind. The allocation in reflect.ValueOf(x) code is usually the conversion of x to any at the call, which a signature taking any forces on every caller.
  • Prefer TypeAssert[T](v), v.String(), v.Int() to v.Interface().(T) when the Value came through a pointer.
  • Pass a pointer to json.Marshal and reuse buffers with json.Encoder or Append-style code when the encoder shows in a profile; the struct box and the result copy are the allocations, not the reflection walk.
  • Keep nanoseconds in a fence and counts in the test: a count is the same on CI, a laptop and a container.

Practice

An Equal method for Order was written as reflect.DeepEqual(o, p). With two package-level Order variables that the compiler cannot fold, count the allocations per call of reflect.DeepEqual(left, right), left == right and a hand-written Equal method; print the three counts, confirm the three answers agree, and print which one to keep and why.

Solution

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

// Kata: an Equal for orders was written with reflect.DeepEqual. Count its
// allocations per call against == and against a hand-written method, on two
// variables the compiler cannot fold, and say which to keep.
//
//  go build reflection_costs_allocations_kata_go.go && ./reflection_costs_allocations_kata_go
package main

import (
    "fmt"
    "reflect"
    "testing"
)

type Order struct {
    ID       int
    Customer string
    Items    [3]string
}

func (o Order) Equal(p Order) bool {
    return o.ID == p.ID && o.Customer == p.Customer && o.Items == p.Items
}

var (
    left  = Order{ID: 7, Customer: "Ada", Items: [3]string{"lamp", "desk", "chair"}}
    right = Order{ID: 7, Customer: "Ada", Items: [3]string{"lamp", "desk", "chair"}}
    sink  bool
)

func main() {
    count := func(what string, f func()) { fmt.Printf("%-28s %v\n", what, testing.AllocsPerRun(100, f)) }
    count("reflect.DeepEqual(left, right)", func() { sink = reflect.DeepEqual(left, right) })
    count("left == right", func() { sink = left == right })
    count("left.Equal(right)", func() { sink = left.Equal(right) })
    fmt.Println("all three agree:", reflect.DeepEqual(left, right) == (left == right) && (left == right) == left.Equal(right))
    fmt.Println("keep: == (the type is comparable); DeepEqual is for slices and maps")
}

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

reflect.DeepEqual(left, right) 2
left == right                0
left.Equal(right)            0
all three agree: true
keep: == (the type is comparable); DeepEqual is for slices and maps

In other languages

  • Rust pays the encoder's cost at compile time: serde generates the field-by-field code that json.Marshal derives with reflect on every call, so there is no walk to allocate for, and the counting is done with the harnesses in the Rust library's Benchmark harnesses ↗ rather than an AllocsPerRun — Rust's std has no allocation counter, and a custom GlobalAlloc stands in.
  • Python has no cheaper path: getattr, dataclasses.fields and json.dumps are all reflection over dictionaries, and a __slots__ class is the closest thing to removing the box.
  • Java's java.lang.reflect calls were the slow path for years; MethodHandle ↗ and the JIT closed most of the gap, and Jackson's generated accessors do what Go's reflect does with Field(i).

Sources

  • testing.AllocsPerRun ↗ — runs once to warm up, then averages, and the result is an integer-valued float.
  • Release notes: Go 1.21, reflect ↗ — ValueOf no longer forces its argument onto the heap; Go 1.25, reflect ↗ — TypeAssert avoids the allocation of Interface() plus an assertion.
  • src/reflect/value.go at go1.25.5 ↗ — packEface, which copies an addressable value before boxing it; TypeAssert.
  • encoding/json, Marshal ↗ — and src/encoding/json/encode.go ↗: the pooled encodeState and the copy of its bytes into the result.
  • Alan A. A. Donovan and Brian W. Kernighan, The Go Programming Language (Addison-Wesley, 2015), chapter 12, "Reflection" — "A Word of Caution".
  • Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 16, "Here Be Dragons: Reflect, Unsafe, and Cgo" — "Use Reflection Only if It's Worthwhile".
  • Go in Practice, 2nd ed. (Manning, 2025), chapter 13, "Reflection, code generation, and advanced Go" — "Generating Go code with Go code", the alternative when the encoder's cost matters.