AlgoMaster Logo

JSON with Structs

Last Updated: May 17, 2026

10 min read

JSON is the format every web API, config file, and message queue payload seems to speak, so converting between Go structs and JSON is a core skill. Go's encoding/json package handles both directions through Marshal (struct to JSON) and Unmarshal (JSON to struct), and it leans on the struct tags from the previous lesson to control field names, omit zero values, and skip fields entirely.

Marshal: Struct to JSON

The fastest way to turn a Go value into JSON is json.Marshal. It returns a []byte, which you can print, write to a file, or send over the network.

A few things are worth pointing out about the output. The field names in the JSON exactly match the Go field names, capitalization and all. The keys appear in the same order they were declared on the struct. The result is a compact JSON object with no extra whitespace, which is what you want over the wire but not what you want when debugging in a terminal.

For human-readable output, json.MarshalIndent takes a prefix and an indent string and produces pretty-printed JSON:

The first argument to MarshalIndent is a line prefix (almost always "") and the second is the indent string (two spaces is a common choice). Every nested level adds another copy of the indent.

Unmarshal: JSON to Struct

Going the other way uses json.Unmarshal. It takes a []byte of JSON and a pointer to the destination value, and fills the destination in place.

Two details matter here. First, the second argument has to be a pointer (&p), because Unmarshal writes into the value you pass. Passing the value directly produces an error like json: Unmarshal(non-pointer Product). Second, the JSON keys have to match field names that encoding/json can find on the struct. That matching is what the next two sections are about.

Field Names and Exporting

The rule for field name matching is simple but easy to get wrong: only exported fields (those starting with an uppercase letter) are visible to encoding/json. Unexported fields are skipped silently in both directions.

The password field is gone from the output. This is sometimes what you want (passwords genuinely should not be marshaled) and sometimes a bug (you typed a lowercase field name by accident and now wonder why your JSON is empty). The "missing field" version of this bug is one of the most common Go beginner traps, so when JSON behaves strangely, the first check is "is the field exported?".

By default, the JSON key is the Go field name verbatim, including case. Name becomes "Name", not "name". Most JSON APIs in the wild use lowercase or snake_case keys, which is why you almost always want to override the default with a tag.

The json:"key" Tag

A struct tag of the form ` json:"key" ` renames the field in JSON. The Go field name is for your code, the JSON key is for the wire format, and the tag bridges them.

The same tag drives unmarshaling. Given JSON with lowercase keys, the decoder finds the matching Go fields through the tag, not the field name:

Notice that the field still has to be exported, even with a tag. The tag controls the name; exporting controls visibility. name string \json:"name"\` is invisible to encoding/json` for exactly the same reason as a plain unexported field.

The mapping between struct fields, tags, and JSON keys is what encoding/json is doing under the hood. The next diagram shows the round trip.

Tags sit between the Go struct and the JSON form. Marshal walks the struct, looks up each exported field's tag (or falls back to the field name), and writes the JSON. Unmarshal walks the JSON keys, looks up the field whose tag matches each key, and writes into that field. The same tags drive both directions.

Tag Options: omitempty, dash, and string

The json tag supports a few options after the key, separated by commas. The three you'll use constantly are omitempty, -, and string.

omitempty

omitempty skips the field when its value is the zero value for that type. The zero values are: false for bool, 0 for any numeric type, "" for string, nil for pointers, interfaces, slices, maps, and channels, and empty arrays for fixed-size arrays.

Phone is "" and Loyalty is 0, both zero values, so both are omitted from the output. Name and Email are non-empty, so they appear. This keeps payloads small and avoids confusing the receiver with fields that don't carry real information.

There's one classic gotcha with omitempty: it can't tell "the user set this to zero on purpose" from "the user didn't set it at all". A Quantity int \json:"quantity,omitempty"\` will drop the field when Quantity == 0, even if zero was the intended value. When that distinction matters, use a pointer (*int`) or a custom type that tracks "was this set".

- (dash) to skip entirely

A single dash as the tag value means "never include this field in JSON, in either direction":

PasswordHash is exported (so encoding/json would normally see it) but the - tag tells the package to ignore it in both Marshal and Unmarshal. Even when the incoming JSON tries to set PasswordHash, it's discarded. This is the right way to hold sensitive data on a struct that also gets serialized.

If you specifically want a JSON key named "-", use ` json:"-," ` (note the trailing comma). That's a rare need, but the language allows it.

string to encode numbers as JSON strings

The string option marshals a numeric or bool field as a JSON string, not a JSON number. This is sometimes needed when the receiving system uses a language without a 64-bit integer type (JavaScript's Number loses precision above 2^53) or when an API spec demands it.

The ID field comes out as "9007199254740993" (a JSON string), while Total stays as the number 4999. The string option doesn't change the Go type; ID is still int64 in your code. It only changes how the value is written and read on the JSON side.

Nested Structs

Nested structs serialize as nested JSON objects, and the encoder handles them automatically. There's no extra configuration; if a field's type is itself a struct, its contents appear under that field's JSON key.

Unmarshaling works the same way. As long as the struct fields and tags match the JSON structure, the decoder fills the nested struct without any special handling:

Nesting works to any depth. A struct that contains a struct that contains a struct serializes and deserializes the same way. This is what makes encoding/json pleasant to use for real-world payloads, which are almost always nested.

Slices and Maps of Structs

Slices of structs become JSON arrays. Maps of structs become JSON objects keyed by the map keys. The encoder handles both transparently.

A nil slice marshals as null, while an empty slice ([]Product{}) marshals as []. That difference catches people, because both have len(s) == 0. If the receiving system expects an array and you send null, you've shipped a bug. To force an empty array, initialize with a literal: Items: []Product{}.

Maps work the same way, but the keys have to be strings (or types that JSON can serialize as a string):

The encoder sorts map keys alphabetically in the output, which gives stable, diff-friendly JSON. That's a nice property when you're checking JSON into version control or comparing payloads.

Unknown Fields

By default, Unmarshal ignores JSON keys that don't correspond to any field in the target struct. This is forgiving behavior that lets your code keep working when the producer adds new fields you don't care about.

color and weight are silently discarded. For most APIs this is the behavior you want, because it means a server can add fields without breaking older clients.

When you want the opposite (reject any unknown field as a possible typo or unauthorized input), use json.Decoder with DisallowUnknownFields:

Strict mode is a good fit for config files and admin APIs, where unexpected keys probably mean a typo. It's a bad fit for public APIs that need to stay forward-compatible.

Working with Arbitrary JSON

Sometimes you don't know the JSON shape in advance, or it varies enough that defining a struct for it isn't worth it. Two tools cover that case: map[string]interface{} for fully dynamic JSON, and json.RawMessage for "hold this part raw and decide later".

map[string]interface{}

Decoding into a map[string]interface{} (often written as map[string]any since Go 1.18) gives you a tree of generic values. JSON objects become maps, arrays become []interface{}, numbers become float64, strings stay strings, booleans stay booleans, and null becomes nil.

Every access needs a type assertion because the values come back as interface{}. That makes the code wordy and easy to break: data["tags"].([]any)[0] panics if tags doesn't exist or isn't an array. Reach for map[string]any when the JSON shape is genuinely unknown, not when you're being lazy about defining a struct.

json.RawMessage

json.RawMessage is a []byte with custom marshaling that holds raw, undecoded JSON. Use it when one part of a payload has a known shape and another part has multiple possible shapes that you'll decode later based on a discriminator.

The first Unmarshal reads Type and stores the payload bytes verbatim. The second Unmarshal (inside the switch) decodes those bytes into a struct matched to the event type. RawMessage avoids re-decoding the outer object just to look at the type.

Custom Marshaling Preview

When the default behavior isn't what you need, a type can implement MarshalJSON() ([]byte, error) and UnmarshalJSON([]byte) error to control its own serialization. This is most common for types like custom date formats, enums backed by integers that should serialize as strings, or values that need extra validation.

OrderStatus is an int under the hood, but the JSON shows the human-readable name because the type implements MarshalJSON. The matching UnmarshalJSON would read a string and map it back to the integer constant.

Common Pitfalls

A few traps catch most newcomers to encoding/json and a fair number of experienced developers too.

Forgetting to Export the Field

This is the most common bug, by a wide margin. A field with a lowercase first letter is invisible to encoding/json, even if you've added a tag.

The struct has tags but no exported fields, so the marshal produces an empty object. go vet catches this with the message "struct field has json tag but is not exported", which is one of the best reasons to run go vet on your code regularly.

time.Time Formatting

time.Time implements MarshalJSON, so it serializes automatically, but the format is RFC 3339 by default. That's the right default for APIs, but it might not match what the consumer expects.

If the API contract wants Unix timestamps, a date-only format, or a non-standard layout, the fix is a custom MarshalJSON on a wrapper type. The zero time.Time also marshals as "0001-01-01T00:00:00Z", which is rarely what you want; omitempty doesn't help here because time.Time is a struct, not a comparable zero like 0 or "". Common workarounds include using *time.Time (where nil triggers omitempty) or implementing custom marshaling that emits null for the zero value.

Integer Overflow

JSON has one number type, and the decoder maps it to whatever Go type the target field has. If the JSON number is too large for the target type, the decoder returns an error.

The fix is usually to use int64 or uint64, which can hold the value. When decoding to interface{}, all JSON numbers become float64 regardless of size, and float64 only has 53 bits of integer precision, so very large integers lose accuracy silently. For payloads with IDs larger than 2^53, the string tag option (sending the number as a JSON string) is the standard workaround.

Summary

  • json.Marshal converts a Go value to JSON bytes. json.MarshalIndent does the same with pretty-printed output.
  • json.Unmarshal converts JSON bytes back into a Go value. The destination must be a pointer.
  • Only exported fields (uppercase first letter) are visible to encoding/json. Unexported fields are silently skipped, in both directions.
  • The json:"key" tag renames a field in JSON. Tag options like omitempty (drop zero values), - (never serialize), and string (numbers as JSON strings) cover most real-world needs.
  • Nested structs, slices of structs, and maps of structs all serialize automatically with no extra configuration.
  • Unknown JSON fields are ignored by default. Use json.NewDecoder(r).DisallowUnknownFields() for strict mode.
  • For dynamic JSON, map[string]any gives you generic decoding and json.RawMessage defers decoding until you know what shape to expect.
  • Custom marshaling through MarshalJSON and UnmarshalJSON handles enums, custom date formats, and any case where the default behavior doesn't fit.

That wraps up the structs section. The next file in this folder is the capstone lab, where everything from struct basics, exported fields, embedding, tags, constructors, equality, and JSON come together in one E-Commerce program.