Struct Tags in Go and What Actually Reads Them at Runtime
A struct tag is only a string the compiler ignores. See how reflect reads it, what the json options do, what vet checks and how to write your own tag.

A struct tag looks like a piece of the type system and is not one. It is a string. The compiler stores it next to the field, reflect gives it back on request, and a library decides what it means. Everything people find magical about json:"name" comes from one package choosing to read it. Knowing that makes a whole group of bugs predictable: the typo that does nothing, the lowercase field that vanishes, the option that works in one library and not another. This article shows how a tag is read, builds a tiny validator that uses its own tag, and lists the failures that go vet can and cannot catch. It extends structs in Go, and it sits in the Go basics path.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
The syntax
A tag is the raw string literal that follows the field's type. By convention it holds one or more key:"value" pairs separated by a single space, and the value is a double-quoted string:
type User struct {
ID int `json:"id" db:"user_id"`
Name string `json:"name,omitempty" validate:"min=2"`
Email string `json:"-"`
}The language specification says only that a tag is a string made visible through the reflection interface and that it takes part in type identity. It does not say what goes in it. The key:"value" convention is described in the reflect.StructTag documentation, and every library follows it. Backticks keep the double quotes unescaped, which is why tags are always written that way.
How a library reads one
The mechanism is ordinary reflection. Get the type, walk the fields, ask each one for its tag:
package main
import (
"fmt"
"reflect"
)
type User struct {
ID int `json:"id" db:"user_id"`
Name string `json:"name,omitempty" validate:"min=2"`
Email string `json:"-"`
token string `json:"token"`
}
func main() {
t := reflect.TypeOf(User{})
for i := range t.NumField() {
f := t.Field(i)
jsonTag, hasJSON := f.Tag.Lookup("json")
fmt.Printf("%-6s exported=%-5t json=%q found=%t db=%q\n",
f.Name, f.IsExported(), jsonTag, hasJSON, f.Tag.Get("db"))
}
}ID exported=true json="id" found=true db="user_id"
Name exported=true json="name,omitempty" found=true db=""
Email exported=true json="-" found=true db=""
token exported=false json="token" found=true db=""Tag.Get returns the value or the empty string, and Tag.Lookup also reports whether the key was there, which matters when an empty value means something. The unexported token field has a tag and the reflection API hands it over, but a package like encoding/json cannot set or read an unexported field at all, so it never looks. That is the first thing to check when a field is missing from your JSON: a lowercase field name wins over every tag.
What the standard library does with json tags
encoding/json gives the tag three parts: a name, and options after commas. The name is the key in the object. A - skips the field. The options change what gets written, and they are best seen side by side:
package main
import (
"encoding/json"
"fmt"
"time"
)
type Profile struct {
ID int `json:"id"`
Name string `json:"name"`
Nickname string `json:"nickname,omitempty"`
Age int `json:"age,omitempty"`
Active bool `json:"active,omitzero"`
Joined time.Time `json:"joined,omitzero"`
Secret string `json:"-"`
Count int64 `json:"count,string"`
Notes *string `json:"notes"`
}
func main() {
out, err := json.Marshal(Profile{ID: 7, Name: "Ada", Secret: "x", Count: 42})
fmt.Println(string(out), err)
}{"id":7,"name":"Ada","count":"42","notes":null} <nil>The output shows each option at work. Nickname and Age are zero values with omitempty, so they are absent. Be careful, though: omitempty also drops a legitimate 0, so an age of zero and an unset age look the same. Joined is a zero time.Time, a struct, which omitempty can never skip, because a struct is never "empty" in its sense. Go 1.24 added omitzero for exactly that, and it omits any value that is the zero value of its type, including a zero time. Secret is excluded by the dash. Count has the string option, which writes the number as a JSON string, a common request when a client cannot hold a 64-bit integer. And Notes, a nil pointer without any omit option, is written as null.
If you need the fine print, the encoding/json documentation is where each option is defined. Other packages define their own: yaml and toml libraries use yaml:"..." and toml:"...", database/sql helpers use db:"...", and validators use their own mini-languages. None of them share a registry, so the same field may carry four tags.
Writing your own
The mechanism is open to you. This is a validator, short enough to read in one go, that enforces a min and a max on integers and a minimum length on strings from a tag:
package main
import (
"fmt"
"reflect"
"strconv"
"strings"
)
type Signup struct {
Username string `rules:"min=3"`
Age int `rules:"min=18,max=120"`
Bio string
}
func validate(v any) []string {
var problems []string
rv := reflect.ValueOf(v)
rt := rv.Type()
for i := range rt.NumField() {
rules, ok := rt.Field(i).Tag.Lookup("rules")
if !ok {
continue
}
field := rv.Field(i)
for _, rule := range strings.Split(rules, ",") {
name, arg, _ := strings.Cut(rule, "=")
limit, _ := strconv.Atoi(arg)
var size int
switch field.Kind() {
case reflect.String:
size = field.Len()
case reflect.Int:
size = int(field.Int())
}
if name == "min" && size < limit {
problems = append(problems, fmt.Sprintf("%s must be at least %d", rt.Field(i).Name, limit))
}
if name == "max" && size > limit {
problems = append(problems, fmt.Sprintf("%s must be at most %d", rt.Field(i).Name, limit))
}
}
}
return problems
}
func main() {
fmt.Println(validate(Signup{Username: "al", Age: 17}))
fmt.Println(validate(Signup{Username: "alice", Age: 30}))
}[Username must be at least 3 Age must be at least 18]
[]The first signup breaks two rules and the second none. The listing is deliberately small, and the problems it has are the problems of the real libraries. It ignores the parse errors from Atoi, it only handles two kinds, and nothing checks that the rule name is spelled correctly. A misspelled mim=3 is silently skipped. That is the central weakness of tags: the compiler cannot help, because to it the tag is a string. If you build on tags, validate the tags themselves in a test that walks your types, and fail loudly on a rule name you do not know.
What go vet checks, and what it does not
go vet includes a structtag analyzer that checks the syntax of tags and some duplicate-name problems for the json and xml keys. Give it a tag with a missing quote:
package main
import "fmt"
type T struct {
Name string `json:name`
}
func main() {
fmt.Println(T{})
}# example.test/lab
# [example.test/lab]
./main.go:6:2: struct field tag `json:name` not compatible with reflect.StructTag.Get: bad syntax for struct tag valueThis program compiles and runs, and the program ignores the tag. go vet is what notices.
The # example.test/lab header is part of the tool's output, and the line below it is the finding: the tag is not in the key:"value" form that reflect.StructTag.Get can parse. Vet catches the syntax, and it will not catch jsno:"name", since jsno is a legal key for a library that does not exist. Two defenses work. A round-trip test that marshals a value and compares it with the JSON you expect catches wrong names and wrong options for the libraries you use. And a linter such as musttag, available through golangci-lint, reports structs passed to a marshaller that lack the tags you require.
Where tags go wrong
Tags are stringly typed, and they spread information across the code: the struct says one thing about a field and the SQL says another. A rename of the Go field leaves the tag behind, so tags are better kept short and mirrored by tests. Embedded structs flatten their tagged fields into the parent for JSON, which can collide with a field of the same name. Tags also do not travel through conversions the way you might hope: converting between two struct types with identical fields ignores the tags, which is by design. If you need different wire shapes for the same data, define a second struct for each wire format and convert, instead of overloading one type with five tags. The patterns for decoding errors that come from tag mismatches are in JSON unmarshal errors in Go, and for the reflection side, the reflect package documents every call used above.


