{
"$type": "site.standard.document",
"canonicalUrl": "https://rednafi.com/go/struct-tags/",
"description": "A quick tour of Go struct tags: how different libraries use them, how you read them at runtime with reflection, and how other tools read them at build time instead.",
"path": "/go/struct-tags/",
"publishedAt": "2026-04-18T00:00:00.000Z",
"site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
"tags": [
"Go",
"Reflection",
"Codegen"
],
"textContent": "Struct tags in Go are these little annotations that you stick beside struct fields.\nLibraries read them to decide what to do with each field, and the most familiar place you'll\nsee them is JSON marshalling and unmarshalling:\n\nencoding/json reads those tags to pick the wire key, drop a zero value with omitempty,\nor skip the field with -.\n\nValidation libraries do the same thing with a different tag key. [go-playground/validator]\nreads a validate:\"...\":\n\nMy preferred envvar library, [caarlos0/env], does the same for environment variables:\n\nCLI libraries like [alecthomas/kong] use them for parsing flags:\n\nAcross all of these libraries the pattern is identical. A string sits beside each field, and\nsome code reads it at runtime through reflection every time you call Marshal, Struct, or\nParse.\n\nYou can also do it earlier, reading the tag once before the program runs and writing out\nplain Go that needs no reflection at call time.\n\nReading the tag at runtime\n\nThe standard library exposes tags through reflect.StructTag. A tag is any back-quoted\nstring after a field, and the API gives you a key/value lookup on it. You can read your own\ntag keys the same way:\n\nThat's the whole surface area. The compiler doesn't inspect the contents, so typos,\nmalformed values, and outright garbage all compile without complaint. What a library does\nwith the string is up to it.\n\nA naive validator that reads a check tag and understands required, min, and email\nwalks the fields and dispatches with a switch:\n\nHere:\n\n- (1) unwrap the pointer and grab the struct's type metadata\n- (2) for each field, pull its value, its name, and the check tag\n- (3) rules are comma-separated, and min=2 cuts into head=\"min\", arg=\"2\"\n- (4) dispatch on the rule name; each case formats its own error\n\nCall it like this:\n\nThis is fine for three rules. By the time you've added oneof, url, uuid, regex, and\nnested struct validation, the switch becomes unmanageable. A cleaner shape pulls each rule\ninto its own function and keeps a map keyed by tag name. The validator then has two halves,\na registry of rules and a dispatcher that runs them.\n\nThe registry maps each tag name to a small function that checks one thing:\n\nEach rule takes a field value and an optional argument, and returns an error. Adding a new\nrule is one new map entry, no changes to anything else.\n\nThe dispatcher is the same reflection loop as before, but without the switch. It looks up a\nhandler by tag name and calls it:\n\n- (1) walk every field of the struct\n- (2) grab the check tag and split it on commas, one rule per comma\n- (3) look up the rule's handler in the rules map; unknown rules are skipped\n- (4) call the handler with this field's value, bubble the error up with the field name\n\nThe dispatcher doesn't know what any given rule does, only that it exists in the map.\n\nOpen [baked_in.go] in go-playground/validator and you'll find the same shape: a\nbakedInValidators map with entries like \"required\", \"email\", \"len\", \"min\", each\npointing to a small function. The public validate.RegisterValidation(\"uuid\", ...) call\ninserts another entry into that map at runtime. The reflection sits in around twenty lines,\nand every new rule is one more function in the map.\n\nReading the tag at build time\n\nThe runtime shape pays a reflection cost on every call. You can skip that by reading the tag\nonce, before the program runs. [easyjson] is built around this idea: it's a drop-in\nalternative to encoding/json that reads your json:\"...\" tags at go generate time and\nwrites out a MarshalJSON and UnmarshalJSON per type, with no reflection left in either\none.\n\nTake the User we marshalled at the top of the post, with one line added to it:\n\nRun easyjson user.go and you get a user_easyjson.go alongside it. The full file\n([user_easyjson.go] in the examples repo) is ~90 lines of straight-line Go, but it's mostly\nplumbing. The skeleton is:\n\nThe encoder is where the tag decisions show up. Every choice the tag made has been frozen\ninto the code:\n\n- (1) json:\"name\" becomes a literal \"name\": in the output, no lookup at call time\n- (2) json:\"email,omitempty\" turns into a plain if in.Email != \"\" check\n- (3) json:\"-\" drops Admin entirely. The field doesn't appear in the encoder, and the\n decoder's switch has no case \"admin\"\n\nThe only place the tag string meets code is [parseFieldTags] in easyjson's gen/encoder.go,\nwhich is the build-time twin of the bakedInValidators map from the runtime half:\n\nThe returned fieldTags is what the surrounding generator consumes: omit skips the field,\nomitEmpty wraps the emit in an if, name becomes the literal \"name\": string. That one\nswitch decides every branch in the generated output.\n\neasyjson isn't the only tool that does this. [ent] walks Go schema files and emits a typed\nbuilder per entity, [sqlc] walks SQL queries and emits typed scanners, and protoc-gen-go\nwalks .proto files and emits the structs. Different inputs, same trick: read the schema\nonce at build time and write the Go that would otherwise need reflection at call time.\n\nFind the fully runnable code for both the runtime validator and the codegen tool that emits\nper-type Validate methods on [GitHub].\n\n\n\n\n[go-playground/validator]:\n https://github.com/go-playground/validator\n\n[caarlos0/env]:\n https://github.com/caarlos0/env\n\n[alecthomas/kong]:\n https://github.com/alecthomas/kong\n\n[sqlc]:\n https://github.com/sqlc-dev/sqlc\n\n[ent]:\n https://github.com/ent/ent\n\n[easyjson]:\n https://github.com/mailru/easyjson\n\n[baked_in.go]:\n https://github.com/go-playground/validator/blob/master/baked_in.go\n\n[parseFieldTags]:\n https://github.com/mailru/easyjson/blob/master/gen/encoder.go#L68\n\n[GitHub]:\n https://github.com/rednafi/examples/tree/main/fun-with-struct-tags\n\n[user_easyjson.go]:\n https://github.com/rednafi/examples/blob/main/fun-with-struct-tags/easyjson/user_easyjson.go",
"title": "Peeking into Go struct tags"
}