Common Patterns That Use Reflection in Go (JSON, ORM, DI)
You define a struct with a few fields. You pass that struct to json.Marshal. A string of JSON appears. You didn't write a loop to iterate over the fields. You didn't write a switch statement to handle every possible type. The library just knew what to do. That knowledge comes from reflection. Reflection lets Go programs inspect and manipulate types and values at runtime. It's the engine behind tools that save you from writing boilerplate, but it comes with costs you need to understand.
How reflection works
Think of reflection like an X-ray machine for your code. Normally, the compiler knows everything about your types before the program runs. It checks that you're not passing a string where an int is expected. Once the program starts, that type information is mostly gone. The runtime just moves bits around. Reflection brings the type information back. It lets a running program ask questions like "What are the field names of this struct?" or "What is the underlying type of this interface value?" or even "Can I set this field to a new value?"
The reflect package provides two main types. reflect.Type describes the blueprint: field names, tags, methods, and the kind of data. reflect.Value holds the actual data at runtime. You start with an interface value, pass it to reflect.ValueOf, and then you can query the type or modify the value.
Go convention treats reflection as a tool for library authors, not application developers. The community prefers explicit code. If you can solve a problem with an interface or a few lines of mapping code, do that. Reflection is slower, harder to debug, and breaks static type safety. Libraries like encoding/json use reflection because they must work with any struct the user defines. Your application code usually knows its own types.
Inspecting a struct
Here is a minimal example that reads field names and values from a struct.
package main
import (
"fmt"
"reflect"
)
type Config struct {
Host string `default:"localhost"`
Port int `default:"8080"`
}
func main() {
c := Config{}
// reflect.ValueOf converts an interface{} to a reflect.Value for inspection.
v := reflect.ValueOf(c)
// Type() gets the blueprint: field names, tags, and types.
t := v.Type()
// NumField returns the count of fields in the struct.
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
value := v.Field(i)
// Printf uses reflection internally to format %v, but here we are driving the loop.
fmt.Printf("Field: %s, Type: %s, Value: %v\n", field.Name, field.Type, value)
}
}
The code creates a Config struct. reflect.ValueOf(c) wraps the value. v.Type() extracts the type information. The loop runs t.NumField() times. Inside, t.Field(i) gives metadata like the name and tag. v.Field(i) gives the actual value. The output lists Host, string, empty string, and Port, int, 0. This is how libraries read your structs without you writing custom code for each one.
Reflection is a tool for libraries, not a crutch for application code.
Filling defaults from tags
Libraries often use reflection to apply configuration based on struct tags. This example shows a function that fills zero-valued fields with defaults defined in tags.
package main
import (
"fmt"
"reflect"
"strconv"
)
// FillDefaults sets struct fields to their tag-defined defaults if the field is zero-valued.
// This demonstrates reading tags and modifying values via reflection.
func FillDefaults(v interface{}) {
rv := reflect.ValueOf(v)
// Check if the argument is a pointer to a struct so we can modify it.
// Reflection can only set fields if the value is addressable.
if rv.Kind() != reflect.Ptr || rv.Elem().Kind() != reflect.Struct {
fmt.Println("Expected pointer to struct")
return
}
// Dereference the pointer to get the struct value.
rs := rv.Elem()
rt := rs.Type()
for i := 0; i < rs.NumField(); i++ {
field := rs.Field(i)
// CanSet checks if the field is exported and addressable.
// Unexported fields cannot be modified by reflection.
if !field.CanSet() || !field.IsZero() {
continue
}
tag := rt.Field(i).Tag.Get("default")
if tag == "" {
continue
}
// Set the value based on the field type.
switch field.Kind() {
case reflect.String:
field.SetString(tag)
case reflect.Int:
val, err := strconv.Atoi(tag)
if err == nil {
field.SetInt(int64(val))
}
}
}
}
type Server struct {
Host string `default:"localhost"`
Port int `default:"8080"`
}
func main() {
s := Server{}
FillDefaults(&s)
fmt.Printf("%+v\n", s) // Output: {Host:localhost Port:8080}
}
FillDefaults takes an interface{}. This is the price of reflection: you lose static type safety at the call site. Inside, we check Kind(). reflect.Ptr means it's a pointer. rv.Elem() dereferences it. We iterate fields. CanSet() is crucial. If you try to set an unexported field, the program panics. Tag.Get("default") reads the struct tag. We parse the string and set the value. The output shows the defaults applied. This pattern appears in ORMs, config loaders, and validation libraries.
Struct tags are the bridge between your code and reflection-based tools. Tags keep metadata close to the definition.
Real-world patterns
Three common patterns rely on reflection.
JSON marshaling and unmarshaling. The encoding/json package uses reflection to convert structs to JSON and back. When you call json.Marshal, the library checks the type. If it's a struct, it iterates over fields. It reads the json tag to find the key name. It checks omitempty to skip zero values. It recurses into nested structs and slices. Unmarshaling works in reverse. The library parses JSON, finds the matching field by name or tag, and sets the value. If the types don't match, it returns an error.
Object-Relational Mappers. ORMs like GORM use reflection to map structs to database rows. The struct name becomes the table name. Field names become column names. Tags override these defaults. The ORM inspects the struct to generate SQL queries. It reads field types to bind parameters correctly. It can also scan rows back into structs by matching column names to fields. This saves you from writing SQL for every model, but it adds complexity and hides database queries.
Dependency Injection. DI containers use reflection to resolve dependencies at runtime. You define structs with fields that other structs depend on. The container inspects the fields to find types. It instantiates dependencies recursively. It sets fields via reflection. This allows flexible composition, but it makes the dependency graph hard to trace. Go developers often prefer explicit dependency injection or code generation tools like wire that generate type-safe code at compile time.
If you reach for reflection, ask if an interface or code generation solves the problem better.
Pitfalls and errors
Reflection breaks the compiler's safety net. The compiler can't check your reflection code. You can crash at runtime.
The most common panic is panic: reflect: Set of unexported field or method. This happens if you try to modify a lowercase field. Go's visibility rules apply to reflection. Unexported fields stay private. Reflection respects visibility rules.
Another panic is panic: reflect: call of reflect.Value.Set on zero Value. This happens if you try to set a value that isn't addressable. Addressability means the value lives somewhere the runtime can write to, like a heap allocation or a variable on the stack. If you pass a literal or a read-only constant, reflection can't write to it. You must pass a pointer to a struct to modify fields.
Reflection also adds performance overhead. json.Marshal is slower than a hand-written serializer. Reflection allocates memory and checks types at runtime. For hot paths, avoid reflection. Use code generation instead. Tools like go generate can produce fast, type-safe code that does what reflection does, but at compile time. Protobuf and gRPC use this approach. They generate Go code from a schema definition. The generated code is fast and type-safe.
Performance matters. Profile before optimizing, but know reflection is expensive.
Decision matrix
Use reflection when you are building a library that needs to work with arbitrary user-defined types, like a serializer or validator. Use code generation when you need high performance and can define the schema at compile time, like a protobuf compiler or a custom DSL. Use interfaces when you only need to check for behavior, not structure. Interfaces are faster and type-safe. Use explicit mapping functions when the data structure is simple and performance matters. A few lines of code beat reflection overhead. Use struct tags to carry metadata for reflection-based tools. Tags keep the configuration close to the definition.