Standard library patterns · 3 MIN READ
JSON v2: migrate behavior, not just imports
Compare null, empty and omitted JSON values before changing a public API.
Go 1.27 includes encoding/json/v2 without an experiment flag. Keeping encoding/json preserves its v1 behavior; adopting the v2 API is a separate decision. A compiling change can still alter a mobile model, persisted event or generated client. Write the expected wire representation before migrating.
JSON v2: migrate behavior, not just imports · A MAP OF THE IDEA
- Record the contract
- Compare v1 and v2
- Opt into one change
- Verify old consumers
Useful when
- Compare null, empty and omitted JSON values before changing a public API.
- Preserve the established JSON contract first. Enable an intentional representation change only when fixtures and consumers agree about its meaning.
Watch out
- Go compatibility does not imply that selecting different serializer defaults preserves your application protocol.
Implementation & operation cost: Choose the policy from the actual contract, then verify its failure path in the target environment.
Compare a zero-valued response
Ordinary nil slices and maps become empty collections under v2 defaults. V1 produces null. Neither representation is universally better: an established client may distinguish absence from an empty result. Keep fixtures for the actual exported types, including binary data and custom methods.
package main
import (
v1 "encoding/json"
v2 "encoding/json/v2"
"fmt"
)
type Page struct {
Items []string `json:"items"`
Labels map[string]string `json:"labels"`
}
func main() {
old, err := v1.Marshal(Page{})
if err != nil {
panic(err)
}
next, err := v2.Marshal(Page{})
if err != nil {
panic(err)
}
fmt.Println(string(old))
fmt.Println(string(next))
}
// Output:
// {"items":null,"labels":null}
// {"items":[],"labels":{}}
The two valid JSON documents carry different collection shapes. This complete program uses only the Go 1.27 standard library. The panic checks belong to a small demonstration; an application boundary should return and classify encoding errors.
Preserve first, opt in deliberately
DefaultOptionsV1 supplies the compatibility baseline through the v2 API. Later options override earlier ones, so opt into one reviewed change and keep the rest of the contract stable. A compatibility option does not validate domain data or automatically version persisted messages.
data, err := jsonv2.Marshal(response,
jsonv1.DefaultOptionsV1(),
jsonv2.FormatNilSliceAsNull(false),
)
if err != nil {
return nil, fmt.Errorf("encode response: %w", err)
}
return data, nilThis function-body fragment imports encoding/json as jsonv1, encoding/json/v2 as jsonv2, and fmt. response is the application value. Only nil-slice formatting is deliberately changed; nil-map behavior stays at the chosen v1 baseline.
Test old readers as well as new writers
Compare typed meaning and exact bytes separately. Sorted map output is useful for snapshots, but deterministic encoding is not a universal canonicalization format for signatures. Include old stored records, custom marshalers and independently released consumers, then stage the rollout with sampled comparisons and a format-aware rollback.
Extend the exercise
Keep an empty response fixture and a nonempty one. Change one option, predict the output, run both packages and explain which consumer needs the old result. The longer workshop on the website includes strict decoding, race-tested contract examples and a bounded fuzz target. It is useful preparation before a repository-wide import rewrite.
TAKE THIS WITH YOU
Preserve the established JSON contract first. Enable an intentional representation change only when fixtures and consumers agree about its meaning.
Check your understanding
An existing endpoint must keep nil maps encoded as null during migration. Which approach fits?
Go to the source
Progress stays in this browser. No account needed.