DEV Community

Cover image for Go Struct Tags Beyond json: validate, db, and Writing Your Own
Gabriel Anhaia
Gabriel Anhaia

Posted on

Go Struct Tags Beyond json: validate, db, and Writing Your Own


You add a field to a struct, tag it, and run the service. The
validation you expected to reject a blank email lets it through.
No error at build time. No warning from go vet. The tag is just
sitting there in the source, doing nothing, and Go never told you.

That silence is the thing most Go developers never learn about
struct tags. The tag is a string. The compiler treats it as an
opaque string. Whether it means anything depends entirely on a
library reading it at runtime through reflect, and if the format
is off by one character, that library shrugs and moves on.

Here is how tags actually work under the json key you already
know, how validate and db sit next to it, how to parse your own
key for your own library, and why a typo costs you nothing at
compile time and everything at runtime.

A tag is one string with a specific grammar

A struct tag is the raw string literal after a field. That is all
the compiler sees.

type User struct {
    Email string `json:"email" validate:"required,email"`
}
Enter fullscreen mode Exit fullscreen mode

The string is json:"email" validate:"required,email". Go does
not parse it, does not check it, does not care what keys are in it.
The convention that turns it into structured data lives in the
reflect package, in StructTag, which expects a specific format:
space-separated key:"value" pairs. The value is always quoted.
Inside the value, individual options are comma-separated by
convention, but that convention belongs to each library, not to Go.

You read a tag through reflection:

t := reflect.TypeOf(User{})
field, _ := t.FieldByName("Email")

fmt.Println(field.Tag)              // json:"email" validate:"..."
fmt.Println(field.Tag.Get("json")) // email
fmt.Println(field.Tag.Get("db"))   // "" — no db key present
Enter fullscreen mode Exit fullscreen mode

Tag.Get("json") returns the value for that one key. Ask for a key
that isn't there and you get an empty string, no error. Hold that
thought, because it is the whole story behind the silent failure.

Multiple keys, one field, many readers

The reason tags pack several keys into one string is that different
libraries read the same field for different reasons. The JSON
encoder wants the wire name. The validator wants the rules. The SQL
mapper wants the column. None of them know about the others.

type Product struct {
    ID    int64   `json:"id" db:"product_id"`
    Name  string  `json:"name" db:"name" validate:"required"`
    Price float64 `json:"price" db:"price_cents" validate:"gte=0"`
}
Enter fullscreen mode Exit fullscreen mode

Three consumers walk this struct independently. encoding/json
reads json, sqlx reads db, go-playground/validator reads
validate. Each calls field.Tag.Get with its own key and ignores
the rest. There is no coordination and no conflict, because a tag is
just a namespace where every library rents its own slot.

You can add a fourth reader tomorrow without touching the first
three. That is the design. The cost is that nothing enforces
consistency across them, so json:"price" and db:"price_cents"
can drift apart and no tool will connect the two names for you.

Reading a tag by hand

To see the mechanics without a library in the way, walk a struct
and print every field's json and db tags. This is exactly what
sqlx and friends do internally, minus the caching.

package main

import (
    "fmt"
    "reflect"
)

type Product struct {
    ID    int64   `json:"id" db:"product_id"`
    Name  string  `json:"name" db:"name"`
    Price float64 `json:"price" db:"price_cents"`
}

func main() {
    t := reflect.TypeOf(Product{})
    for i := 0; i < t.NumField(); i++ {
        f := t.Field(i)
        fmt.Printf("%-6s json=%-7q db=%q\n",
            f.Name, f.Tag.Get("json"), f.Tag.Get("db"))
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

ID     json="id"      db="product_id"
Name   json="name"    db="name"
Price  json="price"   db="price_cents"
Enter fullscreen mode Exit fullscreen mode

NumField and Field(i) give you each field. Field(i).Tag is
the StructTag. Get pulls one key. There is no magic; the whole
tag ecosystem is built on these four calls.

Lookup vs Get: telling "empty" from "absent"

Get collapses two different situations into one empty string: the
key is missing, or the key is present with an empty value. When you
write your own tag reader, you often need to tell those apart.
StructTag.Lookup does that.

type Row struct {
    A int `db:""`   // present, empty
    B int `json:"b"` // db absent entirely
}

t := reflect.TypeOf(Row{})
fa, _ := t.FieldByName("A")
fb, _ := t.FieldByName("B")

va, oka := fa.Tag.Lookup("db")
vb, okb := fb.Tag.Lookup("db")

fmt.Printf("%q %v\n", va, oka) // "" true
fmt.Printf("%q %v\n", vb, okb) // "" false
Enter fullscreen mode Exit fullscreen mode

Field A has db:"", which usually means "this column exists but
use the field name" or "skip me" depending on the library. Field B
has no db key at all. Get returns "" for both. Lookup tells
you A opted in with an empty value and B never opted in. If your
library needs a default-vs-explicit distinction, reach for Lookup.

Writing your own tag key

Say you are building a small config loader and want an env key
that maps a struct field to an environment variable, with an
optional default= fallback. You own the key env, so you own its
grammar. Pick comma-separated options to match every other library.

type Config struct {
    Port    int    `env:"PORT,default=8080"`
    Host    string `env:"HOST,default=localhost"`
    Debug   bool   `env:"DEBUG"`
    Ignored string `env:"-"`
}
Enter fullscreen mode Exit fullscreen mode

The parser reads the env key, splits on commas, treats the first
segment as the variable name and the rest as options. A value of -
means skip the field, mirroring the json:"-" convention so nobody
has to relearn it.

func loadEnv(v any) {
    val := reflect.ValueOf(v).Elem()
    typ := val.Type()

    for i := 0; i < typ.NumField(); i++ {
        tag, ok := typ.Field(i).Tag.Lookup("env")
        if !ok || tag == "-" {
            continue
        }

        parts := strings.Split(tag, ",")
        name := parts[0]
        def := ""
        for _, opt := range parts[1:] {
            if strings.HasPrefix(opt, "default=") {
                def = strings.TrimPrefix(opt, "default=")
            }
        }

        raw, present := os.LookupEnv(name)
        if !present {
            raw = def
        }
        setField(val.Field(i), raw)
    }
}
Enter fullscreen mode Exit fullscreen mode

setField converts the string into the field's kind:

func setField(f reflect.Value, raw string) {
    if !f.CanSet() || raw == "" {
        return
    }
    switch f.Kind() {
    case reflect.String:
        f.SetString(raw)
    case reflect.Int, reflect.Int64:
        if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
            f.SetInt(n)
        }
    case reflect.Bool:
        if b, err := strconv.ParseBool(raw); err == nil {
            f.SetBool(b)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Two details matter. CanSet is false for unexported fields, so a
lowercase field silently stays at its zero value; that is a reason
config structs use exported fields. And you own the meaning of every
option in the env value, because Go handed you a plain string and
walked away.

The malformed tag that fails in total silence

Here is the trap that sends people to the debugger. The tag format
is strict: key:"value" with the value in double quotes and a
single space between pairs. Break the format and reflect does not
return the value you meant. It returns empty, with no error.

type Bad struct {
    // missing quotes around the value
    Email string `json:email`
    // colon-space instead of colon
    Name string `json: "name"`
    // single quotes instead of double
    City string `json:'city'`
}
Enter fullscreen mode Exit fullscreen mode

Every one of these is a legal Go string literal, so the code
compiles. Run it through reflection and watch the values disappear:

t := reflect.TypeOf(Bad{})
for i := 0; i < t.NumField(); i++ {
    f := t.Field(i)
    fmt.Printf("%-6s %q\n", f.Name, f.Tag.Get("json"))
}
Enter fullscreen mode Exit fullscreen mode

Output:

Email  ""
Name   ""
City   ""
Enter fullscreen mode Exit fullscreen mode

Three fields, three tags, three empty results. encoding/json
would fall back to the field name for all of them, so Email
serializes as "Email" instead of "email", and your API contract
quietly breaks. No panic, no log line, nothing.

The one tool that catches this is go vet. Its structtag
analyzer, on by default, understands the grammar and flags malformed
tags:

$ go vet ./...
./main.go:4:2: struct field tag `json:email` not compatible
    with reflect.StructTag.Get: bad syntax for struct tag value
Enter fullscreen mode Exit fullscreen mode

That is why the malformed-tag bug survives so often: it only fails
in code that runs go vet. Add go vet ./... to CI and this class
of bug stops shipping. Skip it, and a missing pair of quotes turns
into a production incident that looks like everything is fine right
up until the JSON is wrong.

The takeaway

Wire go vet into CI and let the structtag analyzer be the check
the compiler won't give you. The same flexibility that lets one
field feed the JSON encoder, the validator, and the SQL mapper at
once is what lets a single misplaced quote silently unwire all three.

Reflection and tags are where Go stops being a language you read and
starts being one you inspect at runtime, and it rewards knowing
exactly what StructTag does and does not promise. The Complete
Guide to Go Programming
digs into reflect, the type system, and
how the runtime carries this metadata. Hexagonal Architecture in
Go
is about keeping reflection-heavy mapping penned into adapters,
so your domain code never has to know a db tag exists.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)