Go Module 17 – JSON: encoding/json and encoding/json/v2
Migrating a service from JSON v1 to v2 safely
Move a Go service from encoding/json to encoding/json/v2 without breaking clients: three strategies, DefaultOptionsV1, contract tests and a way back.
What you will learn
- Choose a migration strategy for a service: all at once, option by option, or side by side
- Start from DefaultOptionsV1 and remove compatibility options one at a time, with evidence for each
- Detect changes in a service's JSON with golden contract tests before they reach clients
- Restore the original v1 implementation with GOEXPERIMENT=nojsonv2 when a problem appears
Before you start
What you’ll learn · Before you start
What you will learn
- Choose a migration strategy for a service: all at once, option by option, or side by side
- Start from DefaultOptionsV1 and remove compatibility options one at a time, with evidence for each
- Detect changes in a service's JSON with golden contract tests before they reach clients
- Restore the original v1 implementation with GOEXPERIMENT=nojsonv2 when a problem appears
Before you start
On this page
Marshalled by encoding/json/v2 with no options, the booking response of this lesson changes in three places: a
passenger’s age of 0 appears, a list of tags that was never set becomes [], and an & loses its escape. Each change
is small, and each can break a client that parses the old bytes. Since Go 1.27 the old API already runs on the new
implementation, so a migration means moving the calls themselves, and it is a choice.
The safe path: move every call to v2 with DefaultOptionsV1, which writes v1’s bytes, then turn v1’s behaviours off one at a time, each guarded by a contract test: A test that pins what another party depends on, such as the exact JSON a service sends and the requests it accepts, so that a change to it fails in the test before any client notices., with a way back ready while the toolchain changes. You will choose a strategy, find the options a service depends on and rehearse that way back, on programs and terminal sessions run on Go 1.27.1, and on Go 1.26.8 where a comparison needs the older release.
The v1 API stays, and already runs on v2
Nothing obliges a service to move. encoding/json is covered by the Go 1 compatibility promise: The Go team's stated intent that programs written to the Go 1 specification keep compiling and running correctly, unchanged, in every later Go 1 release, with a few listed exceptions such as security fixes. More in “What Go is and where it fits”, and the Go 1.27 release notes say that the v1 API will continue to be supported and that users are not required to migrate. What changed is underneath: encoding/json is now backed by the v2 implementation, with its behaviour preserved. The notes list three consequences. Unmarshalling is significantly faster, with nothing to do. The exact text of error messages may differ, so a test or a client that compares it can break on the upgrade alone. And a build with GOEXPERIMENT: An environment variable of the go command that turns experimental features of the toolchain on or off when a program is built. Go 1.25 used GOEXPERIMENT=jsonv2 to enable encoding/json/v2; Go 1.27 offers nojsonv2 to turn its new implementation off.=nojsonv2 restores the original implementation, an opt-out the notes expect to be removed in a future release. Walk through the releases that led here:
- Go 1
encoding/json under the compatibility promise
encoding/json is part of the standard library that the Go 1 compatibility promise covers, so programs written for it keep building and behaving the same in later releases.
- Go 1.24
The omitzero option new option
A field tagged omitzero is left out when its value is zero, judged by the type's IsZero method when it has one. It is clearer than omitempty for structs such as time.Time.
- Go 1.25 and 1.26
encoding/json/v2 as an experiment experiment
encoding/json/v2 and encoding/json/jsontext can be built only with GOEXPERIMENT=jsonv2. The API is open to change, and several options are removed or renamed before it ships.
- Go 1.27
The new packages ship, and v1 runs on them new packages
Both packages are part of the standard library. encoding/json is backed by the v2 implementation with its behaviour preserved, though the text of error messages may differ, and its unmarshalling is significantly faster.
- Go 1.27
An opt-out at build time opt-out
Building with GOEXPERIMENT=nojsonv2 restores the original implementation of encoding/json, for programs that meet a compatibility problem. The release notes expect the opt-out to be removed in a future release.
The timeline has 5 events, from Go 1 to Go 1.27.
Moving the calls to encoding/json/v2 buys the stricter defaults of the previous lesson, options on every call and the streaming methods. It pays off first in code that parses untrusted input, where refusing names written twice and invalid UTF-8 matters, and in new endpoints. For a stable API whose clients you cannot update, the old calls on the new implementation may serve for a long time.
Checkpoint
Check: Why moving is optional
Three ways to move: all at once, option by option, side by side
The migration guide describes three strategies. All at once switches the imports, runs the tests and sets a compatibility option wherever a test fails, which suits a team that can afford surprises. Option by option first moves every call to v2 with DefaultOptionsV1, which the guide calls a trivial and safe change, then turns v2’s behaviours on one at a time, so that a failure points at the last option removed. Side by side runs v1 and v2 on the same calls in production and reports their differences while clients still receive v1’s output; the jsonsplit package of the go-json-experiment project does this, at about twice the marshalling cost for each call it compares. Here is option by option on one booking: its first step, a step later on, and where it ends once every option has gone:
// same_bytes.go: one booking through v1, through v2 with DefaultOptionsV1, through plain v2, and with one option removed.
package main
import (
"bytes"
jsonv1 "encoding/json"
json "encoding/json/v2"
"fmt"
)
type Passenger struct {
Name string `json:"name"`
Age int `json:"age,omitempty"`
Berth string `json:"berth,omitempty"`
}
type Booking struct {
PNR string `json:"pnr"`
Passengers []Passenger `json:"passengers"`
Tags []string `json:"tags"`
Note string `json:"note"`
}
func main() {
b := Booking{
PNR: "4521876390",
Passengers: []Passenger{{Name: "Asha", Age: 34, Berth: "LB"}, {Name: "Ravi"}},
Note: "veg & window",
}
want, _ := jsonv1.Marshal(b)
fmt.Printf("v1:\n %s\n", want)
steps := []struct {
name string
opts []json.Options
}{
{"v2 with DefaultOptionsV1", []json.Options{jsonv1.DefaultOptionsV1()}},
{"v2 with DefaultOptionsV1, FormatNilSliceAsNull(false)", []json.Options{jsonv1.DefaultOptionsV1(), json.FormatNilSliceAsNull(false)}},
{"v2 with no options", nil},
}
for _, s := range steps {
got, err := json.Marshal(b, s.opts...)
if err != nil {
fmt.Println(s.name+":", err)
continue
}
fmt.Printf("%s: same bytes as v1: %v\n %s\n", s.name, bytes.Equal(got, want), got)
}
// Decoding goes through the same options: a client that sends "PNR" in capitals.
in := []byte(`{"PNR":"4521876391","tags":["tatkal"]}`)
var a, c Booking
_ = jsonv1.Unmarshal(in, &a)
_ = json.Unmarshal(in, &c, jsonv1.DefaultOptionsV1())
var d Booking
_ = json.Unmarshal(in, &d)
fmt.Printf("decode %s:\n v1 pnr=%q, v2 with DefaultOptionsV1 pnr=%q, v2 with no options pnr=%q\n", in, a.PNR, c.PNR, d.PNR)
} Output
v1:
{"pnr":"4521876390","passengers":[{"name":"Asha","age":34,"berth":"LB"},{"name":"Ravi"}],"tags":null,"note":"veg \u0026 window"}
v2 with DefaultOptionsV1: same bytes as v1: true
{"pnr":"4521876390","passengers":[{"name":"Asha","age":34,"berth":"LB"},{"name":"Ravi"}],"tags":null,"note":"veg \u0026 window"}
v2 with DefaultOptionsV1, FormatNilSliceAsNull(false): same bytes as v1: false
{"pnr":"4521876390","passengers":[{"name":"Asha","age":34,"berth":"LB"},{"name":"Ravi"}],"tags":[],"note":"veg \u0026 window"}
v2 with no options: same bytes as v1: false
{"pnr":"4521876390","passengers":[{"name":"Asha","age":34,"berth":"LB"},{"name":"Ravi","age":0}],"tags":[],"note":"veg & window"}
decode {"PNR":"4521876391","tags":["tatkal"]}:
v1 pnr="4521876391", v2 with DefaultOptionsV1 pnr="4521876391", v2 with no options pnr=""
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run same_bytes.go
With DefaultOptionsV1 the bytes were v1’s, and so was the decoding of PNR in capitals. Turning off one option,
FormatNilSliceAsNull, changed one member: tags became []. Plain v2 changed three, and stopped decoding PNR.
Which strategy fits depends on your clients and your tests; answer three questions about your service:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Strategy | Risk to clients | Effort | Cost while it runs | Evidence it gives |
|---|---|---|---|---|
| All at once Relies on: your tests catching every difference | Poor every difference the tests miss reaches clients in one release | Good change the imports, then fix what the tests report | Good nothing extra once it is done | Mixed only what the tests cover |
| Option by option Relies on: DefaultOptionsV1 and one change at a time | Good starts with the same bytes as v1, then changes one behaviour at a time | Mixed one release for each option or group of options | Good every call runs once | Good a failure points at the option just removed |
| Side by side Relies on: production traffic, compared on a sample | Good clients keep receiving v1 output while v2 is compared | Mixed a wrapper at the call sites, and reports to read | Poor a compared call marshals about twice; sample to keep it small | Good differences found in real traffic, with what caused them |
How the answers decide
- “Do clients you cannot update quickly read the JSON your service sends?” Yes, such as mobile apps or partners and “Do your tests pin the exact JSON your service sends and accepts?” No, or only for some types: rules out All at once. Clients you cannot update would meet every difference your tests miss, all in one release.
- “Do your tests pin the exact JSON your service sends and accepts?” No, or only for some types: adds a caveat to Option by option. Each removed option needs a check, so write golden tests for the types it touches before you remove it.
- “Can a sample of production calls marshal twice for a few weeks?” No, marshalling is a hot path: adds a caveat to Side by side. Compare a small share of calls, or compare recorded payloads away from production, so the extra marshalling stays small.
- “Do clients you cannot update quickly read the JSON your service sends?” No, we ship every client ourselves and “Do your tests pin the exact JSON your service sends and accepts?” Yes, golden tests for every type: adds a caveat to Side by side. With every client in your hands and tests that pin the JSON, comparing live traffic costs more than it can tell you.
- “Do clients you cannot update quickly read the JSON your service sends?” Yes, such as mobile apps or partners: adds a caveat to All at once. Even with good tests, tell the clients you cannot update before the release, since a test cannot see their parsers.
Whichever way you move, three tools make the move safe:
- DefaultOptionsV1 is 21 options, and turning each off alone against your own payloads shows which of them your API needs.
- A golden contract test compares what a service sends and accepts with what v1 did, so a change that a client would see fails a test first.
- A build with GOEXPERIMENT=nojsonv2 takes a service back to the original v1 implementation after an upgrade goes wrong.
Key idea
Start from the bytes v1 writes, with DefaultOptionsV1, and change one behaviour at a time, each with a test that would notice it.
Checkpoint
Check: The three strategies
Which compatibility options your API needs, found by bisection Pro: opens with a Pro, Premium or Ultimate pass
DefaultOptionsV1 is 21 options, and each one an API does not depend on can go. This part turns each one off alone against a service’s response and its logged requests, reads what the result does and does not prove, and turns it into a plan for removing the rest.
In this part:
- Turning off each of the 21 options alone, against real payloads
- What the 13 silent options prove, and what they do not
- Removing options one release at a time
1 runnable example (Go) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Contract tests and a shadow comparison that catch changes before clients do Pro: opens with a Pro, Premium or Ultimate pass
A migration is safe when something fails before a client notices. This part writes a golden contract test that fails three ways under v2’s defaults and passes with the options the API keeps, compares v1 and v2 on a sample of live calls, and shows why the text of an error never belongs in a test.
In this part:
- A golden contract test that fails three ways under v2’s defaults
- One call in ten, compared in production
3 runnable examples (Go, Go (booking.go), Go (contract_test.go), Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
What Go 1.27 changes on its own, and a rollback rehearsed Pro: opens with a Pro, Premium or Ultimate pass
Moving the toolchain to Go 1.27 changes JSON behaviour before any call moves to v2. This part measures two such changes, rehearses the way back with GOEXPERIMENT=nojsonv2, and finds the point after which that way back no longer builds.
In this part:
- Two changes that arrive with the toolchain, measured
- Rolling back with GOEXPERIMENT=nojsonv2, and proving which build runs
- The opt-out has an end
1 diagram · 5 runnable examples (Go, Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise
The questions cover whether to move and how, starting with the idea you know least:
Practice that adapts to you
Practise this lesson
9 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): Do you have to change code from encoding/json to encoding/json/v2 when you upgrade to Go 1.27?
No. encoding/json stays under the Go 1 compatibility promise, and the Go 1.27 release notes say users are not required to migrate. The upgrade does change what runs underneath: encoding/json is backed by the v2 implementation, with its behaviour preserved, faster unmarshalling, and error messages whose exact text may differ. A build with GOEXPERIMENT=nojsonv2 restores the original implementation if that causes a problem. Moving calls to v2 is worth it for its stricter defaults and per-call options, done step by step with tests.
Three longer questions of the module bank build on this lesson:
-
Senior to staff
Plan the move of a public booking API, 40 endpoints used by mobile apps that update slowly, from encoding/json to encoding/json/v2. Which strategy do you choose, in what order do you remove the compatibility options, and what evidence lets you remove each one?
-
Mid to senior
After a service moves from Go 1.26 to Go 1.27 with no code change, a test that compares a json.Unmarshal error message fails, and a partner's error parser breaks. What changed, how do you confirm it, and what do you do now and later?
-
Mid to senior
After a package switched to encoding/json/v2, its golden-file tests fail on some runs and pass on others, and a response cache keyed by a hash of the body almost never hits. What causes both, and how do you fix them?
Key takeaways
- Moving to encoding/json/v2 is optional: encoding/json stays supported, and since Go 1.27 it already runs on the v2 implementation, with faster unmarshalling and error text that may differ.
- Of the three strategies, option by option starts from DefaultOptionsV1, the same bytes as v1, and changes one behaviour at a time; side by side compares v1 and v2 on live calls at about twice the marshalling cost.
- One option removed changes exactly the members it governs, as
FormatNilSliceAsNullchanged onlytags.
Terms in this lesson
- contract test
- A test that pins what another party depends on, such as the exact JSON a service sends and the requests it accepts, so that a change to it fails in the test before any client notices.
- GOEXPERIMENT
- An environment variable of the go command that turns experimental features of the toolchain on or off when a program is built. Go 1.25 used GOEXPERIMENT=jsonv2 to enable encoding/json/v2; Go 1.27 offers nojsonv2 to turn its new implementation off.
References
- encoding/json/v2 Migration Guide (The Go Authors)
- Package encoding/json: Migrating to v2 (The Go Authors)
- Package encoding/json: DefaultOptionsV1 (The Go Authors)
- Go 1.27 Release Notes: encoding/json/v2 (The Go Authors)
- Go 1 and the Future of Go Programs (The Go Authors)
- Command go: environment variables (GOEXPERIMENT) (The Go Authors)
- Package jsonsplit (The go-json-experiment project)
- Package encoding/json/jsontext: Value.Canonicalize (The Go Authors)
- RFC 8785: JSON Canonicalization Scheme (JCS) (RFC Editor (Independent Submission))
- encoding/json/v2 source: errors.go (errorModalVerb) (The Go Authors)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress