Reflection: inspecting types at runtime
You are building a configuration loader. You want a single function, LoadConfig(filename, target), that reads a file and fills in a struct. The problem is that your application has dozens of config structs: DatabaseConfig, ServerConfig, CacheConfig. Each has different fields. Without reflection, you write a separate function for every struct, or you write a giant switch statement that breaks whenever you add a new type.
Reflection solves this. It lets your code look inside a struct at runtime, find the fields, and set their values without knowing the struct's type when you wrote the code. You write the loader once, and it works for any struct you pass in.
Reflection is the ability to inspect and modify types, values, and structures while the program is running. Go provides this through the reflect package. It is powerful, but it comes with a cost. Reflection bypasses compile-time checks, runs slower than direct code, and makes programs harder to read. Use it when you need to handle unknown types, like in serialization libraries or dependency injection frameworks. Avoid it in performance-critical paths or when generics can do the job.
The interface bridge
Go is statically typed. The compiler knows the type of every variable at compile time. This allows the compiler to generate fast machine code and catch errors early. Reflection works by erasing that static type information and replacing it with runtime metadata.
The bridge is interface{}. When you pass a value to a function that accepts interface{}, the compiler packs the value into a runtime structure containing the concrete type and a pointer to the data. Reflection digs into that structure. reflect.ValueOf takes an interface{} and returns a reflect.Value that holds the runtime representation. From there, you can query the type, read fields, call methods, and modify values.
Think of a standard Go variable like a labeled box. The label says "Person", and the compiler knows exactly how to handle it. Reflection is like opening the box, reading the label, checking the contents, and rearranging them, even if you didn't know what was inside when you wrote the code. Opening boxes takes time. The compiler can't optimize reflection calls because it doesn't know what type will arrive at runtime.
Minimal example
Here is the simplest reflection: inspect a struct and print its fields.
package main
import (
"fmt"
"reflect"
)
type Person struct {
Name string
Age int
}
func main() {
p := Person{Name: "Alice", Age: 30}
// reflect.ValueOf wraps the value for runtime inspection.
v := reflect.ValueOf(p)
// Type() returns metadata like field names and types.
t := v.Type()
fmt.Printf("Type: %s\n", t.Name())
// NumField returns the count of fields in the struct.
for i := 0; i < v.NumField(); i++ {
// Field(i) gets the i-th field. Interface() extracts the value.
fmt.Printf("Field %s: %v\n", t.Field(i).Name, v.Field(i).Interface())
}
}
The output shows the type name and each field's value. reflect.ValueOf creates a wrapper. Type() gives access to the structure. NumField and Field(i) iterate over the fields. Interface() converts the reflection value back to an interface{} so you can print it.
Reflection turns static types into runtime data. You trade safety for flexibility.
Type versus Kind
A common source of confusion is the difference between Type and Kind. Type is the exact type, including custom names. Kind is the underlying category.
If you define type MyInt int, the Type name is "MyInt", but the Kind is reflect.Int. Reflection code usually checks Kind to decide how to handle a value. You don't care if the type is int, int64, or MyInt when you want to add numbers; you care that the underlying kind is an integer.
Kind returns constants like reflect.Struct, reflect.Slice, reflect.Map, reflect.Ptr. These correspond to the basic building blocks of Go types. When writing generic reflection code, branch on Kind, not Type.
Modifying values and addressability
Reading values is straightforward. Modifying values requires care. Reflection respects Go's visibility rules and memory model. You cannot modify a value unless it is "addressable."
Addressability means the value lives in a place where you can take its address. Variables are addressable. Values returned by functions are not. Struct fields are addressable only if the struct itself is addressable.
Here is a function that zeroes out all integer fields in a struct. It demonstrates the pointer requirement and CanSet.
package main
import (
"fmt"
"reflect"
)
type Config struct {
Host string
Port int
}
// ZeroInts sets all int fields in a struct to zero.
func ZeroInts(cfg interface{}) {
v := reflect.ValueOf(cfg)
// Elem() dereferences the pointer to access the struct fields.
elem := v.Elem()
for i := 0; i < elem.NumField(); i++ {
field := elem.Field(i)
// CanSet checks if the field is exported and addressable.
if field.Kind() == reflect.Int && field.CanSet() {
field.SetInt(0)
}
}
}
func main() {
c := Config{Host: "localhost", Port: 8080}
fmt.Printf("Before: %+v\n", c)
// Pass &c to allow modification of the struct in place.
ZeroInts(&c)
fmt.Printf("After: %+v\n", c)
}
The function takes interface{}. Inside, reflect.ValueOf(cfg) wraps the pointer. Elem() dereferences the pointer to get the struct value. Without Elem(), you would be inspecting the pointer itself, not the struct. The loop checks CanSet(). This returns true only if the field is exported and addressable. If you pass a value instead of a pointer, CanSet() returns false, and SetInt panics.
Pass pointers to reflection functions that modify data. The copy trap catches everyone once.
Pitfalls and runtime panics
Reflection code can panic in ways that compile-time errors would normally prevent. The runtime checks are strict.
Calling reflect.ValueOf on a nil interface panics. Always check for nil before wrapping. If you pass a nil pointer, ValueOf returns a valid Value representing the nil pointer, which is safe. The panic happens only with a nil interface.
Trying to modify an unexported field panics with panic: reflect: Set of unexported field. Reflection cannot bypass privacy. You can read unexported fields, but you cannot set them. This preserves encapsulation.
Calling Interface() on an unexported field also panics. The runtime prevents leaking private data through reflection. If you need to extract a value, ensure the field is exported.
If you call a method via reflection that doesn't exist, you get panic: reflect: call of reflect.Value.Method on zero Value. Check NumMethod and method names before calling.
Reflection respects privacy. You cannot hack your way into private fields.
Performance cost
Reflection is slow. The compiler cannot inline reflection calls. It cannot optimize type checks. Every reflection operation involves dynamic dispatch and bounds checking.
In a tight loop, reflection can be orders of magnitude slower than direct code. If you are processing millions of records, reflection will dominate your CPU time. Profile your code. If reflection shows up in the flame graph, refactor.
Generics, introduced in Go 1.18, often replace reflection. Generics provide type safety and performance closer to direct code. Use generics when you can constrain types to a known set. Use reflection only when you truly need to handle arbitrary types.
Convention and style
The Go community treats reflection with caution. Code that uses reflection is harder to read and harder to maintain. If you write reflection, document why. Explain the trade-off.
The reflect package follows standard naming conventions. Functions start with capital letters if exported. Receiver names are short. gofmt formats reflection code just like everything else. Don't fight the formatter. The verbosity of reflection is real; the formatter keeps it readable.
When writing reflection code, prefer reflect.Value methods over reflect.Type methods when possible. Value gives you both type and data. Type gives only metadata. Using Value reduces the need to juggle two objects.
Reflection is a sledgehammer. Use it when you need to break walls, not to crack nuts.
When to use reflection
Choose the right tool for the job. Reflection is one option among many.
Use reflection when you are writing a library that must handle arbitrary types, such as a JSON encoder, a database mapper, or a validation framework.
Use reflection when you need to inspect struct tags for configuration mapping or dependency injection.
Use generics when you can constrain types to a known set; generics are faster, safer, and easier to read than reflection.
Use type switches when you have a small, fixed list of types to handle.
Use interfaces when you only care about behavior, not structure.
Generics are the default. Reflection is the escape hatch.