Skip to main content

Go Reflection: Advanced Type Inspection

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 7 min read

Reflection in Go is the mechanism by which code can inspect and manipulate values whose types aren’t known at compile time. It’s how encoding/json, fmt.Sprintf, go-playground/validator, GORM, and most Go frameworks work internally. Used correctly, it enables powerful generic behavior. Used carelessly, it produces slow, brittle code that fails at runtime instead of compile time.

This guide covers the practical patterns for reflection that appear in real libraries — struct tag inspection, dynamic field access, and building validators. For the basics of any and type assertions see Go empty interface and reflection basics.

Why Reflection Exists

Go’s type system is static — at compile time, every value has a known type. But frameworks like JSON encoders must work with any struct, not one defined at compile time. Reflection provides the runtime bridge: inspect whatever type arrives, adapt to its structure.

The cost: reflection bypasses the type system. Operations that would be compile-time errors become runtime panics. Reflection code is harder to read and typically 5–10x slower than equivalent typed code. Use it when the alternative is code generation or requiring users to implement interfaces.

Struct Tag Inspection: The Most Common Use

The most useful reflection in practice reads struct tags — key-value metadata that library authors attach to fields:

type User struct {
    ID       int    `json:"id"    db:"user_id"  validate:"required"`
    Name     string `json:"name"  db:"name"     validate:"required,min=2"`
    Email    string `json:"email" db:"email"    validate:"required,email"`
    Password string `json:"password,omitempty" db:"-"`
}

func inspectTags(v any) {
    t := reflect.TypeOf(v)
    if t.Kind() == reflect.Ptr {
        t = t.Elem()
    }
    if t.Kind() != reflect.Struct {
        return
    }

    for i := 0; i < t.NumField(); i++ {
        field := t.Field(i)  // reflect.StructField

        jsonTag   := field.Tag.Get("json")
        dbTag     := field.Tag.Get("db")
        validateTag := field.Tag.Get("validate")

        fmt.Printf("%-10s json:%-20s db:%-15s validate:%s\n",
            field.Name, jsonTag, dbTag, validateTag)
    }
}

This is the foundation of ORM field mapping, JSON serialization, and struct validation — the framework reads your annotations once (typically cached) and uses them to drive behavior.

Building a Simple Struct Validator

A practical example: a validator that reads validate struct tags and enforces them:

func Validate(v any) []string {
    t := reflect.TypeOf(v)
    val := reflect.ValueOf(v)

    // Dereference pointer if needed
    if t.Kind() == reflect.Ptr {
        t = t.Elem()
        val = val.Elem()
    }
    if t.Kind() != reflect.Struct {
        return []string{"expected struct"}
    }

    var errs []string
    for i := 0; i < t.NumField(); i++ {
        field := t.Field(i)
        fieldVal := val.Field(i)
        tag := field.Tag.Get("validate")

        if tag == "" {
            continue
        }

        for _, rule := range strings.Split(tag, ",") {
            switch {
            case rule == "required":
                if fieldVal.IsZero() {
                    errs = append(errs, field.Name+" is required")
                }
            case strings.HasPrefix(rule, "min="):
                min, _ := strconv.Atoi(strings.TrimPrefix(rule, "min="))
                if fieldVal.Kind() == reflect.String && fieldVal.Len() < min {
                    errs = append(errs, fmt.Sprintf("%s must be at least %d chars", field.Name, min))
                }
            case rule == "email":
                if s, ok := fieldVal.Interface().(string); ok {
                    if !strings.Contains(s, "@") {
                        errs = append(errs, field.Name+" is not a valid email")
                    }
                }
            }
        }
    }
    return errs
}

// Usage
u := User{Name: "Al"}  // too short, missing email
for _, err := range Validate(u) {
    fmt.Println(err)
}
// ID is required
// Name must be at least 2 chars
// Email is required

In production, use github.com/go-playground/validator/v10 which implements this pattern with full Unicode support, cross-field validation, and cached reflection.

Setting Values via Reflection

Reflection can modify values, but only through an addressable pointer:

type Config struct {
    Host    string
    Port    int
    Debug   bool
    Timeout time.Duration
}

// LoadFromEnv reads struct fields and fills them from environment variables
// based on an `env` struct tag
func LoadFromEnv(cfg any) error {
    v := reflect.ValueOf(cfg)
    if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct {
        return fmt.Errorf("expected pointer to struct")
    }
    v = v.Elem()
    t := v.Type()

    for i := 0; i < t.NumField(); i++ {
        field := t.Field(i)
        envKey := field.Tag.Get("env")
        if envKey == "" {
            continue
        }

        envVal := os.Getenv(envKey)
        if envVal == "" {
            continue
        }

        fv := v.Field(i)
        if !fv.CanSet() {
            continue  // unexported field
        }

        switch fv.Kind() {
        case reflect.String:
            fv.SetString(envVal)
        case reflect.Int, reflect.Int64:
            if fv.Type() == reflect.TypeOf(time.Duration(0)) {
                d, err := time.ParseDuration(envVal)
                if err != nil { return err }
                fv.SetInt(int64(d))
            } else {
                n, err := strconv.ParseInt(envVal, 10, 64)
                if err != nil { return err }
                fv.SetInt(n)
            }
        case reflect.Bool:
            b, err := strconv.ParseBool(envVal)
            if err != nil { return err }
            fv.SetBool(b)
        }
    }
    return nil
}

// Usage
type ServerConfig struct {
    Host    string        `env:"APP_HOST"`
    Port    int           `env:"APP_PORT"`
    Debug   bool          `env:"APP_DEBUG"`
    Timeout time.Duration `env:"APP_TIMEOUT"`
}

cfg := &ServerConfig{Host: "localhost", Port: 8080}
LoadFromEnv(cfg)

The CanSet() check is important — calling SetString on a non-settable value panics. Fields are non-settable when: the struct was passed by value (not pointer), or the field is unexported.

Calling Methods Dynamically

Reflection can call methods by name, enabling plugin-like dispatch patterns:

type Calculator struct{}

func (c Calculator) Add(a, b int) int      { return a + b }
func (c Calculator) Multiply(a, b int) int { return a * b }

func callMethod(obj any, method string, args ...any) ([]any, error) {
    v := reflect.ValueOf(obj)
    m := v.MethodByName(method)
    if !m.IsValid() {
        return nil, fmt.Errorf("method %q not found on %T", method, obj)
    }

    in := make([]reflect.Value, len(args))
    for i, arg := range args {
        in[i] = reflect.ValueOf(arg)
    }

    out := m.Call(in)

    results := make([]any, len(out))
    for i, r := range out {
        results[i] = r.Interface()
    }
    return results, nil
}

// Usage
calc := Calculator{}
results, _ := callMethod(calc, "Add", 3, 4)
fmt.Println(results[0].(int))  // 7

This pattern is used by test frameworks, RPC systems, and dependency injection containers. The tradeoff: method name typos become runtime panics, not compile-time errors.

Caching Reflection for Performance

Reflection is 5–10x slower than direct access. For code that runs on every request (serializers, validators), cache the reflection analysis:

type structInfo struct {
    fields []fieldInfo
}

type fieldInfo struct {
    index     int
    name      string
    jsonTag   string
    validateTag string
}

var structCache sync.Map  // map[reflect.Type]*structInfo

func getStructInfo(t reflect.Type) *structInfo {
    if v, ok := structCache.Load(t); ok {
        return v.(*structInfo)
    }

    info := &structInfo{}
    for i := 0; i < t.NumField(); i++ {
        f := t.Field(i)
        info.fields = append(info.fields, fieldInfo{
            index:       i,
            name:        f.Name,
            jsonTag:     f.Tag.Get("json"),
            validateTag: f.Tag.Get("validate"),
        })
    }

    structCache.Store(t, info)
    return info
}

encoding/json uses exactly this pattern — the first time it serializes a type, it analyzes the struct tags and caches the result. Subsequent serializations of the same type use the cached analysis.

When to Use Generics Instead

Go 1.18 generics solve many problems that previously required reflection, with full compile-time type safety:

// Before generics: reflection + runtime type assertions
func MapAny(slice []any, f func(any) any) []any { ... }

// With generics: type-safe, no reflection, faster
func Map[T, U any](slice []T, f func(T) U) []U {
    result := make([]U, len(slice))
    for i, v := range slice {
        result[i] = f(v)
    }
    return result
}

// Generics: works with any comparable type, no reflection
func Contains[T comparable](slice []T, item T) bool {
    for _, v := range slice {
        if v == item { return true }
    }
    return false
}

Use generics when the types are constrained but flexible — data structures, algorithms, utility functions. Use reflection when you genuinely need to work with arbitrary structs you can’t describe with constraints — serialization formats, ORM field mapping, struct validation from tags.

Summary

  • Reflection’s primary practical use: reading struct tags to implement serialization, validation, and ORM field mapping
  • Always check CanSet() before calling SetXxx on a reflect.Value — non-addressable values will panic
  • Cache reflection analysis (sync.Map keyed by reflect.Type) for hot paths — reflection is 5–10x slower than direct access
  • MethodByName enables dynamic dispatch but moves errors from compile-time to runtime — document clearly
  • Go 1.18+ generics handle type-flexible algorithms without reflection — prefer generics for data structures and algorithms
  • Use go-playground/validator/v10 for production struct validation — it implements the patterns here with full Unicode and cross-field support

Resources

Comments

👍 Was this article helpful?