How Go's reflect Package Reads Types: The rtype and the Method Table

reflect is not a parallel type system: it reads the very same runtime type descriptors the compiler emits, and a reflect.Value is just a (typ, ptr, flag) triple with a bitfield packed into the flags word.

TL;DR: reflect does not have its own type system. It reads the exact abi.Type descriptors the compiler emits into the binary — the same ones the garbage collector and the runtime use. A reflect.Value is a three-word triple: a type pointer, a data pointer, and a flag word where ten bits carry the kind, whether the data is behind a pointer, whether it is addressable, and whether it is read-only.

Reflection in Go has a reputation for being slow and imprecise. Both accusations are half right, and the reasons are worth understanding, because the implementation is not what most people assume.

The usual assumption is that reflect maintains a parallel, richer description of every type — a “runtime type” distinct from the “compile-time type”. It doesn’t. There is exactly one type description in a Go binary: the compiler emits it, it is linked into read-only data, and both the runtime and reflect read it.

This is how it all works, based on reading the Go source tree (Go 1.28 development tree, commit c0be4674).

One type description, two readers

The canonical structure lives in src/internal/abi/type.go, because the runtime and the compiler must agree on its layout byte for byte:

go
// src/internal/abi/type.go:21
type Type struct {
	Size_       uintptr
	PtrBytes    uintptr // number of (prefix) bytes in the type that can contain pointers
	Hash        uint32  // hash of type; avoids computation in hash tables
	TFlag       TFlag   // extra type information flags
	Align_      uint8   // alignment of variable with this type
	FieldAlign_ uint8   // alignment of struct field with this type
	Kind_       Kind    // what kind of type this is (string, int, ...)
	// function for comparing objects of this type

Twenty-odd lines that describe every type in the program: how big it is, how much of it contains pointers (what the GC cares about), a hash for map keys, an equality function, the GC’s pointer bitmask, and two offsets into the module’s name and type tables.

reflect does not have its own copy. It aliases it:

go
// src/reflect/type.go:345
// Embed this type to get common/uncommon
type common struct {
	abi.Type
}

// rtype is the common implementation of most values.

So reflect.rtype is a one-field wrapper around abi.Type. The Type interface is implemented by *rtype and its friends (structType, ptrType, sliceType, …), each a larger struct whose first field is the same abi.Type header. That is why the conversion is a single cast:

go
// src/reflect/type.go:780
func toRType(t *abi.Type) *rtype {
	return (*rtype)(unsafe.Pointer(t))
}

A slice’s type descriptor is a sliceType whose first bytes happen to be a Type header. Cast to *rtype and you get the header; cast to *sliceType and you get the whole thing. One representation, many views.

The bridge from the runtime’s world to reflect’s world is one linknamed function:

go
// src/reflect/type.go:2845
//go:linkname toType
func toType(t *abi.Type) Type {
	if t == nil {
		return nil
	}
	return toRType(t)
}

The runtime version returns a *rtype; the reflect version returns the public Type interface. Same memory, different static type.

The flag bitfield

abi.Type carries a TFlag byte:

go
// src/internal/abi/type.go:110
	// TFlagNamed means the type has a name.
	TFlagNamed TFlag = 1 << 2

	// TFlagRegularMemory means that equal and hash functions can treat
	// this type as a single region of t.size bytes.
	TFlagRegularMemory TFlag = 1 << 3

	// TFlagGCMaskOnDemand means that the GC pointer bitmask will be

The design idea here is worth stealing: the common part of a type descriptor is fixed-size and shared by every type of that kind; the variable parts sit immediately after it, with a bit in TFlag telling you whether they are there at all.

TFlagUncommon is the important one: it says “the optional UncommonType struct follows the common header”. TFlagExtraStar is a binary-size optimisation — the name stored for *T may be the name of T with a * glued on, and the flag tells you to strip it. TFlagRegularMemory lets reflect.DeepEqual and the runtime’s map code take a shortcut: a type with no internal structure compares as raw bytes. TFlagDirectIface is the answer to “does this value fit in an interface word?”.

The method table

Where do methods live? Not in the Type header — that is shared by every type of that kind, including ones with no methods at all. Methods live in an optional trailer:

go
// src/internal/abi/type.go:230
type UncommonType struct {
	PkgPath NameOff // import path; empty for built-in types like int, string
	Mcount  uint16  // number of methods
	Xcount  uint16  // number of exported methods
	Moff    uint32  // offset from this uncommontype to [mcount]Method
	_       uint32  // unused
}

Moff is an offset, not a pointer, because the method array sits right after the UncommonType struct. Methods() reads it with unchecked arithmetic:

go
// src/internal/abi/type.go:238
func (t *UncommonType) Methods() []Method {
	if t.Mcount == 0 {
		return nil
	}
	return (*[1 << 16]Method)(addChecked(unsafe.Pointer(t), uintptr(t.Moff), "t.mcount > 0"))[:t.Mcount:t.Mcount]
}

addChecked looks like a bounds check but isn’t — the string argument is documentation only, and the function exists so the compiler can still inline p+x.

Note the two text offsets. Tfn is the function you would call directly, receiver first. Ifn is the interface version — the wrapper an itab points at.

reflect reads this table to build its Method values:

go
// src/reflect/type.go:645
func (t *rtype) Method(i int) (m Method) {
	if t.Kind() == Interface {
		tt := (*interfaceType)(unsafe.Pointer(t))
		return tt.Method(i)
	}
	methods := t.exportedMethods()
	if i < 0 || i >= len(methods) {
		panic("reflect: Method index out of range")
	}
	p := methods[i]
	pname := t.nameOff(p.Name)
	m.Name = pname.Name()

That is a lot of work per call: a new funcType with a receiver prepended, allocated slices, a fresh Value. The “reflection is slow” story in miniature — and it is only slow because it is general.

reflect.Value: a triple with a bitfield

reflect.Value is the most over- and under-appreciated struct in the standard library. Here is the whole thing:

go
// src/reflect/value.go:40
type Value struct {
	// typ_ holds the type of the value represented by a Value.
	// Access using the typ method to avoid escape of v.
	typ_ *abi.Type

	// Pointer-valued data or, if flagIndir is set, pointer to data.
	// Valid when either flagIndir is set or typ.pointers() is true.
	ptr unsafe.Pointer

Three words: type, data pointer, flags. The flags word packs everything reflect needs to know about a value without dereferencing the type:

BitMeaning
0–4The Kind of the value, cached so v.Kind() is a mask, not a load
5flagStickyRO — came from an unexported non-embedded field
6flagEmbedRO — came from an unexported embedded field
7flagIndir — ptr points at the data rather than being the data
8flagAddr — the value is addressable (CanAddr)
9flagMethod — the value is a bound method value

flagIndir is the subtle one. A reflect.Value holding an int stores the integer in the ptr word, because an int fits in a pointer-sized word. A reflect.Value holding a string — two words — cannot, so it stores a pointer to the string header and sets flagIndir.

The read-only flags prevent a whole class of bugs: unsafe can read unexported fields, but reflect refuses to hand them back through Interface():

go
// src/reflect/value.go:1486
func (v Value) Interface() (i any) {
	return valueInterface(v, true)
}

func valueInterface(v Value, safe bool) any {
	if v.flag == 0 {
		panic(&ValueError{"reflect.Value.Interface", Invalid})
	}

CanSet is the same idea in miniature: v.flag&(flagAddr|flagRO) == flagAddr — addressable and not read-only, both conditions in one expression.

packEface: turning a Value back into an any

Value.Interface() has to produce an any, which is a two-word structure: a type pointer and a data pointer. packEface constructs the header directly:

go
// src/reflect/value.go:118
// packEface converts v to the empty interface.
func packEface(v Value) any {
	return *(*any)(unsafe.Pointer(&abi.EmptyInterface{
		Type: v.typ(),
		Data: packEfaceData(v),
	}))
}

Three cases, and the middle one is the interesting one. If the type is “direct interface” (fits in a word, no pointers — int, bool, a pointer, a chan) but the Value was obtained indirectly, the data has to be loaded out of the pointed-to location. If the type is not direct but the Value is addressable, the data has to be copied into a fresh allocation: the interface owns that word and the original might move.

And going the other way, unpackEface is the whole of ValueOf:

go
// src/reflect/value.go:153
// unpackEface converts the empty interface i to a Value.
func unpackEface(i any) Value {
	e := (*abi.EmptyInterface)(unsafe.Pointer(&i))
	t := e.Type
	if t == nil {
		return Value{}
	}
	f := flag(t.Kind())

That is the whole trick: read the type pointer out of the interface header, read the kind out of the type descriptor, set flagIndir if the type does not fit directly, and you are done.

reflect.TypeOf is an interface conversion

Here is the part that surprises people. reflect.TypeOf does no work at runtime beyond what the language already did for you:

go
// src/reflect/type.go:1389
// TypeOf returns the reflection [Type] that represents the dynamic type of i.
// If i is a nil interface value, TypeOf returns nil.
func TypeOf(i any) Type {
	return toType(abi.TypeOf(i))
}

and abi.TypeOf is a reinterpretation of the interface header:

go
// src/internal/abi/type.go:181
// TypeOf returns the abi.Type of some value.
func TypeOf(a any) *Type {
	eface := *(*EmptyInterface)(unsafe.Pointer(&a))
	// Types are either static (for compiler-created types) or
	// heap-allocated but always reachable (for reflection-created
	// types, held in the central map). So there is no need to
	// escape types. noescape here help avoid unnecessary escape
	// of v.
	return (*Type)(NoEscape(unsafe.Pointer(eface.Type)))
}

Passing a value to reflect.TypeOf(i any) boxes it into an interface, and the interface header already contains a pointer to the type descriptor. TypeOf hands that pointer back. No lookup, no map, no allocation. reflect.ValueOf is slightly more work: it constructs the flag word, which means inspecting IsDirectIface.

And reflect.TypeOf(nil) returns nil, because a nil interface has a nil type pointer. There is no “nil type”.

The itab: interface dispatch, for contrast

The other half of type identity is the itab, the table that makes err.(someInterface) work. It’s defined in src/internal/abi/iface.go:

go
// src/internal/abi/iface.go:17
type ITab struct {
	Inter *InterfaceType
	Type  *Type
	Hash  uint32     // copy of Type.Hash. Used for type switches.
	Fun   [1]uintptr // variable sized. fun[0]==0 means Type does not implement Inter.
}

A non-empty interface value is a pair: an *ITab and a data pointer. The ITab says which concrete type is inside, which interface it matched against, and — in the variable-length Fun array — the function pointers for each method.

go
// src/runtime/iface.go:44
func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
	if len(inter.Methods) == 0 {
		throw("internal error - misuse of itab")
	}

	// easy case
	if typ.TFlag&abi.TFlagUncommon == 0 {
		if canfail {
			return nil

Note the first check: if the concrete type has no methods, the TFlagUncommon bit is clear, and the type cannot implement any non-empty interface. The flag doing real work.

Type switches never consult the runtime hash table. The compiler statically generates an itab for every (interface, concrete type) pair in a type switch, so the switch is a hash comparison and a lookup in a compiler-built table — no locks, no allocation, no runtime call.

Reflection versus a type switch

The performance difference is not “reflection is slow and type switches are fast” in the abstract. It’s concrete and measurable:

OperationWhat actually happens
switch v.(type) { case T: }Compiler-generated hash comparison, then a table lookup. No allocation, no interface call.
reflect.TypeOf(v) == reflect.TypeOf(x)Read the type pointer out of the interface header, compare two pointers. Essentially free.
reflect.ValueOf(v).Field(i)Build a Value, compute offsets, check bounds, construct a new Value with new flags.
reflect.ValueOf(v).Interface()packEface — possibly a heap allocation and a typedmemmove.
reflect.ValueOf(v).Call(args)Allocate an argument frame, translate each argument through the ABI description, call through a function pointer, translate results back.
reflect.TypeOf(v).Method(i)Rebuild a funcType, allocate slices, allocate a Value.

So the “reflection is slow” complaint is really two complaints: reflection forces allocation (the boxed any, the reconstructed Method.Type), and reflection loses static knowledge (the compiler knows your struct’s field offsets; reflect has to look them up).

Use reflection at API boundaries, in tests, in serializers, in dependency injection glue — places where the types genuinely aren’t known at compile time.

Trade-offs

What the shared descriptor buys:

  • One copy of the truth. The runtime and reflect cannot disagree about a type’s size, alignment, or pointer layout, because there is only one description of it.
  • Small binaries. Every type is one descriptor in read-only data. reflect adds no parallel metadata.
  • The GC gets what it needs for free. GCData is in the same structure.

What it costs:

  • The descriptor serves everybody. Equal is a function pointer so == works for non-comparable types; GCData has a mode flag for types whose pointer mask is computed on demand.
  • Layout access is unsafe. Everything past the simple accessors is unsafe.Pointer casts and offsets.
  • The flag word is a compromise. Caching the Kind makes v.Kind() a single instruction, but a Value can be constructed wrong. reflect guards with mustBe, mustBeExported, and a pile of panics.
  • Method lookup rebuilds signatures. There is no pre-built signature to point at.

Caveats

The Type interface is not the runtime’s Type. reflect.Type is an interface with about forty methods; abi.Type is a struct. They are related by an unsafe.Pointer cast, not by Go’s type system.

reflect.TypeOf and reflect.ValueOf are not symmetric. TypeOf returns nil for a nil interface; ValueOf returns the zero Value, which panics if you call most methods on it.

The Kind cache in the flag word can go stale in principle. It can’t in practice, because a Value’s type never changes — but a hand-constructed Value with a wrong flag word misbehaves in confusing ways.

Unexported fields behave oddly. v.Field(i) on an unexported field returns a Value with flagStickyRO set: readable with Int(), String() and friends, but Interface() and Set() panic.

Method(i) only returns exported methods for non-interface types, and NumMethod reports the exported count.

reflect.Type values are comparable but reflect.Value values are not. == on two Values compares the struct fields, not the values they represent.

Wrapping up

The one thing to remember: reflect is not a parallel type system. It is a set of accessors over the compiler-emitted abi.Type descriptors, and reflect.Value is a (typ, ptr, flag) triple whose flag word carries the kind, the indirection, the addressability and the read-only state in ten bits. Once you see that, “reflection is slow” becomes a specific claim: reflection allocates, and it looks things up at runtime.

The part people get wrong: they assume reflect.TypeOf has to look anything up. It doesn’t. Boxing a value into an any already put a pointer to the type descriptor in the interface header; TypeOf reads it back out. The expensive parts of reflection are the ones that have to build something — a Method’s signature, an argument frame, a boxed result.

What to go do about it: next time you reach for reflect in a hot path, check whether you actually need it. reflect.TypeOf(x) == reflect.TypeOf(y) is a pointer comparison and costs nothing. A type switch is cheaper still. Reserve reflect.Value.Call and reflect.Type.Method for boundaries where the types are dynamic.

Further reading

  • src/internal/abi/type.go — the Type struct, TFlag bits, UncommonType, Method
  • src/internal/abi/iface.go — ITab, EmptyInterface, NonEmptyInterface
  • src/reflect/type.go — rtype, the Type interface, toType, TypeOf, Method
  • src/reflect/value.go — the Value struct, the flag bitfield, packEface, unpackEface
  • src/runtime/iface.go — getitab and the itab cache
  • src/runtime/type.go — the runtime’s own view of the same descriptors
  • src/cmd/compile/internal/reflectdata/reflect.go — where the compiler emits them

GoInternalsReflectionRuntimePerformance