The previous chapters in this section built up reflection from the ground floor: what reflect.Type and reflect.Value are, how to walk struct fields, how to parse tags, how to call methods and functions dynamically, and what all of that costs. This chapter is about the decisions you make once you have the mechanics: where reflection genuinely earns its complexity, where it causes problems for code that leans on it too hard, and the patterns that keep production code stable.
Reflection is justified when the alternative is worse: hand-written marshallers for every type, repeated scan boilerplate for every query, validation logic that lives in code instead of tags. The standard library and the most popular third-party packages use reflection in a handful of well-defined places, and those places share a property. The code calling the reflective machinery doesn't know the concrete types ahead of time, but the caller does.
The list below is the short version of "places where reflection is the right answer." Each row trades CPU and some safety for the ability to write generic code once and have it work for every type a user throws at it.
| Use case | Why reflection wins | Example libraries |
|---|---|---|
| Serialization (JSON, gob, XML, YAML) | One marshaller handles every user type | encoding/json, encoding/gob, encoding/xml, gopkg.in/yaml.v3 |
| ORM and row scanning | Map result columns into arbitrary structs | jmoiron/sqlx, gorm.io/gorm, ent |
| Validation | Declarative rules live on struct tags | go-playground/validator |
| Dependency injection containers | Resolve dependencies by type at startup | uber-go/dig, uber-go/fx |
| Test assertions | Deep equality across arbitrary shapes | reflect.DeepEqual, stretchr/testify |
| Deep copy utilities | Walk a value's full graph once | jinzhu/copier, custom helpers |
| Configuration binding | Map env vars or YAML keys to struct fields | spf13/viper, caarlos0/env |
The pattern that holds these together is "library author writes the reflective code once, user writes a plain struct." That asymmetry is the whole game. If the same person is writing both sides, generics, interfaces, or codegen are almost always cleaner. We'll come back to that distinction at the end of the chapter.
encoding/json is the canonical example. The package doesn't know your Order struct exists; it discovers fields, types, and tags at runtime and produces JSON without any registration step. encoding/json reads the json:"name,omitempty" tags using the reflect.StructField.Tag API.
encoding/gob and encoding/xml follow the same model. They walk the value with reflect.Value, look up tags on each field, and produce bytes. The reason this is reflection's strongest argument: writing a hand-rolled marshaller for every struct in a real codebase would be hundreds of lines of duplicated code, all of it stale the moment someone adds a field.
Cost: encoding/json caches the per-type field map on first use, so the second Marshal of the same type is much cheaper than the first. If you're encoding millions of records on a hot path, generators like easyjson or ffjson skip reflection entirely and produce code that's 3 to 5 times faster.
The sqlx library's Get and Select methods use reflection to match SQL columns to struct fields by db tags. Without reflection, every query result has to be scanned by hand: rows.Scan(&p.ID, &p.Name, &p.Price, &p.Stock) for each row, kept in sync with the SELECT list by humans. With reflection, the library reads column names from the rows metadata, walks the struct fields, and assigns each column to the matching field automatically.
GORM goes further: it uses reflection to infer the table name from the struct name, build INSERT and UPDATE statements from struct fields, and resolve foreign-key relationships from tags like gorm:"foreignKey:CustomerID". The cost is that GORM's generated SQL is harder to predict than a hand-written query, which is why some teams prefer sqlx once their queries stop being trivial CRUD.
The validator/v10 package is built around struct tags that describe rules. The library reads the tags with reflection and runs each rule against the matching field. The user writes a tag, not a function.
When validate.Struct(c) runs, the library walks every field, parses the tag, looks up the named rules in its registry, and applies them. Tag-driven validation is popular precisely because the rules sit next to the data they describe. The trade-off is that a typo in a tag (requrired instead of required) becomes a runtime error, not a compile error. Unit tests catch those, but they're a real failure mode.
Uber's dig and fx use reflection to wire up a graph of dependencies. You register constructors (func() *Database, func(*Database) *OrderRepo), and the container inspects each function's parameter and return types with reflect.Type to figure out the order to call them in.
The container sees that OrderService needs *OrderRepo, OrderRepo needs *Database, and Database needs nothing. It calls them in topological order. This is one of the few legitimate uses where reflection runs at startup once, then the program never touches reflect again on the hot path. Whether DI containers are worth the cost over plain constructor wiring is a separate debate; the point is that reflection makes the pattern possible.
reflect.DeepEqual is the standard library's answer to "are these two values equal in every field, every map key, every slice element?" Without it, you'd write field-by-field comparisons for every struct in your tests. The function works on values of any type and recurses through slices, maps, structs, pointers, and channels.
testify/assert and cmp (from google/go-cmp) build richer comparisons on top. cmp produces a diff instead of a yes/no, which makes large struct mismatches readable. Both are reflection underneath.
A common chore in a service is mapping environment variables to a config struct. Without reflection, you write cfg.Port, _ = strconv.Atoi(os.Getenv("PORT")) for every field. With reflection, you describe the mapping in tags and let one function fill the struct.
The example below is a small env-to-struct binder. It walks struct fields, reads each env:"NAME" tag, looks up the value with os.Getenv, parses it according to the field's kind, and assigns it. It's not a finished library, but it's a faithful sketch of what caarlos0/env and similar packages do.
The shape is the same as json.Unmarshal: take a pointer, dereference to the struct, walk fields, read tags, convert strings into the right kind, and write back. Real libraries add support for slices, time durations, defaults, and required-flag tags, but the core is what you see here.
The mechanics of reflection are reasonable. The pitfalls almost always come from edge cases at the boundary: nil values, unexported fields, pointer vs value semantics, and misunderstanding what Elem does. The standard library is forgiving in some places and ruthlessly strict in others, which is what makes reflection feel inconsistent. The rules are consistent, but you have to know them.
Unexported fields can be read with reflect.Value.Interface() only if you don't go through reflection (Go's regular dot access works because the compiler enforces visibility). Through reflection, unexported fields can be inspected (field.Name works) but not modified, and in some cases not even read via Interface() without a panic.
The cleanest fix is "never write reflection code that touches unexported fields." Library authors who need to read every field of arbitrary structs (some testing frameworks, deep-copy utilities) sometimes use unsafe to bypass the rule, but that's a rabbit hole that breaks go vet, the race detector, and most static analyzers. Don't do it unless you have a specific reason and you've thought through every consequence.
This one trips experienced Go developers, never mind newcomers. An interface in Go is two words: a type descriptor and a value pointer. A nil interface has both words zero. An interface that holds a nil pointer to a concrete type has a non-nil type descriptor and a nil value. The two are not equal, and the standard == nil check fires on the first but not the second.
A truly nil interface produces an invalid reflect.Value whose Kind() is reflect.Invalid. Calling almost any method on it panics. An interface holding a typed nil pointer produces a valid Value whose Kind() is Ptr and whose IsNil() is true. The two cases need different handling.
The safe pattern when accepting an any parameter is to check IsValid first, then Kind, then IsNil (only for kinds where it applies: Ptr, Interface, Slice, Map, Chan, Func).
To write to a field on a struct, the reflect.Value you start with must be a pointer to the struct. Then Elem() unwraps the pointer, and the resulting Value represents the struct itself, which has settable fields. Skip the pointer and you get a copy that you can read but never modify.
The rule is "pointer in, Elem() to get inside, then write." Skip either step and the write either panics or silently modifies a copy that nobody else sees. The pitfall is forgetting it under pressure or when you've written a helper that takes any and doesn't enforce "pointer to struct" up front.
A safer helper signature looks like this:
This is what production-grade reflection code looks like. Most of the function is guard rails. The actual assignment is one line at the bottom. The guards exist because every one of those checks corresponds to a panic that would otherwise happen at runtime in a customer environment.
Two types that "look the same" are not always the same in Go's type system. A named type (type CustomerID int64) is distinct from its underlying type (int64). A pointer type (*Order) is distinct from the value type (Order). Reflection makes these distinctions visible, and code that assumes "they're basically the same" gets a runtime error.
AssignableTo is what Set requires. ConvertibleTo is what an explicit Convert would allow. They're different, and getting them mixed up produces "cannot assign int64 to type main.CustomerID" panics that are obvious in hindsight and infuriating in real time. When a generic helper takes any and wants to assign it into a struct field, the helper should either require exact type match, call Convert explicitly when types are convertible, or fail with a clear error. Silent conversion is rarely what you want.
Reflection lookups are ten to a hundred times slower than direct code, and re-walking a type on every call is the easiest way to make a reflective helper unusable in production. The pitfall isn't using reflection; it's using it without caching what doesn't change.
The fast version pays the reflection cost once per type and answers every subsequent call with a cached field index. encoding/json, sqlx, and validator all do something similar. If you write reflective code that gets called in a tight loop, the cache is not optional.
Cost: A reflective call without caching can easily be 100 ns per invocation. A million calls per second of request traffic is then 100 ms of pure reflection overhead. Cache the per-type metadata and you're back to single-digit nanoseconds per call.
This is the section most reflection tutorials skip, and it's the most important one. The fact that reflection can do something doesn't mean it should. Three alternatives almost always beat reflection when they apply: generics, interfaces, and code generation.
The tree above is a rough decision aid, not a law. The middle of the diagram is where most real cases land.
Generics (Go 1.18+) replace many old reflection use cases. Pre-generics, a function that summed any numeric slice had to use reflect.Value to walk the slice and Kind to dispatch. Today it's a one-liner:
Type-safe, no reflection, full inlining. The compiler generates specialized code per type.
Interfaces solve the "different types, same behavior" case. If every type you handle implements Marshal() ([]byte, error), you don't need reflection. You write func Save(m Marshaler) error and accept anything that satisfies the interface. Reflection becomes necessary only when types don't share a method, and the library has to discover behavior at runtime.
Code generation wins when reflection's cost matters. sqlc reads SQL files and generates type-safe Go code for each query. easyjson reads struct definitions and generates marshallers that don't touch reflect. protoc-gen-go does the same for Protocol Buffers. The runtime cost is zero because the work happens at build time. The trade-off is an extra step in the build pipeline and generated files that must be regenerated when the source changes.
Reflection is the right answer when none of the above fit: arbitrary user types, dynamic field access by name, tag-driven behavior, and you can't or won't generate code. That set is narrower than people assume.
Reflection bugs feel different from normal Go bugs. Compile errors stay quiet; runtime panics show up in stack traces with reflect.flag.mustBeAssignable or reflect: call of reflect.Value.Field on string Value, and you have to translate those back into "what was I actually trying to do." A few habits help.
Print `Type` and `Kind` first. Before any Field, Elem, or Set call, log what you have. Half of reflection bugs are "I thought I had a pointer to struct but I have an interface holding a slice."
%T and %v are your other friends. fmt.Printf("%T\n", x) prints the concrete type of an interface value, no reflect.TypeOf needed.
Walk the chain step by step. A common pattern is reflect.ValueOf(ptr).Elem().FieldByName("X").Set(...). When this panics, you can't tell which step failed. Break it apart while debugging:
The first step that prints something unexpected is the bug.
Use `recover` carefully. A defer func() { recover() }() around reflective code catches panics, but it also hides bugs. Use recover in reflection to convert a known-recoverable panic into a structured error after you've confirmed the inputs were validated upstream. Wrapping all of reflect in a blanket recover hides genuine type mismatches.
This is a backstop, not a substitute for the checks in setField above. If safeSet is the only thing keeping your code from crashing, the underlying helper has bugs that should be fixed at the source.
Test the failure paths. Reflective helpers tend to grow guard clauses for unsupported types, nil inputs, and mismatched kinds. Write a test for each. It's the only way to confirm the helper fails cleanly instead of panicking when a user passes the wrong shape a year from now.
To tie the chapter together, here's a small deep-copy helper for an Order value. It uses everything we've covered: Type, Value, struct walking, pointer handling, and dynamic assignment. The use case is "I have an order in memory, I want a fresh copy that shares no mutable state with the original so subsequent updates can't accidentally affect the cached version."
The original is untouched after the copy is modified. The helper handles structs by allocating a new value and recursing on each field, slices by allocating a new backing array and recursing on each element, maps by recreating the map with new keys and values, and primitives by relying on Go's value-copy semantics.
It's not a complete deep-copy library. It doesn't handle unexported fields (it can't, without unsafe), doesn't deal with cycles (a struct containing a pointer back to itself would loop forever), doesn't preserve pointer aliasing (two fields pointing at the same value get independent copies), and uses no caching. A production library like jinzhu/copier adds all of those. The skeleton above is enough to show what reflection makes possible: one function that copies anything, where without reflection you'd need a hand-written Clone method on every type.
10 quizzes