Struct Tags & Custom Tag Parsing
Struct tags attach declarative metadata to fields.
Libraries read those strings at runtime with reflection or at build time with AST tools.
This page shows how to parse json, db, and validate tags and build a small custom binder.
Summary
Struct tags are backtick-quoted strings after a field declaration.
The reflect package exposes them as StructTag values with Get and Lookup.
Production parsers validate syntax, support multiple keys, and cache per-type field plans.
For hot paths, codegen reads the same tags from source and emits straight assignments.
Recipe
Quick-reference tag parsing card.
// Field declaration
Name string `json:"name,omitempty" db:"user_name" validate:"required,min=2"`
// Read one key
tag := field.Tag.Get("json") // "name,omitempty"
name, opts, _ := strings.Cut(tag, ",")
// Lookup with ok flag
v, ok := field.Tag.Lookup("validate") // "required,min=2", trueWhen to reach for this:
- Building config, env, or CLI binders from struct tags
- Writing validation middleware that reflects request DTOs
- Sharing one struct between JSON APIs and SQL columns via different tag keys
- Prototyping before investing in a
go generatepass
Working Example
A minimal env tag parser that fills a struct from a map.
package main
import (
"fmt"
"reflect"
"strconv"
"strings"
)
type Server struct {
Host string `env:"HOST" validate:"required"`
Port int `env:"PORT" validate:"min=1"`
}
type fieldPlan struct {
index int
key string
kind reflect.Kind
}
func plans(t reflect.Type) ([]fieldPlan, error) {
if t.Kind() != reflect.Struct {
return nil, fmt.Errorf("need struct")
}
var out []fieldPlan
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if !f.IsExported() {
continue
}
key, ok := f.Tag.Lookup("env")
if !ok || key == "" {
continue
}
out = append(out, fieldPlan{index: i, key: key, kind: f.Type.Kind()})
}
return out, nil
}
func bind(ptr any, env map[string]string) error {
v := reflect.ValueOf(ptr)
if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct {
return fmt.Errorf("ptr must be *struct")
}
v = v.Elem()
t := v.Type()
fp, err := plans(t)
if err != nil {
return err
}
for _, p := range fp {
raw, ok := env[p.key]
if !ok {
continue
}
fv := v.Field(p.index)
switch p.kind {
case reflect.String:
fv.SetString(raw)
case reflect.Int:
n, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("%s: %w", p.key, err)
}
fv.SetInt(int64(n))
default:
return fmt.Errorf("unsupported kind %s for %s", p.kind, p.key)
}
}
return nil
}
func main() {
s := &Server{}
err := bind(s, map[string]string{"HOST": "0.0.0.0", "PORT": "3000"})
fmt.Println(s, err)
}What this demonstrates:
Tag.Lookupdistinguishes missing keys from empty values- Field plans cache indices so repeated binds avoid re-walking types
- Only exported fields are bindable from other packages
- Validation tags are present for a follow-on validator (see gotchas)
Deep Dive
How It Works
- The compiler stores tags as opaque strings; no compile-time validation of tag syntax.
reflect.Type.Field(i)returnsStructFieldwithName,Type,Tag, and offset.StructTag.Get(key)returns the value portion before the first comma for that key.- Multiple keys live in one string:
`json:"x" xml:"x"`.
Common Tag Keys
| Key | Typical consumer | Value shape |
|---|---|---|
json | encoding/json | name,omitempty,string |
db | sqlx, GORM | column name, options |
validate | go-playground/validator | rules joined by commas |
yaml | gopkg.in/yaml.v3 | yaml name |
env | custom binders | environment variable name |
Parsing validate Rules
Split on commas, then parse key=value options:
func parseRules(tag string) map[string]string {
rules := map[string]string{}
for _, part := range strings.Split(tag, ",") {
k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
rules[k] = "true"
continue
}
rules[k] = v
}
return rules
}Go Notes
// Cache plans per reflect.Type in a sync.Map for frameworks
var cache sync.Map // key: reflect.Type, value: []fieldPlanGotchas
- Spaces inside tag values - Tags cannot contain spaces unescaped; use commas for options. Fix: Follow each library's documented grammar.
- Duplicate json keys - Two fields with
json:"name"cause silent overwrites inencoding/json. Fix: Rungo vetand custom linters in CI. - Unexported fields - Other packages cannot set them via reflection. Fix: Keep bindable fields exported or stay in-package.
- Tag changes without cache busting - Cached
fieldPlanslices go stale if you reload types in long-running plugins. Fix: Key cache by type identity and document hot-reload limits. - Ignoring Lookup ok -
Getreturns""for missing and empty alike. Fix: UseLookupwhen presence matters. - Huge tag strings - Very long validate chains hurt readability. Fix: Move complex rules to custom validator functions.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Hand-written mapping | One or two DTOs | Dozens of similar structs |
go generate from tags | Hot path binding | Rapid prototype still changing shape |
| Struct embedding + methods | Behavior tied to types | Pure data transfer objects |
mapstructure decoder | Loose config maps | You need strict struct tags per field |
FAQs
What is the syntax of a struct tag?
Backtick string with space-separated key:"value" pairs, e.g. `json:"id"`.
Values are always quoted strings.
How does encoding/json read tags?
It reflects on the type and reads the json key for field names and options like omitempty.
Can I define my own tag keys?
Yes.
Any key is valid; your parser or generator interprets it.
Does go vet check tags?
Some analyzers flag mismatched JSON tags or suspicious struct tags.
Enable govet and struct tag linters in CI.
How do db tags differ from json tags?
db names SQL columns for ORMs and sqlx; json names wire fields.
The same struct often carries both.
Should parsers run on every request?
Walk the type once, cache plans, reuse on each bind.
Per-request full reflection is fine only on cold paths.
Can generators read tags without reflection?
Yes.
go/ast or go/types inspect field tags at codegen time.
What happens if a tag is malformed?
stdlib JSON ignores unknown options; custom parsers should return errors at startup, not per request.
Are tags visible in godoc?
No.
They are implementation metadata, not documentation comments.
How do I test tag parsers?
Table-test structs with different tag combinations and assert bound values and error messages.
Related
- Reflection Basics - field iteration fundamentals
- Reflection vs Code Generation in Go - tags in both models
- Building Custom Code Generators - AST-driven tag codegen
- ORM & Serializer Reflection Costs - tag-driven ORM costs
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).