In the previous article we watched the runtime rebuild an entire stack trace out of metadata the compiler and linker had frozen into the binary at build time. I told you at the end that reflect works on exactly the same trick — metadata baked into the binary, only pointed at your data instead of your call stack. Today we’re going to cash that promise in.
Let’s start with a program that, the first time you see it, feels like it shouldn’t be possible:
package main
import (
"fmt"
"reflect"
)
type User struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
age int
}
func main() {
t := reflect.TypeOf(User{})
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fmt.Printf("%-6s %-8s %q\n", f.Name, f.Type, f.Tag)
}
}
Run it and out comes:
Name string "json:\"name\""
Email string "json:\"email,omitempty\""
age int ""
Field names. Field types. Even the struct tags, character for character. At runtime.
Now sit with how strange that is. Go is statically typed and compiled to native machine code. There’s no virtual machine keeping class objects around, no interpreter that still has your source code in hand. By the time this program runs, User was supposed to be gone — boiled down to 40 bytes sitting in your memory and nothing more. The names Name, Email, and age are things you wrote for humans; the CPU never needed them. So where is this information coming from?
That’s the whole question for today, and the answer has a satisfying shape: the reflect package doesn’t compute any of this. It just reads it. The real work happened weeks ago, on your machine, when you typed go build.
A note on scope: This article focuses on the reading side of
reflect— inspecting types and values. The package can also build things at runtime — new structs, function values, slices, and so on — and that’s a whole story of its own that I’ll probably come back to in a future article.
But a claim like that deserves a proper trace through the source — so let’s start at the surface and dig down.
The Doorway: TypeOf and ValueOf
Most of our exploration starts at one of two doors: reflect.TypeOf
gives you a reflect.Type to ask questions about a type, and reflect.ValueOf
gives you a reflect.Value to inspect (and sometimes modify) an actual value. Both take an any as their argument. You’d expect the entry points of something as powerful as reflection to be where the magic happens — so let’s look at TypeOf:
func TypeOf(i any) Type {
return toType(abi.TypeOf(i))
}
Hmm, one level down then. Here’s abi.TypeOf
:
func TypeOf(a any) *Type {
eface := *(*EmptyInterface)(unsafe.Pointer(&a))
return (*Type)(NoEscape(unsafe.Pointer(eface.Type)))
}
And that’s it. No table lookup, no runtime call, no allocation. Take the address of the any parameter, reinterpret the memory sitting there as a struct called EmptyInterface, and return its first field. reflect.TypeOf is, at bottom, one pointer load.
Which can only mean one thing: the complete description of your type was already there, inside the any, before reflect ever got involved. The interesting part isn’t the function — it’s what an any actually is.
Inside an Interface
Here’s what that EmptyInterface struct looks like (src/internal/abi/iface.go
):
type EmptyInterface struct {
Type *Type
Data unsafe.Pointer
}
Every any in your program — every empty interface value — is exactly this: two pointers. The second one points at the data. The first one points at a type descriptor: a struct that fully describes the dynamic type of whatever was stored in the interface (we’ll get deeper into it in a moment). The runtime has its own mirror of this layout, called eface
.
So when you pass user to a function that takes an any — which is exactly what calling TypeOf is:
func TypeOf(i any) Type // the signature we just saw
TypeOf(user) // user becomes an any right here
the compiler emits code at the call site that stores two pointers: the address of User’s type descriptor, and a pointer to the value. That’s essentially the whole conversion — the data pointer just points at the value sitting somewhere in memory (on the stack or the heap), but the type pointer involves no lookup and no registration: it’s a constant address the compiler knew at compile time.
This reframes our two entry points completely. TypeOf and ValueOf don’t inspect your value at all. They read the interface header — the two pointers the compiler already put there.
But that just pushes the question one step back. The type pointer leads to a descriptor that knows field names and struct tags. Who built that descriptor, and where does it live?
The Compiler Did It
Here is the part I find genuinely beautiful: that descriptor is static data. The compiler built it, the linker placed it, and it’s sitting in a read-only section of your executable right now, before your program has run a single instruction.
When the compiler processes your package, a component called reflectdata
acts as a serializer for types. For every type that can end up in an interface, it emits a symbol named type:<your type> — type:main.User, type:[]string, type:map[string]int — whose raw bytes are the descriptor struct, laid out field by field (writeType
).
You don’t have to take my word for it. Compile the program from the top of the article and run strings on it:
$ go build -o hello .
$ strings hello | grep omitempty
json:"email,omitempty"
There’s your struct tag, verbatim, as bytes in the executable. It was never “kept alive at runtime” — it was compiled in, the same way a string literal is. Reflection’s deepest secret is that there’s no runtime machinery to speak of: it’s the compiler writing notes to its future self, and reflect reading them back with pointer arithmetic.
So far, though, we’ve treated each descriptor as one opaque blob — and to understand how reflect finds a field name inside it, we need to zoom in on the bytes.
How the Type Data Is Shaped
Each descriptor is a single contiguous run of read-only bytes, and the way the compiler shapes that run is half the cleverness of the whole design. It’s built out of layers:

The first layer is the same for every type in the binary, whether it’s an int, a channel, or your User: a compact header recording the type’s size and alignment, its kind, a precomputed hash so type switches never have to compute one, the pointer bitmask the garbage collector walks, the actual implementation of ==, and the type’s name. I won’t walk it field by field here — if you want to go deeper into the data structure behind everything in this article, check out the Type struct
in internal/abi, the package that acts as the contract between the compiler (which writes the bytes) and the runtime and reflect (which read them).
Now, that header is fixed-size, but each kind of type needs different extra information: a struct has a list of fields, a function has parameters and results, a slice just needs its element type. Where does all of that go? It can’t go inside the header — a fixed-size struct can’t hold variable-size data — and reserving room in every descriptor for every possibility would waste space on fields most types never use.
The compiler’s solution is a trick C programmers will recognize instantly: write the kind-specific data right after the header, in the very next bytes of the binary. A struct descriptor is the common header followed by its fields array; a slice descriptor is the same header followed by its element type; a function descriptor is the header followed by its parameters and results. Every descriptor starts the same way; what comes after depends on the kind.
The reading side mirrors the writing side. When reflect sees that the header’s kind field says Struct, it knows what shape the following bytes must have, and casts the whole region to an abi.StructType
— a struct that begins with the common header:
type StructType struct {
Type // the common header, embedded at offset 0
PkgPath Name
Fields []StructField
}
type StructField struct {
Name Name // name and tag, encoded together
Typ *Type // the field's own descriptor
Offset uintptr // where the field sits within the struct
}
That’s all t.Field(i) is doing under the hood in our example: cast the descriptor to *StructType, index into Fields. No lookup tables, no maps — the data was shaped at compile time so that reading it back is a cast and an index.
Methods use the same approach: if the type has any, their data is appended at the end of the descriptor, and reflect finds it the same way — by computing where the previous layer ends.
There’s one thing that deliberately doesn’t live inside the descriptor: the strings. Look at the type of StructField.Name — it’s not a string, it’s a Name
, a single pointer to a compact blob stored elsewhere in the same read-only region. That’s because names repeat constantly across a binary (how many types have a field called ID?), so the compiler emits each distinct name once and lets every descriptor point at the shared copy. And that blob holds both the field’s name and, right behind it, its tag — which is why StructField.Name is one field and not two.
Which closes the mystery from our opening program. The string json:"email,omitempty" that strings found in the binary? That blob is exactly where it was hiding. The tag travels from your source file, through the compiler’s serializer, into .rodata, and comes back out of an f.Tag call untouched.
Before we move on, let’s put the whole walk in one picture — every step our opening program takes, from the calls you wrote down to the bytes in the binary. We’ll follow the iteration where i is 1 — the Email field, the one with the tag:

Every call in our program is just following this picture. reflect.TypeOf reads the type pointer of the any; NumField reads the length stored in the fields slice header; Field(1) lands on one StructField; and f.Name, f.Tag, and f.Type follow its two pointers — one to the shared blob where the name and tag live side by side, the other to the field’s own type descriptor. Everything inside the dashed region was laid down by the compiler; the program is only reading.
Notice, though, that everything in that picture flows from the interface’s type pointer alone — the data pointer has been sitting there this whole time, untouched.
The Value Side
reflect.Value
is what happens when you finally care about that second pointer — and here’s the anticlimax: there is no new machinery on this side. A Value is little more than the two interface pointers held together (plus a few bookkeeping flags), and everything you do with it ends up walking the same data structures we just toured. ValueOf, at the end of the day, is simply using them with the data pointer in hand:

That picture is all v.Field(1) does: read Fields[1].Offset from the read-only descriptor — the offset the compiler serialized — and add it to the data pointer, landing on the Email bytes inside the real value. Same descriptor as before, one extra addition.
One famous gotcha follows directly from this picture: you can read anything through a Value, but you can only write through a pointer. Passing x into ValueOf copies it — that’s just how passing an any works — so writing through the Value would mutate a temporary nobody can ever see, and reflect refuses. Pass a pointer instead and the writes land on the real thing:
reflect.ValueOf(x).Field(0).SetString("Sam") // panics: not addressable
reflect.ValueOf(&x).Elem().Field(0).SetString("Sam") // works
And with that, we’ve met every moving part: the descriptors, the values, and the pointer arithmetic that joins them.
Reflect in Action
Let’s replay our opening program one more time, end to end, with everything we now know.
Before the program even runs, the hard part is done: the compiler serialized a descriptor for User — size, kind, GC bitmask, a fields array with encoded name-and-tag blobs — as plain bytes in a type:main.User symbol, and the linker packed it, with every other descriptor, into a read-only region of the executable. By the time main starts, everything reflect will ever tell us about User is already sitting in memory, mapped straight from the file.
The first thing main does is call reflect.TypeOf(User{}). Passing User{} as the any argument stores the two pointers we know about — the address of type:main.User and a pointer to the value — and TypeOf picks up the first one. From that moment, t points straight into the read-only memory of the executable.
Then the loop asks t.NumField(). Reflect checks the descriptor’s kind field, sees Struct, casts the descriptor to a StructType, and reads the length of its Fields slice: three.
On the first iteration, t.Field(0) indexes into the fields array sitting right behind the descriptor’s header and lands on Name’s StructField: a name pointer, a type pointer, and Offset = 0. f.Name and f.Tag follow that name pointer to the shared blob — the field’s name at the front, the tag bytes right behind it. And f.Type follows the field’s Typ pointer to type:string: another descriptor, same shape, same region. The next two iterations repeat exactly the same walk for Email and age, just one StructField further along the array each time.
And then fmt.Printf prints a line whose every character was read out of read-only pages of the executable. Nothing about User was ever computed at runtime — only located and decoded.
All that’s left is to step back and gather the takeaways.
Summary
That’s the same lesson as the stack traces article, told again with different metadata: the runtime’s most magical-looking features are mostly the compiler leaving very good notes. Reflection in Go isn’t a runtime capability bolted onto a compiled language — it’s the compiler serializing a complete description of every type into read-only data, and — at least on the reading side we explored here — a package that just knows how to walk it: one pointer load to find the descriptor, a cast to read the kind-specific layers behind it, and plain pointer arithmetic to reach names, tags, and field values.
In the next article, we’ll explore profiling — how the runtime watches your program while it runs, sampling what it’s doing and where it’s spending its time. After two articles spent reading metadata frozen at build time, we’ll flip the perspective: profiling is the runtime catching your program in motion.