How to Use Struct Tags in Go (json, db, yaml, validate)

Add backtick-quoted key-value pairs to Go struct fields to control JSON, database, and validation behavior.

The naming collision problem

You model a database record in Go. The database column is user_id. Your REST API expects id. Your YAML configuration file uses userId. Your validation library wants required,min=1. You could write three separate structs and copy data between them. Or you could write one struct and attach metadata to each field. That metadata is a struct tag.

Tags solve a specific friction point: different systems use different naming conventions and validation rules, but your Go code needs a single source of truth. Tags let you declare how each external system should interpret a field without changing the field's type or memory layout.

What tags actually do

A struct tag is a backtick-quoted string attached to a struct field definition. The Go compiler completely ignores tags. They do not affect compilation, memory alignment, or type checking. Tags are purely declarative hints for packages that know how to read them at runtime.

Think of a tag like a luggage tag on a suitcase. The suitcase itself is just a container with a fixed shape. The tag tells the airline where it is going, how to handle it, and what happens if it gets delayed. The Go standard library and third-party packages parse those strings using the reflect package. If a package does not know how to read your tag, the tag is invisible.

Tags follow a strict syntax. Each tag is a space-separated list of key-value pairs. The key identifies the package or feature. The value contains the instructions. Options within the value are comma-separated. The entire string lives inside backticks so you can use quotes and special characters without escaping.

Tags are strings. The compiler does not validate their syntax. A typo in a tag key or a misplaced comma will not stop your program from building. The error surfaces at runtime when the library tries to parse it. This design keeps the language simple and pushes validation to the ecosystem.

The minimal example

Here is the simplest struct with tags: one field, two keys, one option.

type User struct {
    ID   int    `json:"id,omitempty" db:"user_id"`
    Name string `json:"name" db:"full_name"`
}

The json key tells the standard library how to map the field to JSON keys. The omitempty option tells the encoder to skip the field if it holds the zero value. The db key tells a database driver how to map the field to a column name. The field name ID remains the identifier inside your Go code. The tags only affect serialization and deserialization.

How the runtime reads them

Packages read tags through the reflect package. When you call json.Marshal, the encoder walks the struct's fields using reflection. For each field, it calls Field().Tag.Get("json"). The Get method splits the tag string by spaces, finds the key, and returns the value. If the key does not exist, it returns an empty string.

The parser then splits the value by commas. The first element is the external name. Subsequent elements are options. The json package recognizes omitempty. The db package might recognize type:varchar or notnull. Each library defines its own option vocabulary. There is no universal tag registry.

This reflection-based approach means tags are flexible but fragile. If you change a tag value to something the library does not recognize, the library usually falls back to the field's Go name. Some libraries panic on malformed tags. Others silently ignore them. The behavior depends entirely on the package author.

Convention aside: Go struct fields must be exported (capitalized) for most serialization libraries to read or write them. The json package will skip lowercase fields entirely. The db package will refuse to scan into them. Capitalization controls visibility, not tagging.

Real-world wiring

Here is how tags look in a production service that talks to a database, exposes an API, and validates input.

type CreateUserRequest struct {
    Email    string `json:"email" db:"user_email" validate:"required,email"`
    Username string `json:"username" db:"username" validate:"required,min=3,max=20"`
    Age      int    `json:"age,omitempty" db:"age" validate:"min=0,max=120"`
}

The json tags shape the HTTP request and response payloads. The db tags align the struct with PostgreSQL or MySQL columns. The validate tags come from a third-party library that checks constraints before the data hits the database. Each package reads only the keys it understands. The struct acts as a single mapping layer between three different systems.

When a request arrives, the JSON decoder reads json tags to populate the struct. The validator reads validate tags to reject malformed input. The database driver reads db tags to generate the correct INSERT statement. The Go code never needs to translate between formats manually.

Convention aside: The community standardizes on key:"value" with no spaces around the colon. Adding a space like json: "id" breaks the parser in most libraries. gofmt does not touch struct tags, so the formatting discipline falls on the developer. Most teams enforce the no-space rule with linters.

Where things break

Tags are powerful because they are untyped strings. That flexibility creates specific failure modes.

A missing colon or a space after the key breaks parsing. The json package expects json:"id". If you write json: "id", the parser treats json: as the key and "id" as the value. The library fails to match the key and falls back to the Go field name. Your API suddenly returns ID instead of id. The compiler never complains. The error surfaces as a silent mismatch in production.

Unexported fields cause runtime panics when you try to write to them through reflection. If you tag a lowercase field and pass it to a scanner, the database/sql package rejects it with sql: Scan error on column index 0: sql/driver: couldn't convert NULL type to string. The json package simply skips the field. You get partial data without a warning.

Typos in option names are ignored by most libraries. Writing json:"id,omitEmpty" instead of omitempty means the encoder never skips zero values. The field appears in every response. The validator library might panic with invalid validation tag syntax if it encounters an unknown option. The behavior varies by package.

Convention aside: The validate ecosystem expects options in a specific order for some rules. required usually comes first. min and max follow. If you reverse them, the library might still work, but error messages become confusing. Read the documentation for your specific validator.

Tags also do not compose automatically. If you embed a struct, the tags do not merge. The parent struct's tags override or shadow the child's tags depending on the library. The json package flattens embedded structs by default. The db package treats embedded fields as separate columns unless you explicitly map them. Composition changes the reflection walk, which changes how tags are resolved.

When to reach for tags

Use struct tags when you need a single Go type to map cleanly to multiple external formats. Use a dedicated DTO struct when the external schema diverges significantly from your internal domain model. Use a custom MarshalJSON method when you need conditional logic, computed fields, or performance-sensitive serialization. Use plain fields when you only interact with one system and want zero reflection overhead.

Tags are declarative glue. They work best when the mapping is static and the external contracts are stable. They break down when you need dynamic behavior, complex transformations, or strict compile-time guarantees.

Where to go next