Skip to content
gohacks.dev
APIs

JSON v2 in Go and What Changes for Your Existing Code

What Go's experimental encoding/json/v2 changes: case-sensitive keys, rejected duplicates, empty arrays for nil slices, Duration formats. v1 unchanged.

8 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the JSON logo.

The encoding/json package has been in Go since 1.0, and its quirks are well known and, in places, permanent: keys match case-insensitively, duplicate keys are accepted and the last wins, invalid UTF-8 is silently repaired, and a nil slice becomes null. Many of these are mistakes that cannot be fixed without breaking programs that depend on them. The Go team's answer is a new package, encoding/json/v2, designed with those lessons and an options-based API, developed in the open as an experiment and first shipped for testing in Go 1.25.

Tested with Go 1.25.6 with GOEXPERIMENT=jsonv2, macOS on Apple Silicon (arm64)

This article runs the experiment and reports what changes, in a form you can reproduce: one program, built twice, once normally and once with GOEXPERIMENT=jsonv2. The goal is to answer the practical question, which is whether you need to do anything to existing code today. The answer is no, and the differences are worth knowing about before they become the default behavior of a library you depend on. The v1 behaviors discussed here are the ones in the tour of marshaling and strict decoding. Everything ran on Go 1.25.6.

What the experiment is

In Go 1.25 the experiment is off by default. With GOEXPERIMENT=jsonv2 set at build time, two things happen:

  • The packages encoding/json/v2 and encoding/json/jsontext become importable. The second is a low-level package for tokens and values with no Go type mapping; the first is the new marshal and unmarshal API.
  • The existing encoding/json package is reimplemented on top of the new code, in a mode that preserves v1 behavior, so the old API gets the same inputs and outputs but a new engine.

Because it is an experiment, nothing about it is a compatibility promise. Names and defaults can change before it ships as a stable package, which is the reason to test with it and not to depend on it. If you want to give feedback, the discussion is on the Go issue tracker (go.dev/issue/71497), and the Go 1.25 release notes describe the flag.

The test program

The same set of inputs is run through v1 and, when it is built in, v2. The common types and inputs live in one file, v1 and v2 each have a file that exercises them, and a build constraint on goexperiment.jsonv2 keeps the default build compiling.

cases.goGo
package main

import "time"

type Item struct {
	Name string            `json:"name"`
	Tags []string          `json:"tags"`
	Meta map[string]string `json:"meta"`
	Zero int               `json:"zero,omitzero"`
	At   time.Time         `json:"at"`
	Raw  []byte            `json:"raw"`
}

var sample = Item{Name: "x", At: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), Raw: []byte{1, 2, 3}}

const (
	upperKey  = `{"NAME":"from upper"}`
	dupKey    = `{"name":"a","name":"b"}`
	unknown   = `{"name":"a","bogus":1}`
	badUTF8   = "{\"name\":\"caf\xe9\"}"
	nullSlice = `{"tags":null}`
)
v1.goGo
package main

import (
	"encoding/json"
	"fmt"
)

func runV1() {
	b, err := json.Marshal(sample)
	fmt.Printf("marshal sample          %s %v\n", b, err)
	var it Item
	for _, c := range []struct{ label, in string }{
		{"upper-case key", upperKey}, {"duplicate key", dupKey}, {"unknown key", unknown}, {"invalid UTF-8", badUTF8},
	} {
		it = Item{}
		err := json.Unmarshal([]byte(c.in), &it)
		fmt.Printf("%-23s name=%q err=%v\n", c.label, it.Name, err)
	}
}
v2.goGo
//go:build goexperiment.jsonv2

package main

import (
	"encoding/json/v2"
	"fmt"
	"time"
)

func runV2() {
	b, err := json.Marshal(sample)
	fmt.Printf("marshal sample          %s %v\n", b, err)
	var it Item
	for _, c := range []struct{ label, in string }{
		{"upper-case key", upperKey}, {"duplicate key", dupKey}, {"unknown key", unknown}, {"invalid UTF-8", badUTF8},
	} {
		it = Item{}
		err := json.Unmarshal([]byte(c.in), &it)
		fmt.Printf("%-23s name=%q err=%v\n", c.label, it.Name, err)
	}
	it = Item{}
	err = json.Unmarshal([]byte(unknown), &it, json.RejectUnknownMembers(true))
	fmt.Printf("%-23s name=%q err=%v\n", "unknown + option", it.Name, err)

	_, err = json.Marshal(struct{ D time.Duration }{1500 * time.Millisecond})
	fmt.Printf("%-23s err=%v\n", "time.Duration", err)
	b, err = json.Marshal(struct {
		D time.Duration `json:"d,format:units"`
		N time.Duration `json:"n,format:nano"`
	}{1500 * time.Millisecond, 1500 * time.Millisecond})
	fmt.Printf("%-23s %s %v\n", "Duration with format", b, err)
	b, _ = json.Marshal(map[string]int{"b": 2, "a": 1, "c": 3}, json.Deterministic(true))
	fmt.Printf("%-23s %s\n", "Deterministic(true)", b)
}

The v2 file's build line, //go:build goexperiment.jsonv2, is the standard way to write code that uses the experiment without breaking builds that lack it. A second file with the opposite constraint supplies a stub.

v2_off.goGo
//go:build !goexperiment.jsonv2

package main

import "fmt"

func runV2() { fmt.Println("(encoding/json/v2 is only built with GOEXPERIMENT=jsonv2)") }
main.goGo
package main

import "fmt"

func main() {
	fmt.Println("== encoding/json (v1 API)")
	runV1()
	fmt.Println("\n== encoding/json/v2")
	runV2()
}

What came out

Shell
echo '--- default build'; go run .
echo; echo '--- GOEXPERIMENT=jsonv2'; GOEXPERIMENT=jsonv2 go run .
echo; echo '--- v1 section identical in both builds?'
a=$(go run . | sed -n '/== encoding\/json (v1/,/== encoding\/json\/v2/p' | shasum | cut -c1-12)
b=$(GOEXPERIMENT=jsonv2 go run . | sed -n '/== encoding\/json (v1/,/== encoding\/json\/v2/p' | shasum | cut -c1-12)
echo "default: $a   jsonv2: $b"
Text
--- default build
== encoding/json (v1 API)
marshal sample          {"name":"x","tags":null,"meta":null,"at":"2026-01-02T03:04:05Z","raw":"AQID"} <nil>
upper-case key          name="from upper" err=<nil>
duplicate key           name="b" err=<nil>
unknown key             name="a" err=<nil>
invalid UTF-8           name="caf�" err=<nil>

== encoding/json/v2
(encoding/json/v2 is only built with GOEXPERIMENT=jsonv2)

--- GOEXPERIMENT=jsonv2
== encoding/json (v1 API)
marshal sample          {"name":"x","tags":null,"meta":null,"at":"2026-01-02T03:04:05Z","raw":"AQID"} <nil>
upper-case key          name="from upper" err=<nil>
duplicate key           name="b" err=<nil>
unknown key             name="a" err=<nil>
invalid UTF-8           name="caf�" err=<nil>

== encoding/json/v2
marshal sample          {"name":"x","tags":[],"meta":{},"at":"2026-01-02T03:04:05Z","raw":"AQID"} <nil>
upper-case key          name="" err=<nil>
duplicate key           name="a" err=jsontext: duplicate object member name "name"
unknown key             name="a" err=<nil>
invalid UTF-8           name="" err=jsontext: invalid UTF-8 within "/name" after offset 12
unknown + option        name="a" err=json: cannot unmarshal JSON string into Go main.Item: unknown object member name "bogus"
time.Duration           err=json: cannot marshal from Go time.Duration within "/D": no default representation (see https://go.dev/issue/71631); specify an explicit format
Duration with format    {"d":"1.5s","n":1500000000} <nil>
Deterministic(true)     {"a":1,"b":2,"c":3}

--- v1 section identical in both builds?
default: 50db24665113   jsonv2: 50db24665113

Nothing changes for v1 code. The default build prints the v1 section and a note that v2 is not built. The experiment build prints the v1 section again, and the checksum of that section is the same in both runs, 50db24665113. Importing encoding/json under the experiment gave byte-identical output for every case here, including the quirky ones: the upper-case key still matched, the duplicate key still gave the last value, and the invalid byte was still replaced. If you do nothing, you get nothing different.

What v2 does differently. Compare the v2 section with the v1 one line by line.

  • Nil collections are empty. v1 wrote "tags":null,"meta":null; v2 wrote "tags":[],"meta":{}. This removes one of the most common surprises in API output. If your clients depend on null for an unset list, that is a visible change.
  • Keys are case-sensitive. {"NAME":"from upper"} filled Name in v1 and left it empty in v2. A client that sends keys with the wrong case, which v1 forgave, will have fields silently dropped under v2 defaults. There are options to restore the old matching.
  • Duplicate keys are an error. {"name":"a","name":"b"} gave b in v1, and in v2 returned jsontext: duplicate object member name "name". Duplicates are a classic source of parser-differential bugs, where two components read different values from the same document, so rejecting them is a safety improvement.
  • Invalid UTF-8 is an error. The café string with a lone latin-1 byte was repaired with the replacement character by v1 and refused by v2 with invalid UTF-8 within "/name" after offset 12. Data that was quietly corrupted now fails loudly, which is correct and will break sloppy producers.
  • Unknown members are still ignored by default, like v1. The new option RejectUnknownMembers(true) turns them into an error, and the output shows the message naming bogus. That replaces the Decoder plus DisallowUnknownFields dance from the strict-decoding article with an option on Unmarshal.
  • time.Duration needs a format. v2 refused to marshal it at all: no default representation, with a pointer to the issue that explains why. Adding format:units produced "1.5s" and format:nano produced 1500000000. v1 silently wrote nanoseconds, which was never a good default for an API. Under v2 you must choose, which is the better outcome once you have done it.
  • Deterministic output is an option. Deterministic(true) sorted map keys in the output.

The tag syntax is also richer: format: options, inline for flattening a struct, unknown to collect extra members, and omitzero (which also exists in v1 since 1.24, see three-state fields). Types with their own MarshalJSON, such as the ones in custom marshalers, keep working.

What to do with this

Existing services: nothing, but run the tests under the experiment. Set GOEXPERIMENT=jsonv2 in CI for one job. Because v1 is reimplemented on the new engine, it is a real test of that engine against your payloads, and any difference is a bug worth reporting. In my run, no output changed.

New code: keep using v1 for now. The API is an experiment and may change. The v1 package is not going away, and everything in this series works with it.

Libraries: be careful with your types' behavior. A type that implements MarshalJSON has its own rules and keeps working. A type that relies on case-insensitive matching, duplicate tolerance or null for empty slices will behave differently when its users move to v2. Test your public types under both.

When v2 becomes the default for your Go version, the places likely to need attention are the ones above: durations, nil collections in responses, and clients that send odd keys. Write the tests now. A golden-string test of each public response (marshaling) will tell you immediately.

The package documentation for the experiment is at pkg.go.dev/encoding/json/v2, and the lower-level token package at encoding/json/jsontext.

Taggedjson
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.