Performance Cost of Reflection in Go

Reflection in Go is significantly slower than static code because it requires runtime type inspection, so avoid it in performance-critical paths.

The price of flexibility

You're building a library that needs to handle any type a user throws at it. Maybe it's a validation framework, a serialization tool, or a generic data mapper. The type system won't let you write func Process(x AnyType). You need something flexible. You reach for the reflect package. It works. The tests pass. Then you run a benchmark, and the numbers look bad. The code that used to take microseconds now takes milliseconds. Reflection is the escape hatch from Go's static type system, and escape hatches always charge a toll.

What reflection actually does

Go is a statically typed language. When you write var name string, the compiler knows exactly how much memory to allocate and how to handle name. It bakes that knowledge into the binary. Reflection turns that certainty into a question. The reflect package lets you inspect types, read values, and even modify fields at runtime. It's like asking the program to read its own source code while it's running.

You can find out if a value is a string, a struct, or a slice. You can get the value of a field named ID without knowing the struct definition ahead of time. This power comes from the runtime maintaining a type description for every type in your program. When you use reflection, you're walking through that description instead of following the fast path the compiler built.

Reflection is a tax on flexibility. Pay it only when you have to.

Static versus dynamic

Here's the simplest comparison. A loop with static types versus a loop with reflection. The static version lets the compiler optimize everything. The reflection version forces the runtime to check types and allocate structures for every element.

Here's the static path. The compiler knows the slice contains strings.

// StaticProcess handles a known slice type.
// The compiler knows every element is a string.
func StaticProcess(items []string) int {
    total := 0
    for _, item := range items {
        // Direct access to string length.
        // No type check needed.
        total += len(item)
    }
    return total
}

Here's the reflection path. The function accepts an interface slice, so it must inspect each element at runtime.

// ReflectProcess handles an interface slice.
// It must check types at runtime.
func ReflectProcess(items []interface{}) int {
    total := 0
    for _, item := range items {
        // Get the type information dynamically.
        // This allocates and walks the type tree.
        t := reflect.TypeOf(item)
        if t.Kind() == reflect.String {
            // Convert back to string to get length.
            // This involves another allocation.
            s := item.(string)
            total += len(s)
        }
    }
    return total
}

The runtime cost

When the compiler sees StaticProcess, it generates machine code that jumps directly to the string length instruction. The CPU knows the data layout. When it sees ReflectProcess, the story changes.

reflect.TypeOf returns a reflect.Type interface. That interface holds a pointer to a runtime type structure. The runtime has to look up the type, check the Kind, and if you want the value, reflect.ValueOf creates a reflect.Value struct. These structs often allocate on the heap. Every call to reflect can trigger a heap allocation. The garbage collector has to clean up those allocations. In a tight loop, you're generating garbage faster than the GC can keep up.

The CPU also loses branch prediction benefits because the type check happens dynamically. The processor can't predict the path as well. An interface{} value already carries overhead: it stores a type pointer and a data pointer. Reflection adds more indirection on top of that. You're paying for the interface wrapper plus the reflection machinery.

When reflection makes sense

Reflection isn't useless. It's essential for tools that must handle arbitrary data. JSON marshaling is the classic case. The encoding/json package uses reflection to walk structs and find tags. Here's a simplified config loader that uses reflection to set struct fields from a map. This pattern appears in ORMs, validation libraries, and config parsers.

Here's a function that loads a map into a struct using reflection. It accepts a pointer so it can modify the struct.

// LoadConfig sets struct fields from a map using reflection.
// This is useful when the struct shape is known but the data source is dynamic.
func LoadConfig(cfg interface{}, data map[string]string) error {
    // Get the value of the interface.
    // We need a pointer to modify the struct.
    v := reflect.ValueOf(cfg)
    if v.Kind() != reflect.Ptr {
        return fmt.Errorf("config must be a pointer")
    }

    // Dereference the pointer to get the struct.
    elem := v.Elem()
    if elem.Kind() != reflect.Struct {
        return fmt.Errorf("config must be a struct pointer")
    }

    return setFields(elem, data)
}

Here's the helper that iterates the map and updates fields. It checks visibility and types.

// setFields iterates the map and updates matching struct fields.
func setFields(elem reflect.Value, data map[string]string) error {
    for key, val := range data {
        // Find the field by name.
        // This is case-sensitive and slow.
        f := elem.FieldByName(key)
        if !f.IsValid() {
            continue
        }

        // Check if the field is settable.
        // Unexported fields cannot be set via reflection.
        if !f.CanSet() {
            continue
        }

        // Set the value based on the field type.
        // This requires type assertion or conversion.
        if f.Type().Kind() == reflect.String {
            f.SetString(val)
        }
    }
    return nil
}

Reflection respects visibility. You can't break encapsulation, even with magic.

The CanSet check is crucial. Reflection cannot set unexported fields. If you have type Config struct { secret string }, FieldByName finds it, but CanSet returns false. The compiler enforces visibility, and reflection respects that rule. You can't use reflection to bypass package boundaries. This is a safety feature, not a bug.

Common traps

Reflection code is prone to runtime panics because the compiler can't verify your logic. You're working with types that are only known at runtime.

If you pass a struct value to a reflection function that tries to modify it, the runtime panics with reflect: Set of unaddressable value. The reflection package works on the address of the value. If you don't pass a pointer, there's no address to write to. Always pass pointers to reflection functions that modify data.

Trying to set a string value on an integer field triggers reflect: SetString of non-string field. The runtime checks types strictly. You must check the field type before calling setter methods like SetString or SetInt.

If you use a type assertion and the type doesn't match, you get a panic like interface conversion: interface {} is string, not int. This happens at runtime, not compile time. Use the comma-ok idiom for safe assertions: s, ok := item.(string).

The runtime panics are your friend. They catch mistakes the compiler missed.

Generics changed the game

Go 1.18 introduced generics. Generics allow you to write functions that work with multiple types while keeping type safety. The compiler generates specialized versions of the function for each type used. This eliminates the runtime cost of reflection. If you can express your logic with generics, do it. Generics are faster and safer.

Here's the generic version of the string processor. It works with any type that supports len, and the compiler optimizes it just like the static version.

// GenericProcess works with any type that supports len.
// The compiler generates a specific version for strings.
func GenericProcess[T ~string](items []T) int {
    total := 0
    for _, item := range items {
        // Direct access.
        // No reflection overhead.
        total += len(item)
    }
    return total
}

Generics replaced reflection for many use cases. Check if generics solve your problem first.

Generics are the new default. Reflection is the legacy escape hatch.

Decision matrix

Use reflection when you are building a generic framework that must handle types unknown at compile time, such as a serialization library or a validation engine. Use reflection when you need to inspect struct tags or field names dynamically, like in an ORM or a config mapper. Use type assertions or type switches when you know the possible types ahead of time but receive an interface value. Use generics when you want type safety with reusable code and the logic works uniformly across types. Use static types in performance-critical loops where every nanosecond counts and the data shape is fixed. Use code generation when you need the flexibility of reflection but the speed of static code, generating specific handlers for each type at build time.

Generics are the new default. Reflection is the legacy escape hatch.

Where to go next