The midnight debugging session
You are writing a test helper that compares two structs field by field. You want to avoid writing a separate equality function for every type in your codebase. You import reflect, write a loop that iterates over struct fields, and run the tests. Everything passes. Two weeks later, you add the helper to a hot path in your production server. The CPU usage spikes. The profiler shows your helper chewing through cycles. You also notice the IDE stopped giving you autocomplete inside the function, and a typo in a field name now crashes the program at runtime instead of failing at build time.
This is the standard reflection trap. The feature works exactly as documented. It also quietly trades compile time guarantees for runtime flexibility, and the trade off rarely pays off in application code.
What reflection actually does
Reflection is the ability of a program to inspect and modify its own structure while it runs. In Go, every value carries a hidden type descriptor. Static typing means the compiler reads that descriptor once, builds a fixed layout in memory, and generates direct machine instructions to access fields. Reflection means you hand the value to the runtime, ask it to read the descriptor on the fly, and perform lookups by string names.
Think of static typing as walking through a house where every room has a permanent sign. You know exactly where the kitchen is. You walk straight there. Reflection is walking through the same house with a flashlight and a list of room names. You have to check every door, read the sign, and verify it matches your list before you step inside. The house is the same. The process is slower, and you can trip over a door that was renamed or locked.
The reflect package exposes two main handles: reflect.Type and reflect.Value. The type handle describes the shape. It tells you how many fields a struct has, what their names are, and what their underlying kinds are. The value handle holds the actual data. It lets you read field contents, call methods, and modify values if they are addressable. The package deliberately separates description from data because mixing them creates unsafe code paths.
A minimal example
Here is the simplest bridge between static and dynamic code. You pass a value into a function that knows nothing about its shape.
package main
import (
"fmt"
"reflect"
)
// InspectType prints the name and kind of any value.
func InspectType(v any) {
// Wrap the value so the runtime can read its type descriptor.
rv := reflect.ValueOf(v)
// Read the type metadata without touching the actual data.
rt := rv.Type()
// Print the Go type name and the underlying kind (struct, slice, etc.).
fmt.Printf("Type: %s, Kind: %s\n", rt.Name(), rv.Kind())
}
func main() {
InspectType(42)
InspectType("hello")
}
The any type is an alias for interface{}. It erases the concrete type at the call site and hands a type pointer plus a data pointer to the function. The reflect package reads those pointers and rebuilds a view of the value. The view is not the value itself. It is a wrapper that performs lookups on every method call.
Walking through the runtime
When you call reflect.ValueOf(v), the runtime allocates a new reflect.Value struct on the heap. It copies the type pointer and the data pointer from your original value. Every method you call on that reflect.Value performs a dictionary lookup against the type descriptor. Field access requires a string comparison against every field name in the struct. Method calls require building a new function signature at runtime and invoking it through a pointer.
The compiler sees any and stops optimizing. It cannot inline the function. It cannot prove the value is not nil. It cannot reorder operations for speed. The generated code falls back to dynamic dispatch, which means indirect jumps and bounds checks on every single access. In a tight loop, those checks add up. The CPU cache misses increase because the runtime jumps between scattered memory addresses instead of following a predictable path.
Calling .Interface() on a reflect.Value is another hidden cost. That method extracts the underlying data, reconstructs the original type pointer, and returns it as an any. If you call it inside a loop, you are forcing the runtime to allocate a new interface header on every iteration. The garbage collector has to track those headers, and the allocation pressure grows linearly with the loop count.
The community convention is to keep reflection behind a boundary. You accept interfaces, return structs. Reflection breaks that pattern by returning any and forcing callers to type assert. If you must use it, wrap it in a function that fails fast and returns a concrete type. Do not leak reflect.Value across package boundaries. Trust the type system when you can. Fight it only when you have to.
Realistic example: a config loader
Here is a common pattern where developers reach for reflection: a generic logger that formats arbitrary structs into key value pairs.
package main
import (
"fmt"
"reflect"
)
// FormatStruct converts a struct into a flat list of key value strings.
func FormatStruct(s any) []string {
// Get the reflection value and ensure it is a struct.
rv := reflect.ValueOf(s)
if rv.Kind() != reflect.Struct {
return nil
}
// Prepare a slice to hold the formatted pairs.
var pairs []string
// Iterate over every field defined in the struct type.
for i := 0; i < rv.NumField(); i++ {
// Read the field name from the type metadata.
name := rv.Type().Field(i).Name
// Read the actual value and convert it to a string.
val := fmt.Sprintf("%v", rv.Field(i).Interface())
pairs = append(pairs, fmt.Sprintf("%s=%s", name, val))
}
return pairs
}
func main() {
type Config struct {
Host string
Port int
}
fmt.Println(FormatStruct(Config{Host: "localhost", Port: 8080}))
}
The function works, but it hides several design choices. The Kind() check prevents panics on non struct inputs. The loop uses NumField() to avoid out of bounds access. The Interface() call extracts each field so fmt.Sprintf can format it. Each of those calls performs a runtime lookup. If you run this function ten thousand times per second, the lookup overhead becomes visible in your latency percentiles.
A better approach for production code is to define a small interface like Stringer or Loggable and implement it on the structs that need formatting. The compiler verifies the implementation. The runtime dispatch is a single vtable lookup. The performance difference is measurable. The readability difference is larger.
Where reflection breaks down
Reflection hides mistakes until the program runs. If you pass a nil pointer to reflect.ValueOf, the resulting value is zero. Calling .Interface() or .Field() on a zero value panics with reflect: call of reflect.Value.Interface on Zero Value. The compiler cannot catch this because it only sees an any type.
Field names are matched by exact string comparison. If you rename a struct field in your codebase but forget to update the reflection lookup string, the program crashes at runtime instead of failing at build time. The compiler rejects missing imports with undefined: reflect, but it will happily compile a reflection lookup for a field that does not exist. You only find out when the test runs or the server starts.
Performance cliffs appear when reflection sits inside loops. A single reflection call costs a few hundred nanoseconds. A loop running that call ten thousand times adds measurable latency. The Go runtime also cannot garbage collect values held inside reflect.Value as aggressively as it handles static variables, which can increase memory pressure.
Modifying values through reflection requires addressability. If you pass a struct by value, the runtime copies it. The copy is not addressable. Calling .Set() on a non addressable field panics with reflect: reflect.Value.Set using unaddressable value. You must pass a pointer, call .Elem() to dereference it, and verify the field is exported. Unexported fields start with a lowercase letter. The runtime refuses to touch them, and you get reflect: reflect.Value.Set using unaddressable value or a similar access denied panic. The language enforces visibility rules even through reflection.
Error handling with reflection is awkward. The package does not return errors for most operations. It panics. You have to wrap calls in recover() or validate every step manually. The standard if err != nil { return err } pattern disappears. You replace it with if !rv.IsValid() { return fmt.Errorf("invalid value") }. The boilerplate shifts from the compiler to your code. The verbosity increases. The safety decreases.
The worst reflection bug is the one that silently returns a zero value. If you ask for a field that does not exist, FieldByName() returns a zero reflect.Value. Calling .Interface() on it gives you the zero value for that type. Your program continues running with empty strings or zero integers. Debugging that requires tracing through runtime logs instead of reading a stack trace.
Keep reflection out of hot paths. Wrap it in initialization code. Cache the reflect.Type descriptors so you do not rebuild them on every request. The type descriptor is immutable. You can store it in a package level variable and reuse it. That single change often cuts reflection overhead by half.
When to reach for reflection
Use direct field access when you know the type at compile time and want the fastest possible execution. Use an interface when you need polymorphism and want the compiler to verify method signatures. Use generics when you need type parameters that the compiler can still optimize and inline. Use reflection when you are building a serialization library, a testing framework, or a dependency injection container where the types are unknown until runtime. Use manual mapping when performance matters and the number of types is small enough to write explicit converters.
Reflection is a tool for library authors, not application developers. The standard library uses it for encoding/json, encoding/xml, and database/sql. Those packages accept the performance cost because they solve a general problem. Your application code usually does not. Write explicit code. Let the compiler catch your mistakes. Reserve reflection for the few places where static typing truly cannot reach.