Last Updated: May 17, 2026
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.
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.
Cost: MarshalIndent is slightly more expensive than Marshal and produces larger output. Use it for logs, config dumps, and debugging. For network payloads, stick with Marshal.
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.
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.
json:"key" TagA 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.
The json tag supports a few options after the key, separated by commas. The three you'll use constantly are omitempty, -, and string.
omitemptyomitempty 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 entirelyA 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 stringsThe 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 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 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.
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.
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.RawMessagejson.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.
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.
A few traps catch most newcomers to encoding/json and a fair number of experienced developers too.
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 Formattingtime.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.
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.
Cost: Decoding into interface{} or map[string]any is convenient but expensive: every value becomes a heap-allocated interface{}, and numbers lose integer precision above 2^53. For hot paths or large payloads, define a struct.
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.encoding/json. Unexported fields are silently skipped, in both directions.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.json.NewDecoder(r).DisallowUnknownFields() for strict mode.map[string]any gives you generic decoding and json.RawMessage defers decoding until you know what shape to expect.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.