Skip to content
C Codeloom
Go

Go Struct Tags and Reflection

Understand how Go struct tags work, how packages like encoding/json read them with reflection, and how to add custom tag-driven behavior to your code.

·5 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • What struct tags are and how they are stored
  • How encoding/json reads tags via reflect
  • The conventional tag key:"value,option" syntax
  • How to write your own tag-driven helper
  • Performance and safety trade-offs of reflection

Prerequisites

  • Basic Go familiarity

What and Why

Struct tags are the little backtick-quoted strings you see after struct fields in Go. They look decorative but they’re a quiet superpower: a stringly-typed metadata channel that packages can read at runtime via reflection. The standard library uses tags for JSON, XML, environment loading, ORM mapping, validation, and more.

Understanding tags lets you not only consume libraries effectively but also build your own configuration- and serialization-driven helpers without resorting to code generation.

Mental Model

A tag is a string attached to a field, accessible via reflect.StructField.Tag. By convention the string is a space-separated list of key:"value" pairs. Each library picks a key (json, yaml, db, validate) and parses its own value.

Tags do nothing on their own. They are inert until a library reflects on the struct. That separation is the point: you describe intent declaratively on the type, and any number of consumers can read it.

Reflection (reflect package) is how libraries enumerate fields, read tags, and read or write values dynamically. It is powerful but slower than direct field access and bypasses some compile-time checks.

Hands-on Example

Let’s build a tiny envload helper that fills struct fields from environment variables based on an env:"NAME" tag.

package main

import (
    "fmt"
    "os"
    "reflect"
    "strconv"
)

type Config struct {
    Host  string `env:"APP_HOST"`
    Port  int    `env:"APP_PORT"`
    Debug bool   `env:"APP_DEBUG"`
    skip  string // unexported, ignored
}

func envload(v any) error {
    rv := reflect.ValueOf(v).Elem()
    rt := rv.Type()
    for i := 0; i < rt.NumField(); i++ {
        f := rt.Field(i)
        key := f.Tag.Get("env")
        if key == "" || !rv.Field(i).CanSet() {
            continue
        }
        raw, ok := os.LookupEnv(key)
        if !ok {
            continue
        }
        fv := rv.Field(i)
        switch fv.Kind() {
        case reflect.String:
            fv.SetString(raw)
        case reflect.Int, reflect.Int64:
            n, err := strconv.ParseInt(raw, 10, 64)
            if err != nil { return fmt.Errorf("%s: %w", key, err) }
            fv.SetInt(n)
        case reflect.Bool:
            b, err := strconv.ParseBool(raw)
            if err != nil { return fmt.Errorf("%s: %w", key, err) }
            fv.SetBool(b)
        }
    }
    return nil
}

func main() {
    os.Setenv("APP_HOST", "0.0.0.0")
    os.Setenv("APP_PORT", "8080")
    os.Setenv("APP_DEBUG", "true")

    var cfg Config
    if err := envload(&cfg); err != nil { panic(err) }
    fmt.Printf("%+v\n", cfg)
}

We accept a pointer so the value is addressable. We skip unexported fields and any without our tag key. The library’s contract is implicit but clear: tag your fields, pass a pointer, get them populated. encoding/json works similarly, just with richer parsing.

Tag lookup and reflective field assignment

Common Pitfalls

Tag syntax errors are silent. A typo like env"APP_HOST" (missing colon) parses to an empty value. reflect.StructTag does not warn. Use go vet which flags many tag mistakes.

Unexported fields can’t be set. Reflection can read their types but CanSet returns false. Capitalize fields you want libraries to fill.

Mismatched tag keys across libraries. If you tag with json:"id" and then use a library expecting JSON:"id", nothing happens. Tag keys are case-sensitive.

Reflection in hot loops. Calling reflective code per request is fine for config loading at startup but expensive in tight loops. Cache the parsed field plan once and reuse it.

Pointer vs value confusion. reflect.ValueOf(cfg) on a non-pointer gives you a copy you can’t mutate. Always pass &cfg and call .Elem().

Practical Tips

Always design tag-driven APIs to skip fields with no tag and to ignore fields with tag:"-". Both conventions match user expectations from JSON.

Document your tag grammar. State the key, the supported options, and how multiple options combine. The JSON package’s json:"name,omitempty" is a great template.

When implementing a parser, expose it as a small package and write table-driven tests. Reflection bugs are subtle; tests pay for themselves.

For very high performance, generate code at build time (go generate) using something like easyjson. You get the same declarative experience without runtime reflection.

Validate tag values eagerly. Walking the struct once at startup and panicking on misconfiguration is friendlier than failing on the first request.

Wrap-up

Struct tags are Go’s pragmatic answer to declarative metadata. With a few lines of reflection you can turn them into config loaders, validators, serializers, and ORM mappers. Keep tags lightweight, parse them once, document the grammar, and remember that reflection trades a little speed for a lot of expressiveness. Used well, tags let you describe data once and let many tools cooperate on it.