Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 25 minutes
  • Examples run with Go 1.27.1 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 8 of 25 minutes free
Free: The v1 API stays, and already runs on v2 · Three ways to move: all at once, option by option, side by side. With a pass: 3 in-depth parts and 3 interview answers, about 17 minutes.

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:

Timeline · 5 events in order JSON in Go, release by release Walk through the releases in order. Each one changed what a JSON migration has to deal with.
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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

  1. Question 1 of 2 A service upgrades from Go 1.26 to Go 1.27. Must its calls to encoding/json move to encoding/json/v2?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No; the v1 API stays supported and nobody is required to migrate

    Moving is a choice; the upgrade alone already puts the v2 implementation under the old calls.

  2. Question 2 of 2 Which of these does a service get from the Go 1.27 upgrade alone, without changing a call?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Faster unmarshalling, and error messages whose exact text may differ

    Behaviour is preserved, speed improves, and the wording of errors is the part to watch.

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: v1, DefaultOptionsV1, one option removed, and plain v2 Go · same_bytes.go
// 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:

Interactive · answer and compare Which way to move fits your service? Answer three questions about your service. The table marks the strategies that fit, with a caveat or not, and says why.
Do clients you cannot update quickly read the JSON your service sends?
Do your tests pin the exact JSON your service sends and accepts?
Can a sample of production calls marshal twice for a few weeks?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three ways to move a service from encoding/json to encoding/json/v2, against four things they risk or cost
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

  1. Question 1 of 2 What is the first step of the option-by-option strategy, and what did same_bytes.go show about it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Move every call to v2 with DefaultOptionsV1; the bytes were the same as v1's

    The guide calls the first step a trivial and safe change: the behaviour is v1's, through the v2 API.

  2. Question 2 of 2 Which changes did plain v2, with no options, make to the booking of same_bytes.go? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • The second passenger gained "age":0
    • tags became []
    • The & in the note was no longer escaped

    Three members changed, and the request with PNR in capitals no longer decoded.

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.

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.

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.

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.

  1. Question 1 of 9 What do the Go 1.27 release notes say about moving from encoding/json to encoding/json/v2?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: The v1 API will continue to be supported, and users are not required to migrate

    The Go 1 compatibility promise covers encoding/json like the rest of the standard library.

  2. Question 2 of 9 A test asserts the exact message of a json.Unmarshal error. What do the release notes say may happen on the Go 1.27 upgrade?

    Choose one answer.

    Show the answer: Question 2 of 9

    Answer: The exact text of error messages may differ, so the test may fail

    Test errors by their type and fields, with errors.As, rather than by their text.

  3. Question 3 of 9 What does building with GOEXPERIMENT=nojsonv2 do in Go 1.27?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: It restores the original implementation of encoding/json

    The notes expect the opt-out to be removed in a future release.

  4. Question 4 of 9 Which code gains most from moving its calls to encoding/json/v2 first?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: Handlers that decode untrusted input, where refusing duplicate names and invalid UTF-8 matters

    Move where the stricter defaults protect you, and leave stable contracts for last.

  5. Question 5 of 9 What does the all-at-once strategy of the migration guide do?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: It switches the imports, runs the tests, and sets a compatibility option where a test fails

    The fastest of the three, and the one whose safety rests entirely on the tests.

  6. Question 6 of 9 Why does the option-by-option strategy remove one compatibility option per step?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: So that any failure points at the one option just removed

    The guide also suggests bisecting when troubleshooting, for the same reason.

  7. Question 7 of 9 What does comparing v1 and v2 on a call cost, according to the migration guide, and how is the cost kept small?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: About twice the marshalling of that call; compare only a sample of calls

    Clients still receive v1's output while the comparison runs.

  8. Question 8 of 9 same_bytes.go turned off only FormatNilSliceAsNull. Why did exactly one member change?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: The option governs only nil slices, and tags was the booking's only nil slice

    This is what makes option by option safe: every change is small and explained.

  9. Question 9 of 9 A team has mobile apps it cannot update quickly and no golden tests of its JSON. Which strategy is ruled out?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: All at once

    The trade-off table rules out all at once for exactly this case.

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:

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

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 FormatNilSliceAsNull changed only tags.

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.