Go struct tags: explicit beats clever
Go’s standard library picked a simple contract for JSON marshaling: public fields serialize with their Go names unless you tag them otherwise. That is it. One line:
type Customer struct {
ID string `json:"id"`
Email string `json:"email"`
} But the JSON world mostly speaks snake_case, while Go insists on PascalCase for anything exported, which means those tags are never optional in practice — every field on a typical API struct needs one. The question is not whether to use struct tags; it is what to put in them.
Three strategies see most of the traffic, and only one of them is right for most Go programs.
1. Explicit, per-field tags
The conservative choice, and the one this tool defaults to:
type Customer struct {
ID string `json:"id"`
FirstName string `json:"first_name"`
CreatedAt int64 `json:"created_at"`
IsVerified bool `json:"is_verified"`
}
Verbose, yes. But the intent is on the page. A reader sees exactly what
comes in and what goes out, one field at a time. A code reviewer can
audit the mapping without running the program. When the API adds
customer_tier, you add one tag. No action-at-a-distance, no
magic.
2. Package-level default naming strategy
Some libraries (go-chi/render, custom wrappers) let you set
a global rule: “all fields are lower_snake_case.” Clean-looking
on the struct side:
type Customer struct {
ID string
FirstName string
CreatedAt int64
IsVerified bool
}
You have pushed a load-bearing convention into a config file or an
init() somewhere. A teammate who reads the struct in
isolation cannot tell what the JSON output looks like. The convention
travels with the project, not the code.
3. Custom MarshalJSON / UnmarshalJSON
The escape hatch for genuine polymorphism — a field that is a string in one context and an object in another, or a timestamp format the standard library does not handle. Powerful, expensive, easy to get wrong. Not the right tool for “I want snake_case field names.”
The struct tag is a small promise between a Go author and a JSON emitter. Write it explicitly.
— the house styleThe default this tool writes is the explicit, per-field kind. The reason is simple: it is the only strategy that preserves intent inside the struct. You can grep for a field name and see both the Go identifier and the wire name in the same line. When the API changes — a field renamed, a type widened — the diff is one tag, not a settings file migration.
Questions that come up
What about acronyms?
Go’s style guide wants URL, not Url. JSON
APIs are less consistent. The tool preserves the all-caps form in Go
while matching whatever the JSON source used in the tag:
type Link struct {
URL string `json:"url"` // JSON "url", Go URL
PostID string `json:"post_id"` // JSON "post_id", Go PostID
APIToken string `json:"api_token"` // JSON "api_token",Go APIToken
} Should I use omitempty?
Only if the API contract allows omitting the field when it is the zero
value. omitempty means: don’t emit this key when the
field holds the Go zero value ("", 0,
false, nil). That is often not what
you want — a bool with omitempty cannot
distinguish “explicitly false” from “absent.” For
responses you are only reading, the tag is ignored during
unmarshaling. The tool does not emit omitempty by default
because wrong-direction optionality is a common bug.
What about pointers?
*string tells Go that the field is nullable. If the JSON has
"email": null, *string becomes nil;
if the key is missing, *string also becomes nil.
The nullable/optional distinction from the first essay does not translate
perfectly, because Go’s zero-value semantics blur the two cases.
Defensible default: use *T for any field that can be
null in the response, accept that missing and null look the
same in Go-land, document the distinction in a comment when it matters.
Should I use json:"-" to skip a field?
Rarely. The tool does not emit - tags; if a field is not in
the JSON, it is not in the struct. The - tag exists mostly
for round-trip types that hold both wire data and local state in one
struct, which is usually an anti-pattern — separate the DTO from the
model.
What about performance?
encoding/json uses reflection and is slower than it should
be. For high-throughput paths, json-iterator/go (drop-in
replacement), goccy/go-json (stricter, sometimes faster), or
bytedance/sonic (assembly, x86/ARM64) all matter once you
are past a few thousand parses per second. The struct tags are the same
across all of them — switching libraries does not require
regenerating types. Which is one more argument for keeping tags explicit
and portable.
The generated Go output from this tool is meant to be the starting point
of a types.go file you own from that point forward. Rename
fields if your project wants different Go names, reorder to taste, add
comments. The tags stay, because the tags are the contract.