- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a Go file in a service you inherited. There's a helper
called mapStructToRow and it takes an any. Inside, forty lines
of reflect.ValueOf, NumField, a type switch on Kind(), and a
tag parser that reinvents half of encoding/json. It works. It also
shows up hot in every CPU profile you've run this quarter.
Reflection in Go is the escape hatch out of the static type system.
It lets you write code that inspects and manipulates values whose
types you don't know at compile time. That is genuinely handy for a
small set of problems. It is also slow, it moves errors from compile
time to runtime, and it turns code that a reader could follow into
code that only the runtime understands. The skill is knowing the few
cases where the trade is worth it, and reaching for something cheaper
everywhere else.
Reach for a type switch first
Most code that ends up using reflect didn't need it. It needed a
type switch. If you know the finite set of types you care about, Go
gives you a first-class way to branch on them, and it's checked by
the compiler.
func describe(v any) string {
switch x := v.(type) {
case int:
return "int: " + strconv.Itoa(x)
case string:
return "string: " + x
case fmt.Stringer:
return "stringer: " + x.String()
default:
return "unknown"
}
}
The type switch is fast. Each case is a type comparison the runtime
does cheaply, and the variable x comes out already typed, so the
rest of the branch is ordinary Go. No reflect.Value, no boxing
back and forth, no interface{} gymnastics.
The rule of thumb: if you can write down the list of types on one
hand, use a type switch. Reflection earns its place only when the
set of types is open — when your code has to work with structs it
has never seen, defined in packages that don't exist yet.
The first justified case: serializers over unknown structs
This is the canonical reason reflect exists. encoding/json,
encoding/xml, a CSV row mapper, a config loader, a database row
scanner — all of them take a struct they were never compiled
against and walk its fields at runtime.
You can't type-switch your way out of this. The whole point is that
the struct is the caller's, not yours. You have to read its fields,
its tags, and its kinds dynamically.
func fieldTags(v any) map[string]string {
out := map[string]string{}
rv := reflect.ValueOf(v)
if rv.Kind() == reflect.Pointer {
rv = rv.Elem()
}
if rv.Kind() != reflect.Struct {
return out
}
rt := rv.Type()
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
if tag, ok := f.Tag.Lookup("db"); ok {
out[f.Name] = tag
}
}
return out
}
That's the shape of every reflection-based mapper: get the Type,
count the fields, read each StructField, pull its tag. When you're
writing a library that has to serialize arbitrary user structs, this
is the correct tool. database/sql scanning, ORM row hydration, and
generic config decoders all live here for a good reason.
Note the guardrails already showing up: the pointer check, the kind
check, the early return. Reflection code needs those because the
compiler stopped helping you. Any of them missing is a runtime panic
waiting for the wrong input.
The second justified case: generic-ish glue at a boundary
The other honest use is glue code at the edge of a system where the
types genuinely aren't known until runtime. A plugin registry that
constructs handlers by name. A dependency injector that wires
constructors together. A test helper that fills a struct with sample
values. These sit at a boundary, they run once at startup, and they
deal with types the code was not compiled against.
func zeroValue(t reflect.Type) any {
return reflect.New(t).Elem().Interface()
}
// build a fresh instance of a registered type by name
func (r *Registry) New(name string) (any, bool) {
t, ok := r.types[name]
if !ok {
return nil, false
}
return zeroValue(t), true
}
Two things make this defensible. It runs at wiring time, not on the
request hot path, so the cost is paid once. And there is no static
alternative — the whole feature is "construct a type chosen at
runtime," which the type system can't express ahead of time.
Since Go 1.18, generics ate a chunk of what people used to reach for
reflection to do. A Map[K, V], a Filter[T], a typed container —
those are compile-time now, with no reflect in sight. If your
"generic" helper has a fixed type parameter you could name, generics
are the answer. Reflection is for the residue that generics can't
reach: open type sets discovered at runtime.
The cost, measured honestly
Reflection isn't free, and the cost has three parts. Benchmark your
own case before deciding, but know what you're paying for.
The runtime cost. A reflective field access does work a direct field
access doesn't: it looks up type metadata, checks kinds, and often
boxes values back into interface{}, which can allocate. A struct
walk over reflect is materially slower than reading the fields
directly, and it shows up as both CPU time and heap allocations
under load. Write a testing.B for the hot path and compare the two.
func BenchmarkDirect(b *testing.B) {
u := User{ID: 7, Name: "ada"}
for b.Loop() {
_ = u.ID
_ = u.Name
}
}
func BenchmarkReflect(b *testing.B) {
u := User{ID: 7, Name: "ada"}
rv := reflect.ValueOf(u)
for b.Loop() {
_ = rv.Field(0).Interface()
_ = rv.Field(1).Interface()
}
}
Run it with -benchmem. The allocation column is usually the story:
Interface() boxes each field, and boxing is where the garbage
collector pressure comes from. (b.Loop() is the Go 1.24 benchmark
form that keeps the loop from being optimized away.)
The safety cost. Every reflect call that could be wrong is a
panic, not a compile error. Field(99) on a two-field struct, a
Set on an unexported field, a kind mismatch — all runtime. You
trade the compiler's help for a stack of your own guard clauses,
and any gap is a crash in production instead of a red squiggle in
your editor.
The readability cost. The next person to read reflect.ValueOf(v). has to hold the whole runtime model in
Elem().Field(i).Interface()
their head to know what it does. Direct field access reads itself.
That cost is real even though no profiler measures it.
The alternative: generate code instead
When you need the dynamic behavior on a hot path, the move is to
push the reflection to build time. Instead of walking a struct with
reflect on every request, generate the exact, boring, direct-access
Go once, and run that.
This is what the fast serialization libraries do. easyjson and
similar tools read your struct with go generate and emit a
MarshalJSON that touches fields directly — no reflection at
runtime. The standard stringer tool does the same thing for enum
names. You keep the "works over arbitrary structs" ergonomics at the
tool level, and the runtime gets plain code.
//go:generate go run ./cmd/genmapper -type=User
// The generated file has no reflect. Just:
func (u User) Row() []any {
return []any{u.ID, u.Name, u.Email}
}
The generated function is faster than the reflective one, it can't
panic on a bad field index, and a reader can follow it. The cost
moves to your build: a go generate step, a checked-in generated
file, and a code generator to maintain. For a library on a hot path,
that trade is usually right. For a config loader that runs once at
boot, it usually isn't — the reflection there is fine, because the
cost is paid a single time and nobody profiles startup.
A decision you can keep in your head
Walk down this list and stop at the first match:
- Do you know the finite set of types? Use a type switch.
- Is the type parameter something you could name? Use generics.
- Is it truly open types, but off the hot path (startup, config, wiring)? Reflection is fine. Add the guard clauses.
- Is it open types on a hot path (a serializer, a per-request mapper)? Push the reflection to build time with code generation.
Reflection is a real tool in the Go toolbox, and the standard
library's best packages use it well. The mistake is reaching for it
first, when a type switch would have been faster, safer, and
readable — or reaching for it on a hot path where generated code
would have paid for itself the first week in production.
Reflection sits right on the seam between Go's static core and its
dynamic edges, which is exactly the kind of thing The Complete
Guide to Go Programming digs into — how reflect, interfaces, and
the type system actually fit together at runtime. And when the
reflective glue starts leaking into your domain logic, Hexagonal
Architecture in Go is about keeping it penned in at the boundary
where it belongs, instead of letting it spread through the core.

Top comments (0)