Struct tags

Struct tags are metadata strings attached to Go struct fields to control how external tools process the data.

The invisible instruction manual

You define a User struct to hold data from your database. The database column is user_name. Your JSON API expects userName. Your Go field is UserName because exported names must start with a capital letter. You need a bridge between the Go type system and the external formats that consume it. Struct tags provide that bridge. They sit immediately after a field declaration, wrapped in backticks, carrying key-value pairs that tell external packages how to map, validate, or serialize the data.

What they actually are

Struct tags are just strings. The Go compiler treats them as opaque metadata attached to a field. There is no type checking. There is no enforcement. If you write a tag with a typo, the compiler silently accepts it. The tags exist solely for packages that use reflection to read them at runtime.

The syntax follows a strict convention. A tag is a space-separated list of key:"value" pairs. Each key belongs to a specific package or library. The json key belongs to encoding/json. The db key belongs to database drivers. The validate key belongs to validation libraries. You can attach multiple keys to a single field by separating them with spaces. The value portion can contain comma-separated options. The omitempty option in JSON tags is a classic example. It lives inside the quotes, separated by a comma, not a space.

Convention aside: Go developers never argue about tag formatting. The standard library parsers are rigid. A missing space between two tags breaks the second one. A comma instead of a space breaks the option parser. Treat tags as configuration strings that follow a well-documented contract.

Minimal example

Here is the simplest way to attach a tag and see it in action. The encoding/json package reads the json key to decide what string to use as the JSON property name.

package main

import (
    "encoding/json"
    "fmt"
)

type User struct {
    // Capitalized for export, but we want lowercase JSON keys
    FirstName string `json:"first_name"`
    Age       int    `json:"age"`
}

func main() {
    u := User{FirstName: "Ada", Age: 36}
    // Marshal reads the json tag to map fields to keys
    data, err := json.Marshal(u)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(string(data))
}

The output prints {"first_name":"Ada","age":36}. Without the tags, the output would be {"FirstName":"Ada","Age":36}. The marshaling function inspects the struct, finds the json key on each field, and uses the quoted value as the JSON key. If a field lacks a tag, the function falls back to the Go field name.

Tags are cheap. Reflection is not.

How the runtime reads them

The Go runtime exposes struct tags through the reflect package. Every struct field has a Tag field that implements the reflect.StructTag interface. Under the hood, it is just a string, but the interface provides a Get(key string) method that handles the parsing for you.

When you call Get("json"), the reflection engine splits the raw tag string by spaces, finds the segment starting with json:, strips the key and quotes, and returns the value. If the key does not exist, it returns an empty string. This design keeps the standard library fast while giving third-party packages a consistent API.

Convention aside: The receiver name for methods that read tags is usually one or two letters matching the type, like (t reflect.StructTag) Get(key string) string. The community follows this pattern to keep method signatures readable.

Here is how you read a tag manually without relying on a third-party library:

package main

import (
    "fmt"
    "reflect"
)

type Config struct {
    Host string `env:"API_HOST" default:"localhost"`
    Port int    `env:"API_PORT" default:"8080"`
}

// ReadEnvTags prints the env tag for every field in a struct
func ReadEnvTags(v any) {
    rv := reflect.ValueOf(v)
    // Dereference pointer so we can inspect the underlying struct
    if rv.Kind() == reflect.Ptr {
        rv = rv.Elem()
    }
    // Iterate over each field by index
    for i := 0; i < rv.NumField(); i++ {
        field := rv.Type().Field(i)
        // Get returns empty string if the key is missing
        tag := field.Tag.Get("env")
        if tag != "" {
            fmt.Printf("%s maps to env var %s\n", field.Name, tag)
        }
    }
}

func main() {
    cfg := Config{}
    ReadEnvTags(cfg)
}

The loop walks the struct type, pulls the tag for each field, and prints the mapping. The Get method handles the space-splitting and quote-stripping automatically. You never parse the raw string yourself unless you are building a custom tag parser from scratch.

Reflection reads strings. Your code decides what to do with them.

Realistic example

In production code, tags often drive validation, serialization, and database mapping simultaneously. A single field might carry three different keys. The standard library does not enforce which keys exist, so you can invent your own. Here is a lightweight example that uses a custom log tag to control how a field appears in structured logs.

package main

import (
    "fmt"
    "reflect"
)

type Request struct {
    ID      string `json:"id" log:"request_id"`
    Secret  string `json:"token" log:"-"`
    Payload string `json:"payload" log:"payload"`
}

// BuildLogMap creates a map of field values filtered by the log tag
func BuildLogMap(v any) map[string]any {
    result := make(map[string]any)
    rv := reflect.ValueOf(v)
    // Handle both struct values and pointers to structs
    if rv.Kind() == reflect.Ptr {
        rv = rv.Elem()
    }
    // Walk fields and check the custom log tag
    for i := 0; i < rv.NumField(); i++ {
        tag := rv.Type().Field(i).Tag.Get("log")
        // Skip fields marked with "-" or missing the tag
        if tag == "" || tag == "-" {
            continue
        }
        result[tag] = rv.Field(i).Interface()
    }
    return result
}

func main() {
    req := Request{ID: "abc-123", Secret: "supersecret", Payload: "data"}
    logData := BuildLogMap(req)
    fmt.Println(logData)
}

The function inspects each field, checks the log tag, and skips anything marked with -. The - convention comes from encoding/json and has become the standard way to explicitly exclude a field from serialization or logging. The output prints map[payload:data request_id:abc-123]. The secret token never appears.

Tags are conventions. The compiler does not police them.

Pitfalls and runtime surprises

Struct tags are unvalidated strings. The compiler accepts anything inside backticks. If you write json:"name omitempty" with a space instead of a comma, the JSON encoder treats omitempty as part of the key name. The encoder ignores the malformed tag and falls back to the default behavior. You get no warning. The bug surfaces as unexpected JSON output in production.

Typos in keys are equally dangerous. Writing jsn:"name" instead of json:"name" creates a tag that no standard library reads. The compiler does not flag it. The runtime silently ignores it. You spend hours debugging why a field is missing from your API response.

Reflection misuse triggers panics. If you pass a non-struct value to reflect.ValueOf(v).Field(i), the runtime aborts with reflect: call of reflect.Value.Field on string Value. If you forget to check Kind() before dereferencing a pointer, you get reflect: call of reflect.Value.Elem on ptr Value. These are runtime panics, not compile-time errors. Always validate the type before walking fields.

Convention aside: The if err != nil { return err } pattern exists because Go makes the unhappy path visible. Tag parsing is no different. If you build a custom tag reader, return an error when the format is wrong instead of panicking or silently dropping data.

Tags are just strings until a library reads them. Verify your syntax early.

When to use tags versus alternatives

Use struct tags when you need to attach metadata to fields for serialization, validation, or mapping without changing the struct layout. Use custom methods when the transformation logic is complex or requires external state that tags cannot express. Use separate configuration files when the mapping rules change frequently or differ across environments. Use interfaces when you want to decouple the data structure from the behavior that consumes it. Use plain fields when the external format already matches your Go naming convention and no mapping is required.

Tags are plumbing. Keep them simple and let libraries do the heavy lifting.

Where to go next