Struct Tags

How Go struct tags work, why they exist, and how to use them with the standard library, reflection, and custom tooling.

A struct tag is a small string attached to a struct field, written inside backticks after the field’s type. Library code reads these strings at runtime — or sometimes before the program runs — to decide how to treat that field. The encoding/json and encoding/xml packages are the most visible consumers, but the ecosystem uses the same mechanism for validation, ORM mapping, environment variable parsing, CLI flag handling, and more.

Think of tags as machine-readable instructions glued to data. They do not change the Go type system, and the compiler never checks their content. They exist only for code that deliberately inspects them, and that code decides what the strings mean.

What a Tag Looks Like

A tag is a raw string literal placed after a field type, with no space between the type and the backtick:

type User struct {
    Name  string `json:"name"`
    Email string `json:"email,omitempty"`
    Admin bool   `json:"-"`
}

Each tag is a single backtick-quoted string. Inside it you can pack multiple key-value pairs, separated by spaces. The format required by convention is:

key:"value" key2:"value2"

The key is a non‑empty string that must not contain spaces, quotes, or colons. The value is always quoted with double quotes, and inside those quotes you use Go string literal syntax, so escaping follows Go’s rules.

A field can carry many tags at once — one for JSON, one for XML, one for your own validation logic — all inside the same backtick block:

type Record struct {
    ID int `json:"id" xml:"id,attr" validate:"required,min=1"`
}

Quotes and spaces are not optional:

A tag like json:name (missing double quotes) or json:"name" xml:"age" (missing space between key pairs) will not be parsed as you expect. The reflect package’s Get and Lookup methods follow the key:"value" convention strictly. A stray space inside a quoted value is fine because the value is double‑quoted, but the tag string must begin with a key followed immediately by a colon and a quoted value.

How the Standard Library Uses Tags

encoding/json

When you pass a struct to json.Marshal or json.Unmarshal, the encoding/json package looks for a tag with the key "json". The value is a comma‑separated list of options. The first segment is the name of the JSON field. If no tag is present, the field’s exported Go name is used directly.

The most common options are:

  • json:"name" – Use "name" as the JSON key instead of the field name.
  • json:"name,omitempty" – If the field holds the zero value for its type, skip it entirely in the output.
  • json:"-" – Never include this field in JSON output (or ignore it when reading).
  • json:"name,string" – Force the value to be serialized as a JSON string even when it is a number or bool (less common).
type Config struct {
    Host    string `json:"host"`
    Port    int    `json:"port,omitempty"`
    Debug   bool   `json:"-"`
    Retries int    `json:"retries,string"`
}
func main() {
    c := Config{Host: "localhost", Retries: 3}
    b, _ := json.Marshal(c)
    fmt.Println(string(b))
    // Output: {"host":"localhost","retries":"3"}
}

The Port field vanished because omitempty dropped its zero value (0). The Debug field was excluded by the dash. The Retries field appears as "3", a quoted string, not a raw number.

During unmarshalling, the package does the reverse: it maps JSON keys to struct fields using the tag, respecting - and string the same way. A missing JSON key simply leaves the field at its zero value.

encoding/xml

The encoding/xml package uses the key "xml" and has its own vocabulary. Tags control element names, attributes, nested content, and more.

type Person struct {
    XMLName xml.Name `xml:"person"`
    ID      int      `xml:"id,attr"`
    Name    string   `xml:"name"`
    Age     int      `xml:"age,omitempty"`
}
func main() {
    p := Person{ID: 42, Name: "Ada"}
    b, _ := xml.MarshalIndent(p, "", "  ")
    fmt.Println(string(b))
    // Output:
    // \u003cperson id="42"\u003e
    //   \u003cname\u003eAda\u003c/name\u003e
    // \u003c/person\u003e
}

The attr option makes ID an XML attribute rather than a child element. omitempty suppresses the element when the field is zero (like Age). The XMLName field sets the root element name.

Tag syntax for XML is richer:

XML tags support directives like ,innerxml and ,chardata that drastically change how the field interacts with the XML tree. The full list is in the encoding/xml documentation.

What Happens When You Access a Tag

The reflect package gives you access to tags through reflect.StructTag. Once you have a reflect.Type representing a struct, you can retrieve a reflect.StructField and then call .Tag.Get(key) or .Tag.Lookup(key).

type Item struct {
    Title string `check:"required,min=3" json:"title"`
}
func main() {
    t := reflect.TypeOf(Item{})
    f, _ := t.FieldByName("Title")
    fmt.Println(f.Tag.Get("json"))  // title
    fmt.Println(f.Tag.Get("check")) // required,min=3
    // Get returns "" when the key is missing; Lookup reports presence.
    v, ok := f.Tag.Lookup("xml")
    fmt.Println(v, ok) // "" false
}

Get and Lookup only understand the key:"value" convention. They do not interpret the value — that is entirely up to the consumer. The standard library does not enforce any schema inside the value string; typos and malformed options compile without error.

Reflection requires exported fields:

You can only obtain a reflect.StructField for an exported (capitalised) field. Tags on unexported fields are legal Go, but most reflection‑based consumers — including encoding/json — will ignore them entirely. If you put a json tag on a lower‑case field, the field will never be serialised, which can lead to silent data loss.

Processing a Custom Tag at Runtime

When the standard library tags aren’t enough, you can read your own tags and act on them. A typical example is validation: attach a validate tag and write a function that walks the struct with reflection, dispatching rules.

1

Define the struct with a validation tag

type SignupRequest struct {
    Email    string `validate:"required,email"`
    Password string `validate:"required,min=8"`
    Age      int    `validate:"gte=13,lte=130"`
}

The tag value contains comma‑separated rules. Some rules accept arguments after an equals sign.

2

Build a registry of rule functions

Each rule name maps to a function that checks one field value and returns an error.

type ruleFunc func(reflect.Value, string) error
var rules = map[string]ruleFunc{
    "required": func(v reflect.Value, _ string) error {
        if v.IsZero() {
            return errors.New("is required")
        }
        return nil
    },
    "min": func(v reflect.Value, arg string) error {
        n, _ := strconv.Atoi(arg)
        if len(v.String()) < n {
            return fmt.Errorf("must have at least %d characters", n)
        }
        return nil
    },
    "email": func(v reflect.Value, _ string) error {
        _, err := mail.ParseAddress(v.String())
        return err
    },
    // ... gte, lte, etc.
}
3

Write the validation dispatcher

A single function that walks the struct, reads the validate tag, and invokes the correct rule function.

func Validate(s any) error {
    v := reflect.ValueOf(s).Elem()
    t := v.Type()
    for i := 0; i < t.NumField(); i++ {
        field := t.Field(i)
        tag := field.Tag.Get("validate")
        if tag == "" {
            continue
        }
        for _, rule := range strings.Split(tag, ",") {
            head, arg, _ := strings.Cut(rule, "=")
            fn, ok := rules[head]
            if !ok {
                continue
            }
            if err := fn(v.Field(i), arg); err != nil {
                return fmt.Errorf("%s %w", field.Name, err)
            }
        }
    }
    return nil
}
4

Use it in practice

req := &SignupRequest{Email: "not-valid", Password: "123"}
err := Validate(req)
fmt.Println(err)
// Output: Email mail: missing '@' or angle-addr

Field names preserved:

When a validation error includes the Go field name, it’s instantly clear which piece of data failed. This works because the dispatcher has access to field.Name from reflection. A production validator would also accumulate multiple errors before returning.

The runtime approach is easy to get started with and keeps tag meaning in one place, but it pays a reflection cost on every call. For throughput‑sensitive code, you can pay that cost once at build time.

Generating Code from Tags

Instead of reading tags every time the program runs, you can read them once during code generation and emit plain Go that hard‑codes the decisions. Tools like easyjson work this way: they parse your json:"..." tags at go generate time and produce a MarshalJSON/UnmarshalJSON pair that contains no reflection loops.

A tag consumer that uses reflect at call time. Easy to prototype, but adds an overhead every time you call Validate or Marshal. Good for moderate throughput or when the struct changes infrequently.

// Every call to Validate walks the struct with reflection.
err := Validate(&req)

Many ORMs, serializers, and validators offer both paths, though the code‑generation approach is less common. The trade‑off is always the same: flexibility and development speed versus runtime performance. For most applications, the standard library’s runtime reflection is perfectly adequate; code generation becomes valuable only when profiling shows a real bottleneck.

Common Mistakes and Misconceptions

Forgetting that tags are only advisory:

Tags have no effect on Go’s type system, memory layout, or assignment. Adding a json:"-" tag does not make a field private. Any Go code that accesses the struct directly can still read and write it. Tags only matter to the library that chooses to read them.

  • Unexported fields with tags — The encoding/json and encoding/xml packages skip unexported fields silently, regardless of tags. If you add a json:"name" tag to a lowercase field, it will never appear in output, and no error is reported.
  • Tag value typosjson:"email, omitemtpy" (a typo in the option) does not cause a compile error. The JSON package ignores options it doesn’t recognise, so omitempty quietly fails to work, and the field appears even when empty.
  • Confusing multiple tag consumers — When a field carries json, bson, and validate tags, each library only reads its own key. There is no cross‑contamination. This is intentional; you can safely stack tags without fear that json will misunderstand a bson option.
  • Assuming tags are Go‑wide conventions — The key:"value" format is documented in the reflect package, but the values inside the quotes are completely library‑specific. json:"name" and validate:"name" mean entirely different things to their respective packages.
  • Using tags for business logic — Because tags are static strings, they are hard to vary per environment or deployment. If a field should be redacted only in certain conditions, handle that in code rather than trying to encode it into a tag.

Summary

Struct tags are a lightweight way to attach metadata to fields so that other code — both in the standard library and in the ecosystem — can treat those fields appropriately without changing the type system. The mechanism is built on one consistent convention: a backtick‑quoted string containing space‑separated key:"value" pairs, read via reflect.StructTag.

The encoding/json and encoding/xml packages show the pattern clearly. Beyond serialisation, the same reflect‑based approach powers validation, ORM mapping, CLI parsing, and environment variable binding. When runtime reflection is too expensive, code generation can freeze the same tag decisions into plain Go at build time.

To work effectively with tags, treat them as instructions for a specific tool. Keep the field name exported, quote your values, and remember that the compiler will never warn you about a malformed tag. When you build your own tag consumer, start with a small rule registry and grow it from there.