Custom JSON Marshaling and Unmarshaling in Go
Implement the json.Marshaler and json.Unmarshaler interfaces to control how Go types serialize to and from JSON.
What you'll learn
- ✓How Go's json.Marshal and json.Unmarshal work with struct tags
- ✓Implementing the Marshaler and Unmarshaler interfaces
- ✓Handling enums, timestamps, and polymorphic types in JSON
- ✓Common patterns for backward-compatible API responses
Prerequisites
None — this post is self-contained.
Go’s encoding/json package handles most serialization through struct tags. But struct tags have limits. When you need custom date formats, enum string mappings, flattened structures, or polymorphic types, you implement the json.Marshaler and json.Unmarshaler interfaces. These two interfaces give you full control over how a type converts to and from JSON.
Struct Tags Refresher
Before diving into custom marshaling, here is what struct tags handle:
type User struct {
ID string `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email,omitempty"`
Password string `json:"-"` // never serialized
Age int `json:"age,omitempty"`
}
The json:"name" tag controls the JSON key. omitempty skips zero values. - excludes the field entirely. This covers many cases, but not all.
The Marshaler Interface
To customize JSON output, implement json.Marshaler:
type Marshaler interface {
MarshalJSON() ([]byte, error)
}
When json.Marshal encounters a type that implements this interface, it calls MarshalJSON() instead of using the default reflection-based encoding.
Custom Date Formats
Go’s time.Time marshals to RFC 3339 by default (2026-07-02T15:04:05Z). Many APIs use different formats:
type Date struct {
time.Time
}
func (d Date) MarshalJSON() ([]byte, error) {
formatted := d.Time.Format("2006-01-02")
return json.Marshal(formatted)
}
func (d *Date) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return err
}
d.Time = t
return nil
}
type Event struct {
Name string `json:"name"`
Date Date `json:"date"`
}
Now marshaling produces {"name":"Launch","date":"2026-07-02"} instead of the full RFC 3339 timestamp.
Enum String Mapping
Go uses iota for enums, but JSON consumers expect strings:
type Status int
const (
StatusPending Status = iota
StatusActive
StatusInactive
StatusArchived
)
var statusNames = map[Status]string{
StatusPending: "pending",
StatusActive: "active",
StatusInactive: "inactive",
StatusArchived: "archived",
}
var statusValues = map[string]Status{
"pending": StatusPending,
"active": StatusActive,
"inactive": StatusInactive,
"archived": StatusArchived,
}
func (s Status) MarshalJSON() ([]byte, error) {
name, ok := statusNames[s]
if !ok {
return nil, fmt.Errorf("unknown status: %d", s)
}
return json.Marshal(name)
}
func (s *Status) UnmarshalJSON(data []byte) error {
var name string
if err := json.Unmarshal(data, &name); err != nil {
return err
}
val, ok := statusValues[name]
if !ok {
return fmt.Errorf("unknown status: %q", name)
}
*s = val
return nil
}
Usage:
type Account struct {
Email string `json:"email"`
Status Status `json:"status"`
}
a := Account{Email: "alice@example.com", Status: StatusActive}
data, _ := json.Marshal(a)
// {"email":"alice@example.com","status":"active"}
Flattening Nested Structures
Sometimes the JSON shape does not match your Go struct hierarchy. Custom marshaling lets you reshape:
type Address struct {
Street string
City string
State string
Zip string
}
type Customer struct {
Name string
Email string
Address Address
}
func (c Customer) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Name string `json:"name"`
Email string `json:"email"`
Street string `json:"street"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
}{
Name: c.Name,
Email: c.Email,
Street: c.Address.Street,
City: c.Address.City,
State: c.Address.State,
Zip: c.Address.Zip,
})
}
This produces a flat JSON object even though the Go struct is nested.
Polymorphic JSON With a Type Discriminator
APIs often return different shapes based on a type field. Handle this with a custom unmarshaler:
type Shape interface {
Area() float64
}
type Circle struct {
Radius float64 `json:"radius"`
}
func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius }
type Rectangle struct {
Width float64 `json:"width"`
Height float64 `json:"height"`
}
func (r Rectangle) Area() float64 { return r.Width * r.Height }
type ShapeWrapper struct {
Shape Shape
}
func (sw *ShapeWrapper) UnmarshalJSON(data []byte) error {
var raw struct {
Type string `json:"type"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
switch raw.Type {
case "circle":
var c Circle
if err := json.Unmarshal(data, &c); err != nil {
return err
}
sw.Shape = c
case "rectangle":
var r Rectangle
if err := json.Unmarshal(data, &r); err != nil {
return err
}
sw.Shape = r
default:
return fmt.Errorf("unknown shape type: %q", raw.Type)
}
return nil
}
func (sw ShapeWrapper) MarshalJSON() ([]byte, error) {
var typeName string
switch sw.Shape.(type) {
case Circle:
typeName = "circle"
case Rectangle:
typeName = "rectangle"
}
type alias ShapeWrapper
return json.Marshal(struct {
Type string `json:"type"`
Shape
}{
Type: typeName,
Shape: sw.Shape,
})
}
Now {"type":"circle","radius":5} unmarshals into a Circle and {"type":"rectangle","width":3,"height":4} into a Rectangle.
Handling Backward Compatibility
When an API field changes type between versions, custom unmarshaling handles both:
type Config struct {
Timeout time.Duration
}
func (c *Config) UnmarshalJSON(data []byte) error {
// Try the new format first (string like "30s")
var obj struct {
Timeout string `json:"timeout"`
}
if err := json.Unmarshal(data, &obj); err == nil && obj.Timeout != "" {
d, err := time.ParseDuration(obj.Timeout)
if err != nil {
return err
}
c.Timeout = d
return nil
}
// Fall back to old format (integer milliseconds)
var legacy struct {
Timeout int64 `json:"timeout"`
}
if err := json.Unmarshal(data, &legacy); err != nil {
return err
}
c.Timeout = time.Duration(legacy.Timeout) * time.Millisecond
return nil
}
Both {"timeout":"30s"} and {"timeout":30000} unmarshal correctly.
Using json.RawMessage for Deferred Parsing
When you need to inspect a discriminator before parsing the rest, json.RawMessage delays parsing:
type Event struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
func ParseEvent(data []byte) (*Event, error) {
var e Event
if err := json.Unmarshal(data, &e); err != nil {
return nil, err
}
switch e.Type {
case "user_created":
var payload UserCreatedPayload
if err := json.Unmarshal(e.Payload, &payload); err != nil {
return nil, err
}
// handle payload
case "order_placed":
var payload OrderPlacedPayload
if err := json.Unmarshal(e.Payload, &payload); err != nil {
return nil, err
}
// handle payload
}
return &e, nil
}
json.RawMessage stores the raw bytes without parsing, letting you unmarshal into the correct type after inspecting the discriminator.
Avoiding Infinite Recursion
A common mistake is calling json.Marshal(s) inside MarshalJSON(), which calls MarshalJSON() again, creating infinite recursion. The fix is to create a type alias:
type MyType struct {
Name string `json:"name"`
Extra string `json:"-"` // computed field
}
func (m MyType) MarshalJSON() ([]byte, error) {
type Alias MyType // Alias does NOT inherit MarshalJSON
return json.Marshal(struct {
Alias
FullName string `json:"full_name"`
}{
Alias: Alias(m),
FullName: m.Name + " (" + m.Extra + ")",
})
}
The type alias Alias has the same fields but does not inherit the MarshalJSON method, breaking the recursion.
Wrap Up
Struct tags handle straightforward JSON mapping, but real-world APIs demand more. Implementing json.Marshaler and json.Unmarshaler lets you control date formats, map enum values to strings, flatten nested structures, handle polymorphic types with discriminators, and maintain backward compatibility across API versions. The pattern is consistent: take control of the bytes, parse or produce them however the API requires, and return the result.
Related articles
- Go Go Struct Tags and Reflection
Understand how Go struct tags work, how packages like encoding/json read them with reflection, and how to add custom tag-driven behavior to your code.
- Rust Rust Serde Serialization and Deserialization Guide
Master Serde in Rust with practical patterns for JSON, custom serialization, field renaming, enums, and error handling in real-world projects.
- Rust Rust Serde Tutorial
A complete guide to Serde, Rust's de facto serialization framework, covering derive macros, attributes, custom types, and common JSON patterns.
- Go Go Concurrency: Fan-Out, Pipeline, and Worker Pool
Master advanced Go concurrency patterns including fan-out/fan-in, pipelines, and worker pools with practical examples and production-ready code.