AlgoMaster Logo

Struct Tags for JSON

High Priority22 min readUpdated June 6, 2026

JSON encoding in Go is driven almost entirely by struct tags. The previous chapter showed how Marshal and Unmarshal move bytes back and forth; this one zooms in on the small string of metadata that controls every detail of the output, from field names to whether zero values appear at all. The defaults are sensible, but real APIs almost always need overrides, and the difference between a clean payload and a confusing one usually comes down to a few characters inside a backticked tag.

Tag Syntax Recap

A struct tag is a backticked string written right after a field's type. The encoder reads it through reflection and uses the value associated with the json key to drive its decisions. The shape that matters for JSON looks like this:

The json value follows a simple grammar: a name, then zero or more comma-separated options. The name controls what the field is called in JSON. The options control how the value is encoded. The rest of this chapter is just a tour of those options and their less obvious corners.

The next diagram captures the path each field takes through encoding. The struct definition is the input. The encoder reads the tag, applies the rules, and writes the matching JSON key/value pair. If the rule says "drop this", nothing is written.

The "drop field" branch is what omitempty and - control. The "write value with options" branch is where string and similar options live. Everything else is just naming.

Renaming Fields

The simplest use of the tag is to override the field name. Go convention says fields are CamelCase. JSON convention almost always says snake_case or camelCase. The tag bridges the two without renaming the Go field.

The Go code keeps names that read naturally in Go, while the JSON follows whatever convention the API documents. The renaming is two-way: the same tag drives both Marshal and Unmarshal, so a payload with "first_name" decodes into the FirstName field without any extra work. The name itself can include hyphens, underscores, dots, and digits; the encoder writes whatever you give it verbatim.

omitempty: Skipping Zero Values

The omitempty option tells the encoder to skip a field whose value is the zero value for that field's type. It comes after the name, separated by a comma: ` json:"phone,omitempty" `. This keeps payloads small and avoids sending fields that don't carry real information.

The rule is "zero value for the type". For each common Go type, that means:

TypeTreated as Empty When
boolfalse
Numeric types (int, float64, etc.)0
string""
Pointers, interfacesnil
Slicesnil only (not just length zero, see below)
Mapsnil only (not just length zero, see below)
Channelsnil
Arrays (fixed size)Never omitted, even if all elements are zero
Structs (non-pointer)Never omitted, even if every field is zero

A struct value, like time.Time or a nested address struct, is never empty in the omitempty sense, no matter what its contents are.

Here's the common case where omitempty works exactly as you'd expect:

Phone is "", Loyalty is 0, and Active is false. All three are zero values for their types, all three have omitempty, and all three vanish from the output. Only Name (which has no omitempty) and Email (non-empty) appear.

The Zero-Length Slice and Map Gotcha

omitempty on a slice or map is more subtle than you might expect. The rule says the encoder checks whether the value is the zero value, which for slices and maps means nil. An empty non-nil slice ([]int{}) is not nil, so omitempty does not drop it.

Here's the demonstration:

Items is nil and disappears. Tags is []string{}, which has length zero but is a real slice header, so the encoder emits []. This is a common bug for those who initialize an empty slice for safety and then wonder why omitempty doesn't drop it. If you want length-zero slices and maps to be dropped, either leave them nil (don't initialize), or check the length yourself and reset to nil before marshalling.

Before Go 1.24, that was the whole story for the standard omitempty. The newer omitzero option handles structs and length-zero containers in a more useful way.

The time.Time Gotcha

time.Time is a struct. Its zero value is a struct with all internal fields zeroed, not nil. The encoding/json package's omitempty does not look inside structs to check whether they're "empty"; it only checks the zero-value rule, and a non-pointer struct is never the zero value in this sense.

DeliveredAt is the zero time.Time, which marshals as "0001-01-01T00:00:00Z". omitempty did nothing. The workarounds before Go 1.24 were always the same: switch to a *time.Time so the zero state is nil, or write a custom marshal method. Since Go 1.24, omitzero solves this directly, which the dedicated section below covers.

Skipping a Field: -

A single dash as the tag value tells encoding/json to ignore the field entirely, in both directions. Marshal won't write it. Unmarshal won't read it. The field stays a normal exported field in Go code, but the encoder treats it as if it weren't there.

The marshal output skips PasswordHash because of the - tag. The unmarshal refuses to write into PasswordHash, even though the incoming JSON tried to set it. That's the right behavior for sensitive fields like password hashes, API secrets, or any value that should live on the struct but never cross the network.

The Literal "-" Field Name

Once in a while, a JSON contract actually has a key named "-". To distinguish "skip this field" from "name this field literally minus-sign", encoding/json uses a trailing comma: ` json:"-," . The dash followed by a comma and an empty options list means "the field's JSON key is literally -`".

Subtract has the tag json:"-," (with the trailing comma), so its JSON key is the literal string "-". Add has the tag json:"-" (no trailing comma), so it's skipped entirely. This is a niche feature; you'll go years without needing it. The two-character difference matters when you do.

Encoding Numbers as Strings: ,string

The string option tells the encoder to wrap the field's value in double quotes, encoding it as a JSON string instead of a JSON number, bool, or whatever the type's natural JSON form would be. This is useful when the consumer can't handle large integers natively (JavaScript's Number loses precision above 2^53) or when an API contract demands string-encoded numbers.

ID, Total, and Approved all come out as JSON strings because of the ,string option. The Go types haven't changed; ID is still int64, Approved is still bool. Only the wire representation differs.

The option works for int, all sized integer types, float32, float64, and bool. It does not work for strings (which are already strings) or arbitrary types. Applying it to a string field is a silent no-op rather than an error, which is a common surprise for those who copy a tag from one field to another and forget to remove ,string.

Decoding works the same way in reverse. With ,string on a numeric field, the decoder expects a JSON string and parses it back to the Go type. If the JSON sends a bare number when the tag expects a string, the decoder returns an error like json: invalid use of ,string struct tag, trying to unmarshal unquoted value into int64. The tag is strict on the way in: if the contract says "send numbers as strings", the decoder enforces that, which prevents silent precision loss on large integers.

Combining Options and Empty Names

Options chain together with commas. You can have a name, omitempty, and string all on the same field: ` json:"id,omitempty,string" `. The order has to be: name first, then options. Repeating an option, or putting an unknown one, is silently ignored.

The full order shows every field with its ,string option applied (the numbers are quoted). The partial order has the zero values for Total (0.0), CouponCode (""), and ReferralFee (0), so omitempty drops all three. Each option does its job independently.

There's also a special case for the name. If you leave it empty but include options, the field uses its Go name and the options still apply: ` json:",omitempty" `. The leading comma is the marker.

The JSON keys are the Go field names (Rating, Comment) because the name half of each tag is empty. The options still take effect: ProductID is zero so it's dropped, Rating is encoded as a string. Empty-name tags are useful when you want the option but the default Go name is already what you want for the JSON.

Case Matching During Decode

Marshalling always uses the exact name from the tag (or the exact field name if there's no tag). Unmarshalling is more forgiving. The decoder tries an exact match first; if no field has that exact name in its tag, it falls back to a case-insensitive comparison.

All three decode. The decoder always prefers an exact match, but when one isn't found, it scans for a case-insensitive match. That tolerance helps when producers don't agree on case conventions, but it can also hide typos. The "exact match first" rule matters if you have two fields whose tags differ only in case: an incoming "id" always goes to the field tagged json:"id", never to the one tagged json:"ID".

Embedded Structs and Tag Promotion

When a struct has an anonymous (embedded) field of another struct type, the encoder promotes the embedded struct's fields to the outer level by default. The fields appear as if they were declared directly on the outer struct, and their tags follow them.

The Address fields appear at the top level of the JSON, flattened in alongside name and email. The tags on the inner struct (json:"street", etc.) drive the names. The outer struct didn't need to redeclare them.

If you give the embedded field a tag of its own, the encoder treats it as a named field instead, and the inner fields nest under that key. Changing the declaration to Address Address \json:"address"\`` produces this output:

The decision between flat and nested is yours, and the tag on the outer field is what controls it. Field name conflicts during promotion follow Go's normal rules: a field declared directly on the outer struct shadows a promoted field with the same name, and the promoted one is silently skipped. That avoids ambiguous output but can hide bugs if you didn't realize you had a clash.

omitzero (Go 1.24+)

Go 1.24 introduced a new tag option, omitzero, that fixes the two main pain points of omitempty: structs and length-zero containers. omitzero drops a field when its value equals the zero value for the type, where "zero value" is checked more thoroughly than omitempty does.

For most basic types, omitzero behaves the same as omitempty. The differences show up for structs, time values, and types that implement an IsZero() bool method.

DeliveredAt is the zero time.Time, and omitzero correctly drops it. Compare this with the earlier omitempty example, where the same zero time leaked through as "0001-01-01T00:00:00Z". For any code that builds payloads where a field "isn't set yet" is a meaningful state, omitzero removes the need for *time.Time workarounds.

omitzero also handles the IsZero() bool method. If the field's type has that method, the encoder calls it and uses the result. time.Time has IsZero, which is how the example above works. You can give your own types the same treatment:

The second cart has the zero Discount value (no code, no percent). Discount.IsZero() returns true, and omitzero drops the field. The first cart has a real discount, IsZero() returns false, and the field is included.

For length-zero slices and maps that are non-nil, omitzero also does the right thing: it drops them. That fixes the []string{} vs nil confusion that omitempty has.

TagBehavior for time.Time ZeroBehavior for []string{} (Empty)Calls IsZero() Method
omitemptyField kept (struct is never "empty")Field kept (slice is non-nil)No
omitzeroField droppedField droppedYes

omitempty and omitzero can both be used on the same field if you want both behaviors (json:"x,omitempty,omitzero"), but in practice omitzero is what most code wants once it's available. If your project's minimum Go version is below 1.24, you're stuck with omitempty and the workarounds. If you've moved to 1.24 or later, omitzero is the better default for any field that's a struct, slice, map, or time.

Common Bugs

A handful of mistakes account for almost every "my JSON is wrong" bug.

Lowercase First Letter

encoding/json only operates on exported fields, the ones whose names start with an uppercase letter. A tag on an unexported field is parsed by reflection, but the field itself is invisible to the encoder. The most confusing version of this bug is when the field name and the tag both look right, but the field happens to be lowercase.

Two fields, both with tags, and the JSON is empty. The fields are unexported, so encoding/json never sees them. Running go vet on the file flags this with the message "struct field has json tag but is not exported", which is one of the best reasons to keep go vet in your build pipeline. The fix is to capitalize the field names: ID, Total.

Missing or Wrong Quotes

The tag value must be wrapped in double quotes inside the backticks, and there must be no space after the colon. Both of these mistakes parse as "no tag" rather than as an error, so the encoder silently falls back to the Go field name.

Both tags are present, but neither parses correctly. The encoder falls back to the Go field names, so the JSON has "Name" and "Price" instead of "name" and "price". The fix is ` json:"name" and json:"price" `, with no space after the colon and double quotes around the value.

Wrong Key Order in Tag

The grammar is: name first, then options. Putting an option before the name (or omitting the name when you meant to use it) leads to surprises.

In Bad, the encoder reads omitempty as the JSON key name (because the first comma-separated piece is always the name) and treats quantity as an unknown option, which it silently ignores. So the field appears under the literal key "omitempty". In Good, the name is quantity and the option is omitempty, which drops the field when it's zero. Order matters, and the encoder never warns about it.

Capitalizing the Tag Key

The tag key has to be the lowercase json. The encoder calls Tag.Get("json") on each field, and that call is case-sensitive. Writing JSON:"name" or Json:"name" stores the tag but no consumer looks for it.

The capital JSON key is stored, but encoding/json looks for the lowercase json and finds nothing, so the field falls back to its Go name. Always lowercase the tag key. Every standard library encoder follows this convention.

Forgetting That Empty Slices Are Not Nil

Tied to omitempty's slice rule from earlier: code that carefully initializes an empty slice "for safety" then can't figure out why omitempty doesn't drop it. An Items: []string{} in a constructor produces "items":[] in the output, not a missing field. The choices are to leave the slice nil, switch to omitzero (Go 1.24+), or accept [] in the JSON, which is often fine for API consumers. Each option produces different output, so the choice depends on what API consumers expect.

Quiz

Struct Tags for JSON Quiz

10 quizzes