← Essays
§ Essay

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 style

The 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.