Back to home

Go Internals - Reflection

8 min read
Cover Image for Go Internals - Reflection
Lucas LemosLucas Lemos

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 this Value claims to hold.
  • ptr — either the value bits (for some direct-interface cases) or a pointer to the data when flagIndir is set.
  • flag — packed Kind in 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 — the Value points at an addressable location (flagAddr). Locals passed through ValueOf(x) usually are not addressable; ValueOf(&x).Elem() often is.
  • CanSet — addressable and not read-only. Unexported fields reached via reflection carry flagStickyRO / flagEmbedRO, so you can read them in some cases but not assign through Set.
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:

  1. Check kind, export rules, argument count and assignability.
  2. Lay out stack/register arguments according to the platform ABI (funcLayout).
  3. Invoke through runtime helpers (call / ABI stubs).
  4. 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.Type and field/method indexes after first use for a given type.
  • Prefer TypeFor[T]() and kind-specific getters over Interface() when the static type is known.
  • Treat Call as a compatibility tool; if a method is on the hot path, expose a concrete or generic API.
  • Remember visibility and addressability — CanSet failing 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.