Go Internals - Reflection
Introduction
In Go Internals - Interfaces we treated _type and itab as the runtime's answer to "what is this value, and how do I call its methods?" Reflection is the public API that walks those same descriptors after the compiler has left the picture.
encoding/json, ORMs, DI containers, and a surprising amount of "generic until Go had generics" code all sit on reflect.Type and reflect.Value. The cost is not mystical: every ValueOf, field walk, Interface(), and especially Call is ordinary code that can allocate, check flags, and build argument frames — without the optimizations a monomorphic call site gets.
Same descriptors, different package
The compiler embeds type metadata into the binary. At runtime those descriptors are abi.Type (and richer neighbors like abi.StructType, abi.FuncType). The Interfaces article called the older name _type; in Go 1.26 the shared shape lives under internal/abi.
reflect does not invent a second type system. reflect.Type is an interface implemented mostly by rtype, which wraps abi.Type:
// reflect/type.go — simplified
type rtype struct {
t abi.Type
}
func TypeOf(i any) Type {
return toType(abi.TypeOf(i))
}
func TypeFor[T any]() Type {
return toRType(abi.TypeFor[T]())
}
TypeOf takes an interface value and reads the concrete type out of it — the same empty-interface header you already know (Type + Data). TypeFor[T]() skips the interface route when the type is known at compile time, which is the better default for lookup tables and once-only setup.
flowchart LR
A["interface / TypeFor[T]"] --> B["abi.Type descriptor"]
B --> C["reflect.rtype"]
C --> D["reflect.Type methods"]
So when you call t.Kind(), t.NumField(), or t.Implements(u), you are asking questions of metadata the linker already retained for GC, interfaces, and the runtime — not something reflection magically discovers at the last second.
Value layout: typ_, ptr, flag
reflect.Value is three words of bookkeeping around a Go value:
// reflect/value.go — simplified
type Value struct {
typ_ *abi.Type
ptr unsafe.Pointer
flag
}
typ_— which concrete type thisValueclaims to hold.ptr— either the value bits (for some direct-interface cases) or a pointer to the data whenflagIndiris set.flag— packedKindin the low bits, plus read-only / indir / addr / method bits above that.
ValueOf is literally unpacking an empty interface:
func ValueOf(i any) Value {
if i == nil {
return Value{} // invalid: IsValid() == false
}
return unpackEface(i)
}
That ties back to the interface nil trap: ValueOf((*T)(nil)) is a valid Value of kind Pointer whose pointer is nil. ValueOf(nil) (untyped nil interface) is the zero Value and panics on almost every method. Same two-word story as error; different package.
flowchart TB
I["any / eface"] --> U["unpackEface"]
U --> V["Value{typ_, data, flag}"]
V --> R["Field / Index / Call / Interface"]
Addressability and settability
Most of the "why did Set panic?" confusion is flag, not types.
CanAddr— theValuepoints at an addressable location (flagAddr). Locals passed throughValueOf(x)usually are not addressable;ValueOf(&x).Elem()often is.CanSet— addressable and not read-only. Unexported fields reached via reflection carryflagStickyRO/flagEmbedRO, so you can read them in some cases but not assign throughSet.
type user struct {
Name string
age int
}
func demo() {
u := user{Name: "lucas", age: 30}
v := reflect.ValueOf(u) // copy, not addressable
// v.Field(0).SetString("x") // panics: not settable
p := reflect.ValueOf(&u).Elem()
p.Field(0).SetString("other") // ok: exported + addressable
// p.Field(1).SetInt(31) // panics: unexported
}
Reflection enforces Go's visibility rules at runtime. That is deliberate: allowing Set on unexported fields from another package would punch a hole through encapsulation every time someone called reflect.
Walking structs and tags
Struct metadata rides on the same descriptors GC and the compiler use: field names, offsets, types, and tags live next to abi.Type for struct kinds. Field(i) / FieldByName return StructField with Offset, Type, and Tag.
type Item struct {
ID int `json:"id"`
Name string `json:"name,omitempty"`
}
func dump(v any) {
t := reflect.TypeOf(v)
rv := reflect.ValueOf(v)
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fmt.Println(f.Name, f.Tag.Get("json"), rv.Field(i).Interface())
}
}
Hot encoders usually cache Type → field plan once (see how encoding/json grew type caches over the years). Re-resolving field names and tags on every request is pure CPU burn on top of whatever allocation the walk already causes.
Method / MethodByName on a concrete type only expose exported methods. The linker note in the docs is real: using those APIs can keep methods alive that dead-code elimination would otherwise drop, which can grow binaries even if the call never runs at runtime.
Call and MakeFunc
Value.Call is where reflection stops being "read some metadata" and starts building a real call:
- Check kind, export rules, argument count and assignability.
- Lay out stack/register arguments according to the platform ABI (
funcLayout). - Invoke through runtime helpers (
call/ ABI stubs). - Box results back into
[]Value.
That path cannot look like a direct f(a, b) the compiler inlined. Argument []Value slices, result slices, and sometimes copies of non-direct interface values all show up in profiles and allocation stats.
func add(a, b int) int { return a + b }
func viaReflect(n int) int {
fv := reflect.ValueOf(add)
sum := 0
for i := 0; i < n; i++ {
out := fv.Call([]reflect.Value{
reflect.ValueOf(i),
reflect.ValueOf(i + 1),
})
sum += int(out[0].Int())
}
return sum
}
MakeFunc goes further: it allocates a makeFuncImpl with a stub entry point and a Go callback that receives []Value. Useful for proxies and test doubles; expensive if you put it on a per-request hot path.
sequenceDiagram
participant App as application
participant RV as reflect.Value.Call
participant ABI as funcLayout / call stub
participant Fn as target function
App->>RV: Call([]Value)
RV->>RV: check kinds, assignability
RV->>ABI: build arg frame
ABI->>Fn: invoke
Fn-->>ABI: results
ABI-->>RV: pack []Value
RV-->>App: return
Constructed types and caches
SliceOf, MapOf, ChanOf, ArrayOf, FuncOf, and StructOf synthesize types at runtime. Internally they look up a sync.Map cache keyed by kind + element types; misses allocate a fresh abi.Type-shaped object and register it so GC and reflection keep working.
elem := reflect.TypeFor[int]()
t1 := reflect.SliceOf(elem)
t2 := reflect.SliceOf(elem)
fmt.Println(t1 == t2) // true — cached identity
That cache is why constructing []int via reflection repeatedly stays cheap after the first hit, while inventing large unique StructOf shapes still allocates and retains metadata for the process lifetime.
Where allocations actually come from
People say "reflection allocates" as if import "reflect" itself taxed the heap. The usual culprits are more specific:
| Operation | Why it can allocate |
|---|---|
ValueOf on a non-pointer small value that escapes via interface |
Boxing into any before unpack (same as Interfaces) |
Interface() on an addressable value |
May copy into a new heap object so the interface does not alias stack/mutable memory |
Call / CallSlice |
Argument and result []Value, plus ABI frame machinery |
New / MakeSlice / MakeMap |
Explicit heap allocation of the constructed value |
First SliceOf / StructOf miss |
New runtime type metadata |
Narrower APIs help when you already know the kind: Int(), String(), Bytes() avoid an Interface() round-trip. Prefer TypeFor[T]() over TypeOf((*T)(nil)).Elem() ceremony when you can.
// allocates on the Call path when used in a hot loop
out := reflect.ValueOf(fmt.Sprintf).Call([]reflect.Value{
reflect.ValueOf("%d"),
reflect.ValueOf(42),
})
// usually cheaper once types are fixed: stay concrete, or cache Value/Type
s := fmt.Sprintf("%d", 42)
_ = s
_ = out
Measure with go test -bench=. -benchmem on the real helper, not a toy that only constructs a Type once. Escape analysis still applies: -gcflags='-m=2' on a small repro will show which ValueOf arguments leave the stack.
Practical guidelines
- Use reflection at boundaries (decode, plugins, test helpers), not inside tight per-item loops when the type set is closed.
- Cache
reflect.Typeand field/method indexes after first use for a given type. - Prefer
TypeFor[T]()and kind-specific getters overInterface()when the static type is known. - Treat
Callas a compatibility tool; if a method is on the hot path, expose a concrete or generic API. - Remember visibility and addressability —
CanSetfailing is usually a missing pointer or an unexported field, not a mysterious runtime bug.
Conclusion
Reflection in Go is a thin, strict API over the same abi.Type descriptors interfaces and the GC already need. Type asks questions about that metadata; Value carries a pointer-sized view of data plus flags for kind, RO, and addressability. Costs pile up when you box through any, re-walk structs, or build call frames on every invocation — not because the package is "slow by nature."
Next in the series is Concurrency — channels, select, and the sync primitives that park and wake goroutines on top of the scheduler we started with.