encoding/json Custom Marshaling
When struct tags cannot express the JSON shape you need, implement json.Marshaler and json.Unmarshaler on your types.
Custom marshaling controls wire formats for dates, enums, redacted views, and computed fields without leaking implementation details.
Summary
Go's encoder checks each value for MarshalJSON() ([]byte, error) before using default struct rules.
The decoder calls UnmarshalJSON([]byte) error on pointers that implement the interface.
Embedded types and pointer receivers interact with promotion rules, so symmetric marshal/unmarshal pairs and table tests keep APIs honest.
Recipe
Quick-reference recipe card - copy-paste ready.
type Status int
const (
StatusOpen Status = iota + 1
StatusClosed
)
func (s Status) MarshalJSON() ([]byte, error) {
switch s {
case StatusOpen:
return []byte(`"open"`), nil
case StatusClosed:
return []byte(`"closed"`), nil
default:
return nil, fmt.Errorf("unknown status %d", s)
}
}
func (s *Status) UnmarshalJSON(b []byte) error {
var raw string
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
switch raw {
case "open":
*s = StatusOpen
case "closed":
*s = StatusClosed
default:
return fmt.Errorf("invalid status %q", raw)
}
return nil
}When to reach for this:
- Enum-like ints should appear as strings in JSON.
time.Timeneeds a non-RFC3339 layout.- Sensitive structs need a redacted JSON view for logs.
- A field is computed from other fields at encode time.
Working Example
package main
import (
"encoding/json"
"fmt"
"time"
)
type APIKey struct {
secret string
prefix string
}
func (k APIKey) MarshalJSON() ([]byte, error) {
type alias struct {
Prefix string `json:"prefix"`
Hint string `json:"hint"`
}
return json.Marshal(alias{
Prefix: k.prefix,
Hint: k.prefix + "***",
})
}
func (k *APIKey) UnmarshalJSON(b []byte) error {
type alias struct {
Prefix string `json:"prefix"`
Secret string `json:"secret"`
}
var a alias
if err := json.Unmarshal(b, &a); err != nil {
return err
}
k.prefix = a.Prefix
k.secret = a.Secret
return nil
}
type Token struct {
APIKey
ExpiresAt time.Time `json:"expires_at"`
}
func main() {
t := Token{
APIKey: APIKey{secret: "supersecret", prefix: "sk_live"},
ExpiresAt: time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC),
}
b, err := json.Marshal(t)
if err != nil {
panic(err)
}
fmt.Println(string(b))
}What this demonstrates:
- Alias struct pattern avoids infinite recursion when customizing outer types.
- Embedded
APIKeyfields promote intoTokenJSON unless shadowed. - Redacted
MarshalJSONhidessecretwhile still accepting it on decode. ExpiresAtuses defaulttime.Timemarshaling (RFC3339).
Deep Dive
How It Works
json.Marshal walks the value tree.
At each node it asks:
- Does the value implement
json.Marshaler? - Is it a struct with tags?
- Is it a map, slice, or primitive?
Custom marshalers return complete JSON fragments including quotes for strings.
Do not double-encode: return []byte("open") for a string, not json.Marshal("open") wrapped twice.
UnmarshalJSON receives the raw JSON bytes for that value only.
Use a pointer receiver so the decoder can mutate the destination.
Alias Pattern
Defining an inner type alias struct { ... } copies fields without methods.
Marshal through the alias to get default struct behavior with different options:
func (u User) MarshalJSON() ([]byte, error) {
type alias User
return json.Marshal(struct {
alias
DisplayName string `json:"display_name"`
}{
alias: alias(u),
DisplayName: u.First + " " + u.Last,
})
}Embedded Types and Interfaces
If an outer struct embeds a type with MarshalJSON, the promoted method encodes the embedded value when the outer struct does not define its own.
When both outer and inner define marshalers, the outer wins for the outer type's marshal call.
For embedding fields inside a parent struct JSON object, the embedded struct's fields flatten unless the embedded type itself implements MarshalJSON (then it becomes one JSON value).
Go Notes
// Pointer vs value receiver:
// - MarshalJSON on value: works for both value and pointer fields
// - UnmarshalJSON MUST be on pointer receiver
var s Status
json.Unmarshal(data, &s) // calls (*Status).UnmarshalJSONPrefer returning typed errors from UnmarshalJSON so handlers map to 400 responses.
Gotchas
- Infinite recursion - Marshaling
UserinsideUser.MarshalJSONwithout an alias re-enters the same method. Fix: use the alias struct trick. - Wrong JSON fragment - Returning
openwithout quotes produces invalid JSON. Fix: return fully quoted JSON bytes or calljson.Marshalonce on a primitive. - Value receiver on UnmarshalJSON - Decoder cannot mutate the value. Fix: define
UnmarshalJSONon*T. - Embedded shadowing - Outer and inner fields with the same JSON key collide silently. Fix: rename tags or stop flattening with a custom marshaler on the parent.
- Breaking omitempty - Custom marshalers always run; empty values may still encode unless you handle zero cases. Fix: return
nullor skip in parent struct tags with pointers. - Asymmetric pair - Implementing only
MarshalJSONbreaks round-trip tests. Fix: implement both or document one-way encoding.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Struct tags only | Field rename, omitempty, hide | Enum strings, computed fields |
json.RawMessage | Polymorphic nested payloads | Simple scalar formats |
Wrapper type (type UserID string) | Newtype validation at boundaries | Large structs with many fields |
map[string]any | Rapid prototyping | Stable public APIs |
| Third-party JSON library | Need faster reflection | Stdlib behavior is required |
FAQs
Should MarshalJSON call json.Marshal internally?
Yes, on alias types or primitives - once per method.
Never call json.Marshal on the same receiver type without an alias.
Can I implement only MarshalJSON for responses?
Yes for write-only audit logs, but APIs that accept the same type should implement UnmarshalJSON too.
How do I emit null for optional custom types?
Return the bytes null from MarshalJSON when the value is absent.
Pair with pointer fields in parent structs for omitempty semantics.
Does MarshalJSON run for nil pointers?
Nil pointers encode as null without calling the method.
Non-nil pointers call the method on the pointed-to value.
What about MarshalText and encoding.TextMarshaler?
encoding/json also supports TextMarshaler for keys and some scalars.
Prefer JSON interfaces when the wire format is JSON-specific.
How do I test custom marshalers?
Table-test round trips: marshal, unmarshal, compare.
Add cases for invalid input strings and zero values.
Do generics change custom marshaling?
Methods bind to instantiated types normally.
Define marshalers on the generic struct or on type parameters with constraints as needed.
Can I stream with custom types?
json.Encoder calls MarshalJSON per value the same way Marshal does.
Large arrays still benefit from streaming either way.
How does this interact with gin binding?
Gin uses encoding/json under the hood for JSON bodies.
Custom unmarshalers run during ShouldBindJSON.
Should logs use a separate DTO?
Often yes - a UserLogDTO avoids accidental secret leakage.
Custom marshalers on domain types work when you truly need one type everywhere.
Related
- Serialization Basics - Marshal/Unmarshal fundamentals
- Struct Tags for JSON, DB & Validation - when tags are enough
- Schema Evolution & Unknown Field Handling - compatible API changes
- Validation with go-playground/validator - validate after decode
- Serialization Best Practices - round-trip test conventions
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).