Reflection vs Code Generation in Go
Go offers two ways to write generic-looking code without C++-style templates: inspect values at runtime with reflect, or generate typed code before the compiler runs.
Neither approach is universally better.
Reflection keeps libraries flexible and user code short.
Codegen keeps CPU predictable and errors compile-time.
SME teams succeed when they know which tool owns which layer of the stack.
Summary
- Reflection discovers type and field information while the program runs; code generation emits ordinary Go source (or files) so the compiler sees concrete types and calls.
- Insight: Serialization, validation, ORMs, and RPC frameworks all need to map Go types to wire formats. The choice affects latency, binary size, debuggability, and how often CI must rerun generators.
- Key Concepts:
reflect.Type,reflect.Value, struct tags,go generate, AST/template generators,go:fix inline, build tags. - When to Use: Reflection for plugin-style APIs, CLI tools, and one-off config parsing. Codegen for encoders, enum stringers, OpenAPI clients, and any handler on the request hot path.
- Limitations/Trade-offs: Reflection costs CPU and defeats some compiler optimizations; codegen adds build steps, merge conflicts in generated files, and generator maintenance.
- Related Topics: Struct tag parsing,
go generate, custom generators, ORM reflection costs, API migration directives.
Foundations
Go deliberately omitted a macro system and user-defined compile-time templates.
Instead, the language gives you struct tags (string metadata on fields), a small reflect package, and tooling hooks (go generate, go fix) that run before go build.
Reflection answers questions like "what fields does this struct have?" or "is this value a pointer to an int?" at runtime.
The encoding/json package is the canonical example: json.Marshal(v) walks v with reflection unless v implements json.Marshaler.
Code generation answers the same questions once, ahead of time.
Tools read your types (often via go/ast or go/types) and write .go files with explicit field assignments, switch cases, or String() methods.
The stringer tool generates enum string methods; protoc-gen-go generates protobuf structs and getters.
A useful analogy: reflection is an interpreter that reads a schema on every request; codegen is compiling that schema into a dedicated function.
Mechanics & Interactions
Both approaches usually start from the same source of truth: Go type definitions and struct tags.
type definitions + struct tags
|
+-----+-----+
| |
reflect go generate /
at runtime go fix / AST tools
| |
generic specialized
library .go files
logic compiled normally
At runtime, reflection walks reflect.Type (shape) and reflect.Value (data).
Libraries cache reflect.Type values in package-level maps so repeated work amortizes - but the first use and every field access still pays more than a direct field read.
Codegen inlines that walk into straight assignments or switch statements.
The compiler can dead-code eliminate, inline, and escape-analyze the result like hand-written code.
Struct tags sit in the middle.
Tags are compile-time constants attached to fields (json:"name,omitempty").
Parsers read them via reflect.StructField.Tag at runtime, or generators read them from AST and bake decisions into generated code.
go generate is the standard orchestration command.
A source file contains a directive such as //go:generate go run ./gen.go.
Running go generate ./... executes those commands; CI typically runs it before go test when generated files are committed.
go fix (Go 1.26+) adds modernizers including the inline analyzer driven by //go:fix inline directives - a form of migration codegen applied across call sites.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| Reflection | Flexible, no build step, small source footprint | Slower hot paths, runtime panics on misuse | Frameworks, config, admin tools |
| Codegen | Fast, compile-time errors, explicit code to grep | Generator upkeep, noisy diffs, build ordering | Encoders, ORMs on hot paths, large enums |
| Hybrid (reflect once, cache) | Balance for medium traffic | Cache invalidation, memory | Middleware, validation at startup |
| Hand-written only | Simplest for few types | Does not scale with type count | Tiny APIs, stable schemas |
Advanced Considerations & Applications
Production services often use reflection at the edges and codegen in the core.
An HTTP gateway might reflect request bodies into structs for occasional admin endpoints while protobuf/gRPC handlers use generated code from .proto files.
ORMs like GORM reflect struct tags into SQL at runtime; sqlc and ent generate type-safe query code instead.
For Kubernetes controllers, kubebuilder generates typed clients; reflection appears in generic serializers and scheme registration, not in tight reconcile loops.
Security: reflection on interface{} or map[string]any from untrusted input needs bounds checks.
Codegen that emits switch on known enums reduces the attack surface.
Observability: reflection-heavy handlers show up in CPU profiles under reflect.*.
Benchmark with testing.B and pprof before optimizing; often a targeted generator for one struct family is enough.
Version upgrades: when you bump Go, rerun go generate and go fix.
Generated files may change formatting; //go:fix inline migrations propagate API renames without manual sed.
TinyGo and WASM: reflection support is limited or costly on tiny targets.
Prefer codegen for embedded Go binaries.
Common Misconceptions
- "Reflection is always too slow for production" - Reflection on cold paths (startup validation, CLI flags) is fine. Problems appear when every RPC or row scan pays full reflection cost.
- "Codegen means abandoning the stdlib" -
encoding/jsonremains appropriate for many APIs; codegen supplements it where benchmarks demand it. - "Struct tags are only for JSON" - Tags are a general metadata channel for db, validate, yaml, and custom generators.
- "go generate runs automatically on go build" - It does not. Pipelines must invoke
go generateexplicitly or usego:generatein Makefile/CI. - "Reflection removes the need for interfaces" - Interfaces express behavior contracts; reflection inspects concrete shapes. They solve different problems.
FAQs
What is the main difference between reflection and codegen in Go?
Reflection inspects types while the program runs.
Codegen writes normal Go source before compile so the compiler sees fixed types and calls.
When should I prefer reflection?
- Types are not known until runtime (plugins, generic config)
- Code volume must stay small and build steps minimal
- The path is cold (startup, tests, admin)
When should I prefer codegen?
- Hot paths (serialization, DB scans, RPC per request)
- Large enums or many similar structs
- You want misuse to fail at compile time
Does encoding/json use reflection?
Yes, for default marshaling.
Types implementing json.Marshaler / json.Unmarshaler bypass reflection for that type.
What role do struct tags play?
Tags attach string metadata to fields.
Libraries read tags via reflection or generators to control naming, validation, and storage mapping.
Is go generate the same as reflection?
No.
go generate runs external commands (often generators) that emit source files.
Reflection is a runtime library package.
Can I combine reflection and codegen?
Yes.
Common pattern: generate repetitive boilerplate, use reflection for optional plugin registration or one-time schema discovery at startup.
What is //go:fix inline?
A directive on deprecated forwarding functions telling Go 1.26's inline modernizer to rewrite call sites to the new API automatically.
Why do ORMs use reflection?
To map arbitrary structs to tables without per-type hand-written SQL.
The flexibility costs CPU; sqlc/ent trade flexibility for generated queries.
How do I measure reflection cost?
Benchmark with testing.B, profile with pprof, compare against a generated or hand-written baseline on the same payload sizes.
Does reflection work in TinyGo?
Support is limited compared to the main Go compiler.
Embedded and WASM projects should prefer codegen or hand-written mappings.
What fails at runtime with reflection?
Invalid Set on unaddressable values, wrong Kind assumptions, and panics from Interface() on unexported fields across packages.
Related
- Reflection Basics - hands-on
Type,Value, and field iteration - Struct Tags & Custom Tag Parsing - JSON, db, and validate tags
- go generate & stringer - standard codegen workflow
- ORM & Serializer Reflection Costs - when hot paths need generators
- Reflection & Code Generation Best Practices - team rules for choosing approaches
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).