AlgoMaster Logo

Reading Struct Tags

Medium Priority16 min readUpdated June 6, 2026

Struct tags are the thin string of metadata that turns plain Go structs into JSON payloads, SQL rows, validated input, and configuration objects. The previous chapter covered how to walk a struct's fields with reflection; this one zooms in on the reflect.StructTag value attached to each field, the conventions every popular library has agreed on, and how to parse tags yourself when you need to build something custom. This lesson builds a small but real validator that reads validate:"..." tags and rejects bad input.

What a Struct Tag Actually Is

A struct tag is a string literal that sits between a field's type and the line break. In Go's grammar, it's an optional component of a FieldDecl, and the compiler stores it as a raw string with no interpretation. The conventions for what's inside the string are entirely a community agreement, not a language feature.

The whole backticked string is what reflect.StructField.Tag returns. There's nothing magic about the spaces or the colons; they're a convention that the standard library, drivers, and validators all happen to follow. The encoder reads it the same way your code can.

The tags are almost always written with backticks rather than double-quoted strings, because backticked strings let you embed double quotes without escaping. A double-quoted version would need a backslash before every inner quote, and the result is unreadable. Backticks turn the tag into a clean key/value soup.

The Conventional Format

Real-world tags follow a format that's not specified anywhere in the language but is universal across the ecosystem: space-separated key/value pairs, where each value is a double-quoted string.

A tag value usually has the shape name,option1,option2. The first comma-separated piece is the primary value, often the field's name in the target representation. Anything after the first comma is a list of options.

The outer level is keys (one per library), and each key's value is its own mini-language. The JSON encoder uses commas to separate the name from options. The validate libraries usually use commas too, but with key=value pairs. The db keys typically just hold a column name with no options. Every consumer is free to parse its half of the tag however it wants.

That freedom is a strength and a hazard. The strength is that one struct can serve four or five different libraries from the same field definitions. The hazard is that no central parser checks the syntax; a typo silently produces wrong behavior with no compiler complaint.

One struct, three independent metadata channels. The json value drives encoding/json, the db value drives a SQL mapper, and the validate value drives a validator. The struct doesn't have to know which library reads which key; each library asks for its own key by name.

Tag.Get vs Tag.Lookup

reflect.StructTag has two methods for pulling a value out of the tag string: Get(key) returns the value or an empty string if the key isn't present, and Lookup(key) returns the value and a boolean that distinguishes "missing" from "present but empty".

ID has a non-empty db value, so both methods return the same thing. Name has the key but its value is the empty string, which Get can't distinguish from missing; Lookup reports ok=true because the key is present. Price has no db key at all, so Lookup reports ok=false.

The practical difference shows up in opt-in schemes. A SQL mapper might say "if the field has a db tag, even an empty one, treat the field as a database column; if there's no db tag, skip the field entirely". That logic needs Lookup. Get can't tell "skip me" from "use the default name".

MethodReturnsDistinguishes Missing?Use For
Get(key)The value or ""NoConvenience when missing and empty should both fall back to a default
Lookup(key)Value and ok boolYesOpt-in schemes where presence of the key carries meaning

For most JSON-style tags, Get is fine because the JSON encoder treats a missing tag and an empty tag value the same way (fall back to the field name). For validators, configuration loaders, and SQL mappers, Lookup is more honest because it tells you whether the developer made a deliberate choice.

Three fields, three different intents. Lookup lets the program tell them apart and react accordingly. A Get-based version would lump Volume and Internal together, which is the kind of bug that ships and lives for months.

Parsing Comma-Separated Options

Once you have a tag value from Get or Lookup, you usually need to split it. The standard JSON convention (name plus options) is the most common pattern, and the code to parse it fits in a few lines.

strings.Split does the heavy lifting. The first piece is always the name. The remaining pieces are options, and the parser handles only the ones it cares about (here, omitempty and string); unknown options are ignored without error, which mirrors what encoding/json does. That tolerance is intentional: if a future Go version adds a new option, old code that doesn't know about it shouldn't break.

The same approach handles validation libraries. The popular go-playground/validator library uses commas to separate rules and = to separate rule names from arguments. Parsing it is the same strings.Split plus one more split per piece.

strings.Cut splits on the first =. If there's no =, arg is "" and ok is false; the rule has no argument, which is fine for required. The oneof rule's argument is itself space-separated, which is a third convention layered on top. This is the messy reality of parsing tag conventions: every library invents its own micro-language, and you read the docs to know what each separator means.

A Tiny Field Renamer

A simple use case for reading tags is mapping struct field names to a flat map[string]any, where the keys come from the json tag rather than the Go field name. This is the same renaming that encoding/json does, but stripped down to its essential reflection logic.

toMap walks the fields. For each one, it reads the json tag, takes the first comma-separated piece as the name, and applies two rules: a name of - means skip the field, an empty name means fall back to the Go field name. InternalID has the - tag and is absent from the result. Notes has no tag, so the Go name (Notes) carries through unchanged.

That's not a real JSON encoder; it skips many edge cases (omitempty, string option, embedded structs, unexported fields). The reflective core of every library that "reads tags and does something" follows this same pattern: walk the fields, read the relevant tag, parse it into a small struct of decisions, then act.

Building a Mini Validator

The most useful tag-driven tool you can build in 50 lines of code is a validator. The shape is straightforward: walk the fields, read each one's validate tag, parse it into a list of rules, and check the field's value against each rule. Errors get collected and returned.

The validator walks each field, reads validate, and applies rules. good passes silently. bad collects multiple problems and returns them joined into one error (Go 1.20+'s errors.Join is perfect for this). Each failing rule produces a ValidationError with the field name, the rule name, and a human-readable message. errors.Join glues them together.

The validator does not handle nested structs, slices of structs, or pointer dereferencing, all of which a production validator like go-playground/validator does. But the core mechanic, walk fields plus read tag plus apply rules, is exactly the same. Production validators add caching (so the tag parsing happens once per type) and a richer rule library.

How Real Libraries Use Tags

The mechanism stays the same across the Go ecosystem, but each library carries its own conventions and gotchas.

encoding/json (standard library) reads the json key. The value is name,option1,option2. Names like - and the trailing-comma form -, are special. Options include omitempty, string, and (Go 1.24+) omitzero. The encoder caches the parsed tag per type the first time it encounters a struct.

encoding/xml reads the xml key. The grammar is similar to json but with XML-specific options: ,attr marks a field as an XML attribute rather than a child element, ,chardata puts the field's value as raw character data, ,innerxml captures unprocessed inner XML. The conventions overlap with json but aren't identical.

database/sql doesn't use tags directly. The Rows.Scan method takes pointers to fields by position, not by name. Wrappers like sqlx and gorm add tag-driven column mapping: sqlx reads db:"column_name", gorm reads gorm:"column:name;type:varchar(255)" (semicolons, not commas, because GORM picked that separator).

go-playground/validator reads validate. The grammar is comma-separated rules with = separating rule names from arguments. Some rules take arguments (min=3), some don't (required). The library also supports namespaces and dive operators for nested data.

Configuration loaders like kelseyhightower/envconfig and spf13/viper use their own keys (envconfig, mapstructure). The pattern repeats: a single key, a name and optional sub-arguments, library-specific options.

LibraryTag KeyValue FormatNotes
encoding/jsonjsonname,omitempty,string,omitzeroCaches per type
encoding/xmlxmlname,attr,chardata,innerxmlDifferent option vocabulary
sqlxdbcolumn_nameNo options, just a name
gormgormcolumn:x;type:y;indexSemicolon-separated
go-playground/validatorvalidaterule1,rule2=arg,rule3=argEquals separates rule from arg
kelseyhightower/envconfigenvconfigENV_VAR_NAME,default=xComma-separated options

Every consumer follows the same outer rule (space-separated key:"value") but invents its own value grammar. That's what you're agreeing to when you adopt a library: their conventions, their parser, their gotchas. The Go runtime's reflect.StructTag doesn't enforce any of it; it just hands you the raw string.

Multi-Tag Fields and Order

It's normal for one field to carry tags for three or four libraries at once. The order doesn't matter for the consumers, because each library looks up its own key by name. Whitespace between key/value pairs is required, but the spacing is forgiving: one space, several spaces, or a tab all work.

The third field declares its tags in json, validate, db order; the first two declare them in json, db, validate order. Both work identically because Tag.Get does its own lookup. The visual alignment is a style choice; some teams align columns for readability, others run everything together. The Go compiler doesn't care.

What the compiler does care about is that the tag is a single string literal. A line break inside the backticks works (the string can span lines), but the backtick characters themselves must be present.

What Happens with Malformed Tags

reflect.StructTag is permissive about syntax. A tag that doesn't follow the conventional format doesn't cause a compile error or a panic; it just returns empty strings from Get and (false, ...) from Lookup for any key the parser can't find.

A is well-formed and parses. B has a space after the colon; the parser bails and returns nothing. C lacks quotes around the value; same result. D has good json:"d" followed by garbage; the parser took the first valid pair and stopped, so Get("json") returns "d". E uses a comma between pairs instead of whitespace, which doesn't match the grammar; the parser fails on the first piece and returns nothing.

The silent failure is the trap. The code compiles, no error is raised, and the only sign of a problem is that the JSON output doesn't have the keys you expected. go vet catches the most common shape mistakes (mismatched quotes, missing colons), which is one good reason to run it in CI.

The JSON encoder couldn't read the tag, so it fell back to the Go field name and emitted "Name" (capital N) instead of "name". A developer who expected lowercase JSON keys will catch this in testing. A developer who didn't notice will ship a bug. go vet flags this exact case with the message "struct field tag does not look like a known tag format".

Quiz

Reading Struct Tags Quiz

10 quizzes