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:
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.AllocsPerRunis ten lines and the answer forValueOfchanged in 1.21; the answer forInterface()on a field changed in 1.25 withTypeAssert. - Blame the box, not the
Kind. The allocation inreflect.ValueOf(x)code is usually the conversion ofxtoanyat the call, which a signature takinganyforces on every caller. - Prefer
TypeAssert[T](v),v.String(),v.Int()tov.Interface().(T)when theValuecame through a pointer. - Pass a pointer to
json.Marshaland reuse buffers withjson.EncoderorAppend-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.
In other languages¶
- Rust pays the encoder's cost at compile time:
serdegenerates the field-by-field code thatjson.Marshalderives withreflecton 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 anAllocsPerRun— Rust'sstdhas no allocation counter, and a customGlobalAllocstands in. - Python has no cheaper path:
getattr,dataclasses.fieldsandjson.dumpsare all reflection over dictionaries, and a__slots__class is the closest thing to removing the box. - Java's
java.lang.reflectcalls were the slow path for years;MethodHandle↗ and the JIT closed most of the gap, and Jackson's generated accessors do what Go'sreflectdoes withField(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↗ —ValueOfno longer forces its argument onto the heap; Go 1.25,reflect↗ —TypeAssertavoids the allocation ofInterface()plus an assertion. src/reflect/value.goat go1.25.5 ↗ —packEface, which copies an addressable value before boxing it;TypeAssert.encoding/json,Marshal↗ — andsrc/encoding/json/encode.go↗: the pooledencodeStateand 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.