The default JSON output that Go produces from a struct doesn't always match the format your API or storage layer expects. Money stored as cents needs to wire out as "$24.99", an enum value needs to read as "shipped" instead of 2, and a calendar date needs to land on disk as "2024-05-16" without a time component. The encoding/json package lets any type take full control of its own JSON representation by implementing the Marshaler and Unmarshaler interfaces, and there's a lighter TextMarshaler interface that works across JSON, XML, CSV, and environment variables at once.
The encoding/json package has a fixed view of how each Go type should look on the wire. An int64 becomes a JSON number, a string becomes a quoted JSON string, a time.Time becomes an RFC 3339 timestamp, and a struct becomes an object with one key per exported field. That mapping works for most types, but real systems often need something different.
Consider a Money type that stores values as int64 cents to avoid floating-point rounding bugs. The default JSON output is a raw integer:
That's correct for Go, but a frontend that expects "$24.99" can't display this without extra work, and a JavaScript client that converts 2499 to a float for display can re-introduce the rounding bug we were trying to prevent. The same issue shows up with enums (you want "shipped", not 2) and dates (you want "2024-05-16", not "2024-05-16T00:00:00Z").
The fix is to let the type itself decide what JSON it produces and what JSON it accepts. Go gives you four interfaces for that:
| Interface | Method | Used by |
|---|---|---|
json.Marshaler | MarshalJSON() ([]byte, error) | encoding/json only |
json.Unmarshaler | UnmarshalJSON([]byte) error | encoding/json only |
encoding.TextMarshaler | MarshalText() ([]byte, error) | encoding/json, encoding/xml, encoding/csv adapters, flag, env-var libraries |
encoding.TextUnmarshaler | UnmarshalText([]byte) error | Same set as TextMarshaler |
The encoder checks for these methods on each value it visits. If MarshalJSON is defined, it gets called and its bytes go straight into the output. If only MarshalText is defined, the encoder wraps the result in quotes and writes it as a JSON string. If neither is defined, the encoder falls back to the default reflection-based path.
The diagram shows the priority order the encoder uses. MarshalJSON wins if both are defined, because it produces raw JSON (a number, an array, an object, anything) while MarshalText always produces a quoted string. For an enum that wants to encode as a string, MarshalText is enough. For a Money type that wants to encode as either a string or a structured object based on context, MarshalJSON is the right choice.
json.Marshaler has one method:
When json.Marshal encounters a value whose type satisfies this interface, it calls the method and writes the returned []byte directly into the output stream. The returned bytes must be valid JSON. If they're not, json.Marshal returns an error. If the method itself returns a non-nil error, json.Marshal propagates that error to its caller.
A worked example with the Money type, marshaling as a decimal string like "24.99":
Both lines come out as quoted JSON strings. The trick is in the Sprintf format: the backtick-delimited Go string literal ` "%d.%02d" contains an actual leading and trailing double-quote character, so the bytes returned by the method already include the JSON string quotes. Forget those quotes and json.Marshal returns an error because the bytes 24.99 aren't a valid top-level JSON string. The lesson: the []byte returned by MarshalJSON` must be a complete, valid JSON value. For a string, that includes the surrounding double quotes. For a number, no quotes. For an object, the braces and field syntax.
The companion type for an Address that encodes as a single formatted line:
There's a useful trick here. Instead of building the quoted string by hand with Sprintf, the method calls json.Marshal(formatted) on the inner Go string. The standard library handles the quoting, the escaping of special characters (newlines, backslashes, double quotes), and the UTF-8 validation. A handwritten implementation that uses Sprintf("%q", ...) would also handle quoting, but json.Marshal knows about JSON's specific rules (for example, JSON requires for embedded NUL bytes, while Go's %q writes \x00).
Cost: Calling json.Marshal from inside MarshalJSON allocates a fresh []byte slice on every call. For a hot path that marshals millions of values, building the result with strconv.AppendQuote or directly into a pre-sized buffer is measurably faster. Profile before optimizing.
The Marshaler interface is satisfied by both pointer and value receivers, but with a catch we'll cover in detail later: if MarshalJSON has a pointer receiver, only *T satisfies the interface, not T. That means marshaling a value of type T (not *T) will skip the method and fall back to the default reflection path. We'll see this bite later in the chapter.
The decode side has its own interface:
The method receives the raw JSON bytes (just the slice that represents this value, not the surrounding document) and is responsible for parsing them and writing the result back into the receiver. Because the method has to modify the receiver, it must have a pointer receiver. A value receiver wouldn't be able to mutate the underlying value, so json.Unmarshal ignores UnmarshalJSON methods that aren't defined on *T.
Here's Money again, with both directions implemented:
A few things worth pointing out. The input data to UnmarshalJSON is the raw JSON slice for this value, including the surrounding quotes if the value is a JSON string. That's why the method calls strconv.Unquote first to strip the quotes and decode any escape sequences. If the input was a JSON object or array, the slice would contain {...} or [...] and a different parsing strategy would be needed.
The method handles errors carefully. Any malformed input (missing dollars, wrong number of digits for cents, non-numeric characters) returns a descriptive error that gets propagated all the way up to the caller of json.Unmarshal. This is what makes custom unmarshalers useful for input validation: a Money value can never be set to garbage, because the parser rejects it.
Cost: UnmarshalJSON allocates intermediate strings for Unquote and SplitN. For high-throughput parsers, writing a hand-rolled decoder that walks the bytes directly avoids both allocations. The standard library does this for built-in types.
The signature requires a pointer receiver because Go's method set rules say that *T has access to methods declared on both T and *T, but T only has access to methods declared on T. Since UnmarshalJSON needs to assign to fields of the receiver, the receiver must be a pointer. The encoder enforces this by only treating *T as satisfying the json.Unmarshaler interface when the method is declared with a pointer receiver.
Enums are a common case where MarshalText and UnmarshalText are the right choice. The Go OrderStatus type is an integer internally, but on the wire it should be a lowercase string like "placed", "shipped", "delivered". Using MarshalText means the type also works automatically with XML, CSV libraries that respect text encoders, and tools like the flag package or env-var parsers.
Notice that MarshalText returns the bare bytes placed, not "placed". The JSON encoder wraps the result in quotes automatically because the text encoder contract says the bytes represent a string. That's the difference from MarshalJSON, where the method owns the quotes.
The unmarshal side is just as clean. UnmarshalText receives the unquoted string delivered directly. The encoder strips the JSON quotes before calling the method. That's another convenience that UnmarshalJSON doesn't give you (where the input still has its quotes).
The same OrderStatus type now works with XML as well:
The XML encoder calls MarshalText for the same reason the JSON encoder does: when the field has no XML-specific marshaler, the text encoder is the next fallback. Defining MarshalText once gives you encode/decode support across every format that respects it, which is why it's the better default for simple types like enums.
When to pick each:
| Need | Use |
|---|---|
| Encode as a quoted string in JSON | MarshalText (simpler) or MarshalJSON |
| Encode as a JSON number, object, or array | MarshalJSON (MarshalText always produces a string) |
| Encode behavior differs between JSON and XML | MarshalJSON (only JSON sees it) |
| Type should also work in XML, CSV, env vars, query strings | MarshalText |
| Output shape depends on a nested struct | MarshalJSON (text can't produce structure) |
Cost: MarshalText is called once per value. The encoder adds two quote bytes around the result for JSON output, so the total allocation is slightly larger than a hand-tuned MarshalJSON. The difference matters only in profiling-grade hot paths.
The time.Time value in Go encodes as a full RFC 3339 timestamp by default: "2024-05-16T14:30:00Z". For a delivery date or birth date where the time component is meaningless, that's wrong. A common fix is to declare a Date type that wraps time.Time and marshals as "YYYY-MM-DD".
The MarshalJSON method handles the zero value explicitly by returning null instead of "0001-01-01", which would be a meaningless date. The UnmarshalJSON method accepts both a date string and an explicit null, mapping the latter to the zero value. Real systems often need this kind of three-state handling (present + valid, present + null, absent).
The constant dateLayout = "2006-01-02" is Go's reference date, the same digits used by every time.Format and time.Parse call. The pattern names each component (2006 for year, 01 for month, 02 for day) instead of using strftime-style %Y-%m-%d. It's idiomatic Go even though it looks unusual at first.
The Date type embeds time.Time by value, which means a Date value carries all of time.Time's methods (Year, Month, Day, IsZero, Before, After, Format, etc.) without forwarding. The custom marshalers override the default time.Time marshalers because Go's method resolution prefers methods on the outer type over methods on the embedded type.
This is the classic mistake. You want MarshalJSON to start from the default encoding and then tweak the result, so you call json.Marshal on the receiver inside the method:
The call json.Marshal(c) inside MarshalJSON looks at the type of c, sees it has a MarshalJSON method, and calls that method, which calls json.Marshal, which calls MarshalJSON, and the stack runs out. The encoder doesn't detect the loop.
The standard fix is to define an alias type that strips the method set:
The trick is on the line type Alias Customer. Alias is a new named type with the same underlying struct layout as Customer, but it doesn't inherit Customer's methods. So when json.Marshal looks at a value of type Alias, it doesn't see a MarshalJSON method and falls back to the default reflection-based encoder. The Alias(c) conversion is free at runtime because the layouts are identical.
The anonymous struct wraps Alias and adds an extra field (Domain) that gets computed from the original. This lets you reuse the default field-by-field encoding of the base struct while still injecting custom logic.
The same pattern works on the unmarshal side. If you want to parse fields normally and then do post-processing, define an alias and call json.Unmarshal on it:
Note the pointer dance: the alias wraps *Alias (a pointer to Alias), and the conversion (*Alias)(c) reinterprets *Customer as *Alias. The json.Unmarshal call writes into the original Customer through the alias view, then the method does its post-processing.
Cost: The alias trick adds one struct allocation per call (for the anonymous wrapper). It's negligible for most workloads, but pre-allocating buffers and writing a hand-rolled marshaler can save the allocation in tight loops.
The behavior of MarshalJSON depends on whether you declare it with a value receiver or a pointer receiver, and on what kind of value the encoder sees at the call site.
Both encode the same way because a method declared with a value receiver is in the method set of both T and *T. Now flip the receiver to a pointer:
The value version of c did not use the custom method. It fell back to the default reflection-based encoder and produced a JSON object with two fields. The pointer version used the method and produced the formatted string. Same type, same data, different output, all because of which receiver was used.
The rule is simple but easy to forget:
| Receiver type | T satisfies interface? | *T satisfies interface? |
|---|---|---|
Value receiver (c T) | Yes | Yes |
Pointer receiver (c *T) | No | Yes |
For MarshalJSON, the practical advice is: use a value receiver unless you have a specific reason not to. That way, both T and *T get the custom encoding. Common reasons to use a pointer receiver anyway:
For UnmarshalJSON, you have no choice. The method must have a pointer receiver because it has to mutate the receiver. The encoder only treats *T as satisfying json.Unmarshaler.
A subtle wrinkle: if your struct contains a field whose type has a pointer-receiver MarshalJSON, and that field is a value (not a pointer), the encoder still calls the method as long as the parent struct itself is addressable. The encoder takes the address automatically when it can. This makes value-typed fields work in most cases, but it fails when the value isn't addressable, for example a value inside a map:
This works in current Go versions because the encoder copies map values into addressable temporaries when needed. The safer approach, especially if you support older versions, is to use a value receiver whenever possible. Then the question of addressability never comes up.
The third common mistake is forgetting that the bytes returned from MarshalJSON must be a complete, self-contained JSON value. Easy to get wrong with fmt.Sprintf:
The bytes 4.5 stars aren't valid JSON. JSON numbers don't have trailing letters, and JSON strings need surrounding quotes. The encoder's downstream validator catches this and returns an error.
Three common shapes that need different syntax:
| Desired JSON output | Bytes to return | Example |
|---|---|---|
| String | Surrounding double quotes, with \", \\, etc. escaped | "24.99" |
| Number | Digits, optionally with . and e | 2499 or 24.99 |
| Object | Braces with "key":value pairs | {"cents":2499} |
| Array | Brackets with comma-separated values | [1,2,3] |
| Null | Literal null | null |
The safest way to produce a string is to call json.Marshal on a Go string, which handles all the escaping rules:
Now the function delegates the quoting to the standard library, which knows about every edge case (embedded NUL bytes, control characters, surrogate pairs in Unicode). For a number, the safest pattern is to use strconv.AppendFloat or strconv.AppendInt into a buffer rather than hand-formatting with Sprintf, because that guarantees the result is a valid JSON number.
For an object, the cleanest pattern is to declare a temporary struct with the desired layout and call json.Marshal on it:
This is the idiomatic Go pattern: build a struct that exactly matches the desired JSON shape, then let json.Marshal produce the bytes. It's much easier to read than concatenating strings, and the standard library handles every escaping rule for you.
Cost: Hand-rolled marshalers that build bytes directly into a buffer are faster than the aux struct + json.Marshal pattern because they avoid the reflection-based default path. For most code, the readability win outweighs the cost; for hot serialization paths, profile and decide.
Custom marshalers add complexity, hide behavior, and can mask bugs. Use them only when struct tags and the default encoder can't get the job done.
| Situation | Better approach |
|---|---|
| Rename a field on the wire | Struct tag: `json:"customer_name"` |
| Omit a zero-valued field | Struct tag: `json:",omitempty"` |
| Encode a number as a string | Struct tag: `json:"id,string"` |
| Skip a field entirely | Struct tag: `json:"-"` |
| Convert between snake_case and camelCase | Struct tags on each field |
| Add a computed field that depends on others | Custom MarshalJSON with alias trick |
| Change the JSON shape based on a runtime flag | Custom MarshalJSON |
| Encode an enum as a string | Custom MarshalText |
| Validate input during decoding | Custom UnmarshalJSON or UnmarshalText |
| Parse a non-JSON-native format like dates or money | Custom marshalers |
The rule of thumb: if a struct tag fixes the problem, use the tag. If you need to inject logic (formatting, validation, format conversion, computed fields), write a custom marshaler.
10 quizzes