- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You add a field to a struct, tag it, and run the service. The
validation you expected to reject a blank email lets it through.
No error at build time. No warning from go vet. The tag is just
sitting there in the source, doing nothing, and Go never told you.
That silence is the thing most Go developers never learn about
struct tags. The tag is a string. The compiler treats it as an
opaque string. Whether it means anything depends entirely on a
library reading it at runtime through reflect, and if the format
is off by one character, that library shrugs and moves on.
Here is how tags actually work under the json key you already
know, how validate and db sit next to it, how to parse your own
key for your own library, and why a typo costs you nothing at
compile time and everything at runtime.
A tag is one string with a specific grammar
A struct tag is the raw string literal after a field. That is all
the compiler sees.
type User struct {
Email string `json:"email" validate:"required,email"`
}
The string is json:"email" validate:"required,email". Go does
not parse it, does not check it, does not care what keys are in it.
The convention that turns it into structured data lives in the
reflect package, in StructTag, which expects a specific format:
space-separated key:"value" pairs. The value is always quoted.
Inside the value, individual options are comma-separated by
convention, but that convention belongs to each library, not to Go.
You read a tag through reflection:
t := reflect.TypeOf(User{})
field, _ := t.FieldByName("Email")
fmt.Println(field.Tag) // json:"email" validate:"..."
fmt.Println(field.Tag.Get("json")) // email
fmt.Println(field.Tag.Get("db")) // "" — no db key present
Tag.Get("json") returns the value for that one key. Ask for a key
that isn't there and you get an empty string, no error. Hold that
thought, because it is the whole story behind the silent failure.
Multiple keys, one field, many readers
The reason tags pack several keys into one string is that different
libraries read the same field for different reasons. The JSON
encoder wants the wire name. The validator wants the rules. The SQL
mapper wants the column. None of them know about the others.
type Product struct {
ID int64 `json:"id" db:"product_id"`
Name string `json:"name" db:"name" validate:"required"`
Price float64 `json:"price" db:"price_cents" validate:"gte=0"`
}
Three consumers walk this struct independently. encoding/json
reads json, sqlx reads db, go-playground/validator reads
validate. Each calls field.Tag.Get with its own key and ignores
the rest. There is no coordination and no conflict, because a tag is
just a namespace where every library rents its own slot.
You can add a fourth reader tomorrow without touching the first
three. That is the design. The cost is that nothing enforces
consistency across them, so json:"price" and db:"price_cents"
can drift apart and no tool will connect the two names for you.
Reading a tag by hand
To see the mechanics without a library in the way, walk a struct
and print every field's json and db tags. This is exactly what
sqlx and friends do internally, minus the caching.
package main
import (
"fmt"
"reflect"
)
type Product struct {
ID int64 `json:"id" db:"product_id"`
Name string `json:"name" db:"name"`
Price float64 `json:"price" db:"price_cents"`
}
func main() {
t := reflect.TypeOf(Product{})
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fmt.Printf("%-6s json=%-7q db=%q\n",
f.Name, f.Tag.Get("json"), f.Tag.Get("db"))
}
}
Output:
ID json="id" db="product_id"
Name json="name" db="name"
Price json="price" db="price_cents"
NumField and Field(i) give you each field. Field(i).Tag is
the StructTag. Get pulls one key. There is no magic; the whole
tag ecosystem is built on these four calls.
Lookup vs Get: telling "empty" from "absent"
Get collapses two different situations into one empty string: the
key is missing, or the key is present with an empty value. When you
write your own tag reader, you often need to tell those apart.
StructTag.Lookup does that.
type Row struct {
A int `db:""` // present, empty
B int `json:"b"` // db absent entirely
}
t := reflect.TypeOf(Row{})
fa, _ := t.FieldByName("A")
fb, _ := t.FieldByName("B")
va, oka := fa.Tag.Lookup("db")
vb, okb := fb.Tag.Lookup("db")
fmt.Printf("%q %v\n", va, oka) // "" true
fmt.Printf("%q %v\n", vb, okb) // "" false
Field A has db:"", which usually means "this column exists but
use the field name" or "skip me" depending on the library. Field B
has no db key at all. Get returns "" for both. Lookup tells
you A opted in with an empty value and B never opted in. If your
library needs a default-vs-explicit distinction, reach for Lookup.
Writing your own tag key
Say you are building a small config loader and want an env key
that maps a struct field to an environment variable, with an
optional default= fallback. You own the key env, so you own its
grammar. Pick comma-separated options to match every other library.
type Config struct {
Port int `env:"PORT,default=8080"`
Host string `env:"HOST,default=localhost"`
Debug bool `env:"DEBUG"`
Ignored string `env:"-"`
}
The parser reads the env key, splits on commas, treats the first
segment as the variable name and the rest as options. A value of -
means skip the field, mirroring the json:"-" convention so nobody
has to relearn it.
func loadEnv(v any) {
val := reflect.ValueOf(v).Elem()
typ := val.Type()
for i := 0; i < typ.NumField(); i++ {
tag, ok := typ.Field(i).Tag.Lookup("env")
if !ok || tag == "-" {
continue
}
parts := strings.Split(tag, ",")
name := parts[0]
def := ""
for _, opt := range parts[1:] {
if strings.HasPrefix(opt, "default=") {
def = strings.TrimPrefix(opt, "default=")
}
}
raw, present := os.LookupEnv(name)
if !present {
raw = def
}
setField(val.Field(i), raw)
}
}
setField converts the string into the field's kind:
func setField(f reflect.Value, raw string) {
if !f.CanSet() || raw == "" {
return
}
switch f.Kind() {
case reflect.String:
f.SetString(raw)
case reflect.Int, reflect.Int64:
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
f.SetInt(n)
}
case reflect.Bool:
if b, err := strconv.ParseBool(raw); err == nil {
f.SetBool(b)
}
}
}
Two details matter. CanSet is false for unexported fields, so a
lowercase field silently stays at its zero value; that is a reason
config structs use exported fields. And you own the meaning of every
option in the env value, because Go handed you a plain string and
walked away.
The malformed tag that fails in total silence
Here is the trap that sends people to the debugger. The tag format
is strict: key:"value" with the value in double quotes and a
single space between pairs. Break the format and reflect does not
return the value you meant. It returns empty, with no error.
type Bad struct {
// missing quotes around the value
Email string `json:email`
// colon-space instead of colon
Name string `json: "name"`
// single quotes instead of double
City string `json:'city'`
}
Every one of these is a legal Go string literal, so the code
compiles. Run it through reflection and watch the values disappear:
t := reflect.TypeOf(Bad{})
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fmt.Printf("%-6s %q\n", f.Name, f.Tag.Get("json"))
}
Output:
Email ""
Name ""
City ""
Three fields, three tags, three empty results. encoding/json
would fall back to the field name for all of them, so Email
serializes as "Email" instead of "email", and your API contract
quietly breaks. No panic, no log line, nothing.
The one tool that catches this is go vet. Its structtag
analyzer, on by default, understands the grammar and flags malformed
tags:
$ go vet ./...
./main.go:4:2: struct field tag `json:email` not compatible
with reflect.StructTag.Get: bad syntax for struct tag value
That is why the malformed-tag bug survives so often: it only fails
in code that runs go vet. Add go vet ./... to CI and this class
of bug stops shipping. Skip it, and a missing pair of quotes turns
into a production incident that looks like everything is fine right
up until the JSON is wrong.
The takeaway
Wire go vet into CI and let the structtag analyzer be the check
the compiler won't give you. The same flexibility that lets one
field feed the JSON encoder, the validator, and the SQL mapper at
once is what lets a single misplaced quote silently unwire all three.
Reflection and tags are where Go stops being a language you read and
starts being one you inspect at runtime, and it rewards knowing
exactly what StructTag does and does not promise. The Complete
Guide to Go Programming digs into reflect, the type system, and
how the runtime carries this metadata. Hexagonal Architecture in
Go is about keeping reflection-heavy mapping penned into adapters,
so your domain code never has to know a db tag exists.

Top comments (0)