Hands-On Verification
Originally published in Japanese at https://zenn.dev/ymotongpoo/books/go-json-v2-history/viewer/60-verification.
Two implementations left in the source
Peek into the Go 1.27 source tree and you learn one more thing.
$ ls $(go env GOROOT)/src/encoding/json/
decode.go encode.go ...
v2_decode.go v2_encode.go v2_inject.go v2_options.go ...
encode.go and v2_encode.go sit side by side. Look at the top of each:
// encode.go
// Copyright 2010 The Go Authors. All rights reserved.
//go:build !goexperiment.jsonv2
// v2_encode.go
// Copyright 2010 The Go Authors. All rights reserved.
//go:build goexperiment.jsonv2
The build tags make them mutually exclusive. Because the default value of GOEXPERIMENT is on, v2_encode.go is the one that gets used.
$ grep -n "JSONv2" $(go env GOROOT)/src/internal/buildcfg/exp.go
JSONv2: true,
In Go 1.25 and 1.26, you had to enable this explicitly with GOEXPERIMENT=jsonv2. In Go 1.27 it is on by default and has switched to opt-out1: you turn it off with GOEXPERIMENT=nojsonv2.
And what runs when you turn it off is the implementation written in 2010. The release notes say this opt-out is expected to be removed in a future release.
The copyright years alone tell the history of this directory, which is a fun read in itself.
$ head -1 $(go env GOROOT)/src/encoding/json/encode.go
// Copyright 2010 The Go Authors. All rights reserved.
$ head -1 $(go env GOROOT)/src/encoding/json/v2/arshal.go
// Copyright 2020 The Go Authors. All rights reserved.
$ head -1 $(go env GOROOT)/src/encoding/json/jsontext/doc.go
// Copyright 2023 The Go Authors. All rights reserved.
2010 is v1, 2020 is v2’s semantic layer, and 2023 is jsontext. The first commit of the v2 prototype was in October 2020. The name jsontext arrived in 2023, when the two-package structure was proposed in Discussion #63397. Sixteen years of decisions are stacked up in a single directory.
Comparing against nojsonv2
Is this opt-out truly equivalent?
- https://go.dev/play/p/LU764Er5F_G (The Playground cannot set
GOEXPERIMENT, so you only get the default side’s output there.)
package main
import (
"encoding/json"
"fmt"
)
type User struct {
Name string `json:"name"`
}
func main() {
cases := []string{
`{"name":}`,
`{"name":"a"`,
`{"name":"a"} extra`,
`[1,2,3]`,
}
for _, in := range cases {
var u User
fmt.Printf("%-22s -> %v\n", in, json.Unmarshal([]byte(in), &u))
}
var ch chan int
_, err := json.Marshal(ch)
fmt.Printf("%-22s -> %v\n", "chan int", err)
}
--- default (v2 backend) ---
{"name":} -> invalid character '}' looking for beginning of value
{"name":"a" -> unexpected end of JSON input
{"name":"a"} extra -> invalid character 'e' after top-level value
[1,2,3] -> json: cannot unmarshal array into Go value of type main.User
chan int -> json: unsupported type: chan int
--- GOEXPERIMENT=nojsonv2 ---
{"name":} -> invalid character '}' looking for beginning of value
{"name":"a" -> unexpected end of JSON input
{"name":"a"} extra -> invalid character 'e' after top-level value
[1,2,3] -> json: cannot unmarshal array into Go value of type main.User
chan int -> json: unsupported type: chan int
Even the error wording matches. The release notes warn that “the exact text of error messages may differ,” but within the range I tried here, no difference appeared. This is not a proof that every case matches. Still, for a swap onto an entirely different implementation, the fact that things line up this closely is worth putting on record.
Note that this match is supported by bridging code such as v2_inject.go, which contains the logic to reconstruct, from the v2 side, error types like *MarshalerError that v1 used to return.
Randomized error wording
While comparing error strings, I noticed something odd.
Printing the time.Duration error repeatedly, the wording changed even though the code was identical. Here is the result of running the same binary 12 times:
$ for i in $(seq 1 12); do ./exp6bin; done | sort | uniq -c
11 json: cannot marshal from Go time.Duration within "/d": no default representation
1 json: unable to marshal from Go time.Duration within "/d": no default representation
cannot and unable to alternate. This is not a bug. v2/errors.go says:
// errorModalVerb is a modal verb like "cannot" or "unable to".
//
// Once per process, Hyrum-proof the error message by deliberately
// switching between equivalent renderings of the same error message.
// The randomization is tied to the Hyrum-proofing already applied
// on map iteration in Go.
var errorModalVerb = sync.OnceValue(func() string {
for phrase := range map[string]struct{}{"cannot": {}, "unable to": {}} {
return phrase // use whichever phrase we get in the first iteration
}
return ""
})
By deliberately switching between equivalent phrasings of the same message, it prevents anyone from depending on the exact error wording. The implementation rides on the fact that Go’s map iteration order is already randomized, and the choice is made once per process.
Hyrum’s Law states that with a sufficient number of users, all observable behaviors of your system will be depended on by somebody, regardless of what you promise in the spec2.
We have already seen consequences of Hyrum’s Law throughout this book — cases where a fix was wanted but would cause harm. Case-insensitive matching couldn’t be fixed because it was documented. The behavior where methods were or weren’t called depending on addressability — something you could call a bug — was reverted because too much depended on it. The spec of accepting duplicate keys still robs decoding into any of its optimized path.
So v2 went and broke, of its own accord, the observable fact that error wording is stable. It is similar to deliberately randomizing map iteration. A lesson learned over 14 years is applied from the very first line of the new package.
Consequently, if you write tests that string-match error messages, they will break when you move to v2. That is intended.
Measuring performance
So how much faster does moving to v2 actually make things? From the release notes:
Marshal performance is broadly at parity with the previous implementation, while unmarshal performance is significantly faster.
The official blog goes a bit further and uses the phrase “up to 10x” for unmarshal. I measured it myself: reading a 1000-element JSON array into a slice of concrete structs.
goos: darwin
goarch: arm64
cpu: Apple M5 Pro
BenchmarkUnmarshalV1-18 2295 523371 ns/op 121.66 MB/s 242924 B/op 4012 allocs/op
BenchmarkUnmarshalV2-18 2716 446989 ns/op 142.44 MB/s 242924 B/op 4012 allocs/op
BenchmarkMarshalV1-18 5594 213990 ns/op 66148 B/op 3 allocs/op
BenchmarkMarshalV2-18 5516 217250 ns/op 66467 B/op 3 allocs/op
1.18x for unmarshal; marshal is about the same. A far cry from “up to 10x.”
The 10x figure comes from a different situation. Read the same data into any and you get:
BenchmarkAnyV1-18 921 1306860 ns/op 48.72 MB/s 739932 B/op 23014 allocs/op
BenchmarkAnyV2-18 2091 581713 ns/op 109.45 MB/s 626968 B/op 17012 allocs/op
A 2.25x gap, and the allocation count drops from 23014 to 17012.
Note here what BenchmarkAnyV1 is calling: encoding/json’s Unmarshal. In Go 1.27, that too runs on top of the v2 implementation. Yet it is more than twice as slow as calling v2 directly.
The cause lies in one of the options that preserve v1 behavior. Decoding into any has a dedicated optimized path, but its entrance carries a condition:
- src/encoding/json/v2/arshal_default.go#1904 (go1.27.0)
if optimizeCommon &&
t == anyType && !uo.Flags.Get(jsonflags.AllowDuplicateNames|jsonflags.FormatTag) &&
(uo.Unmarshalers == nil || !uo.Unmarshalers.(*Unmarshalers).fromAny) {
v, err := unmarshalValueAny(dec, uo)
If AllowDuplicateNames is set, this path is unreachable. The optimized implementation performs no duplicate-key checking, so it cannot be used under a setting that allows duplicates.
And v1’s spec was to accept duplicate keys. In other words, as long as you call the v1 API, this flag is always set.
Whether that is really what matters can be measured in isolation. Call v2 directly, but align only the duplicate-key handling with v1:
jsonv2.Unmarshal(data, &v, jsontext.AllowDuplicateNames(true))
BenchmarkAnyV1-18 890 1306831 ns/op 48.72 MB/s 739952 B/op 23014 allocs/op
BenchmarkAnyV2-18 2060 584852 ns/op 108.87 MB/s 626969 B/op 17012 allocs/op
BenchmarkAnyV2AllowDup-18 994 1219219 ns/op 52.22 MB/s 739925 B/op 23014 allocs/op
Flipping a single option drops it back to roughly v1’s level. The allocation count, 23014, matches v1 exactly.
The bill for the decision made 14 years ago — silently accepting duplicate keys — lingers in this form. To preserve behavioral compatibility, the optimized path is thrown away.
There is indeed a range where “v1 now sits on v2, so it gets faster with no effort” holds, but the place where the difference is largest receives none of that benefit. As long as you keep calling the v1 API, the cost of reproducing v1’s behavior remains. If you want the performance, you need to call the v2 API explicitly.
Note that this measurement is a single example against data of a particular shape. Results vary with the JSON structure, the number of fields, and the combination of types. If this matters for your production environment, benchmark with your own data.
The 16 differences between v1 and v2
The encoding/json documentation has a section called “Migrating to v2” that lists 16 behavioral differences between v1 and v2. It was written as a migration guide. But read just the left column from top to bottom and it starts to look like something else: a list of what came to be judged a mistake over 14 years.
| v1 behavior | v2 behavior |
|---|---|
| Matches field names case-insensitively | Matches strictly and case-sensitively |
omitempty decides based on whether the Go value is empty | omitempty decides based on whether the JSON would be empty |
The string tag applies to strings, booleans, and numbers, and does not recurse | Applies only to numbers, and recurses into composite types |
nil slices and nil maps become null | Become an empty JSON array and an empty JSON object |
| A Go array can be read from a JSON array of any length | Errors unless the lengths match |
[N]byte is a JSON array of numbers | A Base64-encoded JSON string |
| Pointer-receiver methods are called only when addressable | Always called |
| Methods are not called on map keys | They are called |
| Maps are output in deterministic order | Non-deterministic order |
| Escapes characters for HTML and JavaScript | Escapes only when grammatically required |
| Replaces invalid UTF-8 with the replacement character | Errors |
| Accepts duplicate keys | Errors |
Unmarshaling null into a non-empty value sometimes zeroes it, sometimes not | Always zeroes it |
| Merge rules for non-zero values are inconsistent | Merges JSON objects, replaces everything else |
time.Duration is a number of nanoseconds | Has no default representation and errors |
| Structurally invalid types do not cause runtime errors | Cause runtime errors |
Multiple v1 entries read “inconsistent” or “sometimes, sometimes not.” The problem of methods being called or not called depending on addressability is one example. Even things you could call bugs had become unfixable once dependencies piled up on them.
This is the usual pattern when Go introduces a new feature: the experiment flag starts as opt-in and switches to opt-out after the official release. ↩︎
Named after Hyrum Wright. https://www.hyrumslaw.com/ ↩︎