encoding/json Custom Marshaling
When struct tags cannot express the JSON shape you need, implement json.Marshaler and json.Unmarshaler on your types.
Search across all documentation pages
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.
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.
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:
time.Time needs a non-RFC3339 layout.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:
APIKey fields promote into Token JSON unless shadowed.MarshalJSON hides secret while still accepting it on decode.ExpiresAt uses default time.Time marshaling (RFC3339).json.Marshal walks the value tree.
At each node it asks:
json.Marshaler?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.
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,
})
}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).
// 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.
User inside User.MarshalJSON without an alias re-enters the same method. Fix: use the alias struct trick.open without quotes produces invalid JSON. Fix: return fully quoted JSON bytes or call json.Marshal once on a primitive.UnmarshalJSON on *T.null or skip in parent struct tags with pointers.MarshalJSON breaks round-trip tests. Fix: implement both or document one-way encoding.| 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 |
Yes, on alias types or primitives - once per method.
Never call json.Marshal on the same receiver type without an alias.
Yes for write-only audit logs, but APIs that accept the same type should implement UnmarshalJSON too.
Return the bytes null from MarshalJSON when the value is absent.
Pair with pointer fields in parent structs for omitempty semantics.
Nil pointers encode as null without calling the method.
Non-nil pointers call the method on the pointed-to value.
encoding/json also supports TextMarshaler for keys and some scalars.
Prefer JSON interfaces when the wire format is JSON-specific.
Table-test round trips: marshal, unmarshal, compare.
Add cases for invalid input strings and zero values.
Methods bind to instantiated types normally.
Define marshalers on the generic struct or on type parameters with constraints as needed.
json.Encoder calls MarshalJSON per value the same way Marshal does.
Large arrays still benefit from streaming either way.
Gin uses encoding/json under the hood for JSON bodies.
Custom unmarshalers run during ShouldBindJSON.
Often yes - a UserLogDTO avoids accidental secret leakage.
Custom marshalers on domain types work when you truly need one type everywhere.
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).
Reviewed by Chris St. John·Last updated Jul 18, 2026