Struct Tags

A struct tag in Go is a small string of metadata attached to a struct field, written right after its type. Tags don’t change how the field behaves in ordinary Go code — the compiler mostly ignores them — but libraries like encoding/json, encoding/xml, and validation packages read them at runtime through reflection to decide how to serialize, deserialize, or validate that field. Struct tags are how Go lets you describe a field’s external representation without inventing a new language feature for every use case.

Overview / How it works

Syntactically, a struct tag is just a raw string literal (backtick-quoted) placed after a field’s type in a struct declaration. The Go compiler stores this string verbatim as part of the field’s type information, accessible via the reflect package, but it does not parse or validate the contents of the tag in any special way — as far as the language spec is concerned, a struct tag is an arbitrary string. It’s entirely up to whichever package reads the tag (with reflect.StructTag.Get) to decide what the contents mean.

By convention, a struct tag is formatted as a space-separated list of key:"value" pairs, for example `json:"name" validate:"required"`. Multiple tools can share the same tag string because each one only looks for its own key (json, xml, validate, db, and so on) and ignores the rest. This is why you’ll often see a single field carry several tags at once, one per library that needs to know something about it.

The most common consumer is encoding/json. When you call json.Marshal or json.Unmarshal, the package uses reflection to walk over a struct’s fields. For each exported field, it checks for a json tag: the tag’s value becomes the JSON key instead of the Go field name, and optional comma-separated directives after the name change the behavior further — most importantly omitempty, which skips the field entirely when it holds its zero value, and -, which excludes the field from JSON entirely. If a field has no tag at all, encoding/json falls back to using the field’s Go name directly (case-sensitive during marshal, case-insensitive matching during unmarshal).

Under the hood, none of this is magic specific to encoding/json — any package can define its own tag key and read it with the same reflection APIs. This is exactly how validation libraries, ORMs, and configuration loaders (like db:"user_id" or env:"PORT") work: they define a convention for their tag key, and at runtime they use reflect.TypeOf(x).Field(i).Tag.Get("theirkey") to pull out the string and act on it. Because tags are just strings read at runtime, a typo in a tag is never a compile error — it’s a silent bug, which is the single biggest gotcha with this feature.

Syntax

The general form of a tagged struct field looks like this:

type StructName struct {
	FieldName Type `key1:"value1" key2:"value2,option1,option2"`
}
  • Backticks — the tag must be a raw string literal, delimited by backticks, not double quotes. This avoids having to escape the inner double quotes.
  • key — an identifier a specific package looks for, such as json, xml, or validate. Unknown keys are simply ignored by packages that don’t recognize them.
  • “value” — always wrapped in escaped double quotes inside the backtick string. The first part before a comma is usually the external name to use (e.g., the JSON key).
  • options — comma-separated flags after the name, such as omitempty (skip zero values) or - as the whole value (exclude the field entirely from that package’s processing).
  • space-separated pairs — multiple key:"value" pairs for different packages can coexist on one field, separated by single spaces.

Examples

Example 1: Controlling JSON output with struct tags. Here a struct’s field names are renamed for JSON, and an optional field is omitted when empty.

package main

import (
	"encoding/json"
	"fmt"
)

type Person struct {
	Name  string `json:"name"`
	Age   int    `json:"age"`
	Email string `json:"email,omitempty"`
}

func main() {
	p := Person{Name: "Alice", Age: 30}
	data, err := json.Marshal(p)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(string(data))
}

Output:

{"name":"Alice","age":30}

Because Email is left as its zero value (an empty string) and its tag includes omitempty, encoding/json drops it from the output entirely rather than emitting "email":"".

Example 2: Using - to exclude a field, and unmarshaling with tags.

package main

import (
	"encoding/json"
	"fmt"
)

type Product struct {
	Title string  `json:"title"`
	Price float64 `json:"price"`
	SKU   string  `json:"-"`
}

func main() {
	input := []byte(`{"title":"Keyboard","price":49.99,"sku":"KB-100"}`)
	var pr Product
	err := json.Unmarshal(input, &pr)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Printf("Title: %s, Price: %.2f, SKU: %q\n", pr.Title, pr.Price, pr.SKU)
}

Output:

Title: Keyboard, Price: 49.99, SKU: ""

The SKU field has a json:"-" tag, so encoding/json never writes to or reads from it — even though the input JSON contains a matching "sku" key, it’s ignored, and SKU stays at its zero value.

Example 3: Reading tags yourself with reflection. Struct tags aren’t exclusive to encoding/json — you can read any tag key with the reflect package, which is how validation and ORM libraries build their own tag-driven behavior.

package main

import (
	"fmt"
	"reflect"
)

type User struct {
	Name string `json:"name" validate:"required"`
	Age  int    `json:"age" validate:"min=0"`
}

func main() {
	t := reflect.TypeOf(User{})
	for i := 0; i < t.NumField(); i++ {
		field := t.Field(i)
		jsonTag := field.Tag.Get("json")
		validateTag := field.Tag.Get("validate")
		fmt.Printf("Field: %s, json tag: %q, validate tag: %q\n", field.Name, jsonTag, validateTag)
	}
}

Output:

Field: Name, json tag: "name", validate tag: "required"
Field: Age, json tag: "age", validate tag: "min=0"

This loop uses reflect.TypeOf to get the struct's type, iterates its fields with NumField/Field, and calls Tag.Get for each key of interest. Tag.Get returns an empty string if the key isn't present, so it's always safe to call even when a tag is missing.

How it works step by step

When you call json.Marshal(p) on a struct value, roughly this happens internally:

  • The encoding/json package uses reflect to obtain the concrete type of p and iterates over its fields in declaration order.
  • For each exported field (unexported, lowercase fields are skipped entirely — tags cannot make an unexported field visible), it calls field.Tag.Get("json").
  • If the tag string is empty, the field's Go name is used as the JSON key. If the tag is "-", the field is skipped. Otherwise, the tag is split on the first comma: the first part becomes the JSON key (or, if empty, the Go name is kept), and the remaining parts are checked for known options like omitempty.
  • The field's current value is read via reflection, converted according to its Go type, and, unless suppressed by omitempty on a zero value, written into the resulting JSON object.
  • This process repeats for every field, and the results are assembled into the final byte slice returned by Marshal.

Unmarshal works symmetrically: it builds a lookup from JSON key to struct field (preferring an exact tag match, falling back to a case-insensitive Go-name match), then reflectively sets each matching field from the decoded JSON value.

Common Mistakes

Mistake 1: Forgetting the backticks. A struct tag must be a raw string literal in backticks. Writing it without backticks isn't valid Go syntax at all:

type Bad struct {
	Name string json:"name" // missing backticks -- this does not compile
}

The fix is simply to wrap the tag in backticks:

package main

import "fmt"

type Good struct {
	Name string `json:"name"`
}

func main() {
	g := Good{Name: "Bob"}
	fmt.Println(g.Name)
}

Mistake 2: A typo in the tag key's value causes silent, hard-to-spot bugs. Because tags are plain strings, the compiler cannot catch a misspelled JSON key — the field will simply never be populated:

type Config struct {
	MaxRetries int `json:"max_retreis"` // typo: should be "max_retries"
}
// Unmarshaling {"max_retries": 5} leaves MaxRetries at 0, with no error.

Always double-check tag values against the actual JSON keys you're consuming (or generate structs from a schema/sample payload), and cover serialization round-trips with a test that asserts on the actual field values, not just the absence of an error.

Best Practices

  • Use tags to make the JSON (or XML, or database column) contract explicit, even when it happens to match the Go field name — it protects you if the field is later renamed.
  • Prefer omitempty for optional fields you don't want cluttering output with zero values, but remember it treats an explicit zero and an absent value the same way — if you need to distinguish "not sent" from "sent as zero," use a pointer type instead.
  • Use json:"-" for fields that must never be serialized, such as passwords or internal-only state, rather than relying on omitting the field manually before marshaling.
  • Keep tag values consistent with your API or database naming convention (usually snake_case for JSON, matching whatever the other side of the wire expects).
  • When a struct is shared by multiple tag-consuming libraries (say, json and validate), keep each key's value focused on that library's concerns — don't try to overload one tag key for two purposes.
  • Write a small round-trip test (marshal then unmarshal, or unmarshal a sample payload and assert on fields) for any struct whose tags matter — this is the cheapest way to catch a typo tags can't catch for you at compile time.

Practice Exercises

  • Define a struct Book with fields Title, Author, and Pages. Add json tags so the JSON keys are title, author, and page_count respectively, then marshal a sample value and print the result.
  • Add a fourth field Notes string with json:"notes,omitempty". Marshal two values of Book — one with Notes set and one without — and observe how the output differs.
  • Write a function that takes any struct value (parameter type any) and uses reflect to print every field's name alongside its json tag, similar to Example 3. Try it on a struct that has some fields without a json tag and confirm Tag.Get returns an empty string for those.

Summary

  • A struct tag is a backtick-quoted string attached to a field; the compiler stores it but does not interpret it — that's left to whichever package reads it via reflection.
  • encoding/json reads the json tag to rename fields, and supports omitempty to skip zero values and - to exclude a field entirely.
  • Multiple tag keys can live on one field (json, xml, validate, and so on), each read independently by its own consumer.
  • Unexported fields are never processed by encoding/json, regardless of any tag you put on them.
  • Tags are just strings: a typo or missing backtick either compiles into a silent bug or fails to compile entirely, so validate tag-driven behavior with tests rather than assuming correctness.