encoding/json/v2 Waited Five and a Half Years to Become Go’s Default
From a Private Experiment to the Standard Library — the Full Timeline, Local Benchmarks, and Three Gotchas the Release Notes Skip
GOLANG
encoding/json/v2 Waited Five and a Half Years to Become Go’s Default
Go 1.27 ships soon. From the release notes, the biggest change for ordinary developers is that json v2 graduates. I went to check the one claim the notes make about performance:
Marshal performance is broadly at parity with the previous implementation, while unmarshal performance is significantly faster.
Marshal holds steady, unmarshal gets much faster. I ran my own benchmarks against that. The row for unmarshaling into a concrete struct matched, -38.9%. The row right below it did not.
UnmarshalAny1000-10 1.846m ± 1% 2.735m ± 0% +48.14%
UnmarshalMap1000-10 1.857m ± 2% 2.740m ± 38% +47.52%Unmarshaling into map[string]any was almost half as slow again. My first thought was machine noise, so I pushed count to 10 and reran. It reproduced. Then I switched to GOEXPERIMENT=nojsonv2 on the same toolchain as a control, which ruled out other 1.27 changes. It is the new engine itself.
Every number below reproduces. The code, benchmarks, and raw benchstat output live in hxzhouh/blog-example/go1.27. Run ./run.sh json.
So I went digging through the package’s history. json v2 took five and a half years to land in the source tree, and what people argued about over those five and a half years was never runtime speed.
A Private Repo that Ran for Five and a Half Years
Joe Tsai (dsnet, a member of the Go team and the maintainer of the compress/* packages) created a repository called github.com/go-json-experiment/json on February 19, 2021, more than a year before generics landed in Go. It was a personal experiment with no official standing.
Two and a half years later, on October 5, 2023, he wrote up what he had learned as discussion #63397. It drew 227 upvotes and 96 comments, and the acknowledgments name mvdan, rogpeppe, and rsc.
Another year and change after that, on January 31, 2025, the formal proposal #71497 went in and eventually collected 222 comments. One line near the top of it stuck with me:
This is the largest major revision of a standard Go package to date.
In August 2025, Go 1.25 shipped encoding/json/v2 behind GOEXPERIMENT=jsonv2, with an explicit note that the design would keep evolving. Go 1.26 left it alone, and the release notes did not mention it at all.
On November 21, 2025, Austin Clements opened #76406 to publish the minutes of the json/v2 working group. A standing working group with public minutes, for one standard library package, is not something you see often in the Go project.
Likely accept came on May 6, 2026, and was accepted on May 13. By the time it is on by default in Go 1.27, five years and six months have passed since that first commit in a private repo.

Why it Had to Be V2 instead of a Patch on V1
dsnet never says “v1 is broken” outright in the discussion. He sorts the problems in encoding/json into four categories and judges each one on whether it can be fixed without breaking compatibility.
Category one, missing functionality. Custom time.Time formats (#21990), omitting fields by value (#11939 and five other issues all ask for this), encoding a nil slice as [] rather than null (#37711), inlining without relying on Go embedding (#6213). All of these could be added to v1 without breaking compatibility.
Category two, API defects. json.NewDecoder(r).Decode(v) does not reject trailing garbage (#36225). Options can only be set on Encoder/Decoder, so Marshal/Unmarshal cannot use them and there is no way to pass them down the call stack (#41144). Compact and Indent hardcode *bytes.Buffer. New APIs could solve these too, at the cost of several ways to do the same thing inside one package.
Category three, the performance ceiling. The bottleneck is not the implementation. It is the shape of the exported API:
MarshalJSON() ([]byte, error)forces the implementer to allocate a[]byte, and once it returns, the json package has to parse the result again to validate it and fix up indentation.UnmarshalJSONtakes a complete JSON value, so the json package must first scan the whole thing to find where the value ends, and thenUnmarshalJSONparses it a second time internally. If the implementation callsUnmarshalrecursively, this becomes quadratic. The performance collapse parsingspec.Swaggerin kubernetes/kube-openapi#315 came from exactly this. Not a theoretical problem.Decoder.Tokenreturns an interface, so boxing numbers and strings always allocates (#40128).Encode/Decodeappear to take anio.Writer/io.Reader, but buffer the entire value in memory (#33714).
The first three can be routed around with new interfaces. The last one cannot. Making it genuinely streaming is a breaking change by itself.
Category four, behavioral defects. Only this category has no way out:
- Invalid UTF-8 is allowed, while RFC 8259 requires UTF-8.
- Duplicate object names are allowed, while RFC 7493 recommends rejecting them. Field names match case-insensitively (#14750).
MarshalJSON/UnmarshalJSONare skipped when a value is not addressable (#22967 and three other issues; a fix landed once historically and was reverted because it broke too much code that depended on the bug). - Merge semantics are a mess. The worst case: unmarshaling into a non-nil slice merges straight into the elements between
lenandcapwithout zeroing them (#21092). - Error types have no structure. Syntax errors, semantic errors, and IO errors cannot be told apart.
All of these are default behavior. Changing any one of them breaks compatibility.
Put differently, v2 was forced by category four. Had the first three existed on their own, we would probably still be using a heavily patched v1 today.

Duplicate Keys Really Can Escalate Privileges
Those behavioral defects sound like fussiness. One of them is not.
RFC 8259 leaves the handling of duplicate names in a JSON object undefined. Take the first, take the last, ignore it, or raise an error, and you are still within spec. The same JSON bytes can therefore produce different values in parsers written in different languages.
The encoding/json/v2 package documentation spells out the attack model. Say you have two microservices, the first handling authentication and the second handling execution. An attacker crafts a JSON payload the two services disagree about, passes authentication with A's valid credentials, and then executes as B.
This is not hypothetical. CVE-2017–12635 is CouchDB hitting this: its Erlang and JavaScript JSON parsers handled duplicate keys differently, and a regular user could escalate straight to admin.
Case-insensitive matching is another entrance to the same class of problem. An attacker swaps in a case variant your security scanner does not recognize ({"UserName":...} matching json:"username") and walks past it. It is also a performance bottleneck, since that kind of matching cannot be done with a single map lookup.
v2 flips the default on all three: invalid UTF-8 errors out, duplicate keys error out, and matching is case-sensitive. If you want the old behavior, you pass an option explicitly.
// v2 rejects this by default
json.Unmarshal([]byte(`{"a":1,"a":2}`), &m)
// → jsontext: duplicate object member name "a"
// say so if you want the old behavior
json.Unmarshal([]byte(`{"a":1,"a":2}`), &m, jsontext.AllowDuplicateNames(true))V1 now Runs on the V2 Engine, and One Byte Differs
The most aggressive change is in the old package. encoding/json now runs entirely on top of the v2 implementation.
I wrote a comparison program covering 13 behaviors and ran it in three environments, go1.26.2, go1.27rc2, and go1.27rc2 + GOEXPERIMENT=nojsonv2. It covers encoding of nil slices and maps, duplicate keys, case matching, overwriting non-empty slices and maps, HTML escaping, numeric overflow, null handling, trailing garbage, cyclic references, and error text.
Twelve of them behave identically. The thirteenth does not:
input S{A: "\xff\xfe"}
go1.26.2 {"a":"��", ...} ← written as a 12-character ASCII escape sequence
go1.27rc2 {"a":"<raw U+FFFD, six bytes: EF BF BD EF BF BD>", ...}
go1.27rc2 nojsonv2 {"a":"��", ...}I also swept the other troublesome code points character by character: U+2028, U+2029, 0x01, 0x7f, HTML's <>&, and valid multibyte sequences. All identical. The only difference is this one, how the replacement character for invalid UTF-8 gets written.
Both forms are valid JSON and parse to the same string. But if you have ever diffed the output of json.Marshal against a golden file, checksummed it, or used it to compute a webhook signature, and your data can pick up dirty latin1 bytes, this will blow up on you.
The release notes say one thing about all of this:
Marshaling and unmarshaling behavior is preserved, but the exact text of error messages may differ.
Behavior is preserved; only the text of error messages may differ. But what changed above is not error text. It is output bytes.
Measured: This is not an Across-the-board Speedup
The benchmark code and raw output are both in the repo. Test environment: Apple M5, 10 cores, 16 GB, darwin/arm64, go1.26.2 vs go1.27rc2, benchstat, count=8. The payload is Payload{Users []User}, where User holds a time.Time, a map[string]string, a *string, a nested struct, and a []string.
│ go1.26.2 │ go1.27rc2 │
│ sec/op │ sec/op vs base │
MarshalSmall-10 67.71n ± 2% 121.05n ± 1% +78.79%
Marshal100-10 58.15µ ± 2% 60.36µ ± 1% +3.79%
Marshal1000-10 584.0µ ± 1% 603.8µ ± 1% +3.39%
MarshalIndent100-10 155.3µ ± 1% 105.2µ ± 1% -32.26%
EncoderStream1000-10 565.6µ ± 1% 582.2µ ± 0% +2.94%
UnmarshalStruct100-10 189.7µ ± 1% 117.8µ ± 1% -37.92%
UnmarshalStruct1000-10 1.950m ± 1% 1.191m ± 1% -38.94%
UnmarshalAny1000-10 1.846m ± 1% 2.735m ± 0% +48.14%
UnmarshalMap1000-10 1.857m ± 2% 2.740m ± 38% +47.52%
DecoderStream1000-10 2.050m ± 0% 1.501m ± 17% -26.81%
UnmarshalSmall-10 309.4n ± 6% 175.0n ± 3% -43.46%
Valid1000-10 456.6µ ± 6% 197.0µ ± 6% -56.85%
geomean 143.2µ 125.1µ -12.63%Allocations tell a different story, with a geomean of -37%:
Marshal1000-10 8.003k → 5.003k allocs -37.5%
UnmarshalStruct1000-10 21.69k → 10.42k allocs -52.0%
UnmarshalSmall-10 6 → 1 allocs -83.3%
EncoderStream1000-10 266.5Ki → 79.1Ki B/op -70.3%
MarshalSmall-10 1 alloc/48B → 2 allocs/80B
UnmarshalAny1000-10 57.02k → 61.97k allocs +8.7%Marshal allocates 37% less and still takes 3% longer. The v2 encoder does more CPU work per byte, and the extra work is the state machine inside jsontext that guarantees the output is well-formed.
MarshalSmall going from 67.7ns to 121ns is the most glaring row. A single call goes from 1 allocation to 2, and from 48 bytes to 80. For a service that serializes one small JSON response per request, this path gets close to twice as slow.
Sorted by how you use it:

If your code habitually dumps a payload into map[string]any and picks fields out of it, benchmark before you upgrade.

What in the V2 API is Actually Worth Using
Performance aside, v2 fills in a batch of things v1 argued about for a decade and never shipped. Every snippet below runs directly in go127/stdlib/jsonv2.
omitzero and omitempty are now two different things. The first judges by Go's type system (if there is an IsZero(), it uses it), the second by JSON's type system. For a slice that is empty but not nil, omitzero keeps it and omitempty drops it. That distinction is exactly what the #11939 chain of issues argued over for years.
type User struct {
Name string `json:"name"`
Age int `json:"age,omitzero"`
Tags []string `json:"tags,omitempty"`
Balance int64 `json:"balance,string"` // emit as a string, dodging JS's 53-bit precision
Loose string `json:"loose,case:ignore"` // relax case matching per field
}embed with a catch-all field. v1 has no equivalent at all:
type Outer struct {
Inner `json:",embed"`
C int
Rest map[string]jsontext.Value `json:",embed"` // catches every unknown field
}Feed it {"A":1,"B":2,"C":3,"x":true,"y":[1,2]} and the unknown x and y land in Rest, then Marshal back out intact. Doing this in v1 means wrapping the struct in a map[string]json.RawMessage yourself and hand-resolving the conflicts with the named fields.
Functional marshalers. No changes to the type definition, no newtype wrapper:
opts := json.WithMarshalers(json.MarshalToFunc(func(e *jsontext.Encoder, m Money) error {
return e.WriteToken(jsontext.String(fmt.Sprintf("%d.%02d", m/100, m%100)))
}))
json.Marshal(struct{ P Money }{12345}, opts) // → {"P":"123.45"}Syntax-level operations on jsontext.Value. Canonicalize() sorts keys recursively per RFC 8785, so you do not have to write that yourself for JSON signing and deduplication:
raw := jsontext.Value(`{"b":2,"a":{"d":4,"c":3}}`)
raw.Canonicalize() // → {"a":{"c":3,"d":4},"b":2}One switch back to v1 semantics. The most useful thing during migration:
json.Marshal(v, v1.DefaultOptionsV1()) // v2 API, v1 behaviorThree Gotchas the Release Notes Skip
Gotcha one: the format tag simply does not work in Go 1.27.
If you have read early json/v2 articles or used go-json-experiment/json, you have seen this:
type A struct{ T time.Time `json:"t,format:DateOnly"` }In the Go 1.27 standard library, it errors out:
json: unable to marshal from Go main.A: Go struct field T has unsupported `format` tag optionI tried DateOnly, '2006-01-02', RFC3339, sec, base64, and hex. Same error every time. Reading the source explained it: v2/fields.go:258 records an errUnsupportedFormat unconditionally whenever it finds a non-empty format, and the only thing that clears it in v2/arshal_default.go:1132 is the internal flag jsonflags.FormatTagSupported.
That flag lives in internal/jsonflags, and no exported Option can set it.
The whole mechanism is in the code. There is just no door. To customize a time format in 1.27, you still go through MarshalToFunc or wrap the type.
Gotcha two: the output bytes for invalid UTF-8 changed. Covered above. Check whether you diff the output of json.Marshal against golden files, checksum it, or sign it.
Gotcha three: map[string]any is 48% slower. The official release notes give no warning about this one, and it happens to be the most common way people handle dynamic JSON.
All three share one fallback: build with GOEXPERIMENT=nojsonv2 to fall back to the old implementation wholesale. The Go team says the flag is expected to be removed in some future release, so do not treat it as a long-term plan.
How Should You Migrate?
- Existing code does not need to change. The v1 API is supported indefinitely, and the Go team explicitly says migration is not required. Most projects can upgrade to 1.27, change nothing, and get the 38%
unmarshalspeedup for free. - Two kinds of code deserve benchmarking first: high-QPS services that serialize a lot of small objects, and code paths that unmarshal JSON into
map[string]any. While you are there, check whether any test or signing logic compares the output ofjson.Marshalbyte for byte. - There are two main reasons to move to v2 on purpose. One is a public-facing interface that needs strict validation, rejecting duplicate keys, invalid UTF-8, and unknown fields. The other is a gateway or configuration system that has to pass unknown fields through losslessly. Signature and cache-key computation counts as half a reason too, since
DeterministicplusCanonicalizebeats rolling your own normalization.
Starting a new project on the v2 API is fine, as long as you confirm the missing format tag does not affect you. Time formats show up in real projects more often than you would think.
Takeaways
- It had to be v2 rather than a patch on v1 for one reason: the three defaults of invalid UTF-8, duplicate keys, and case-insensitive matching cannot be changed, and they are a real attack surface (CVE-2017–12635). The missing functionality and the API defects could all have been added compatibly.
- v1 now runs on the v2 engine, and 13 behavior comparisons turned up just 1 byte-level difference: the replacement character for invalid UTF-8 is no longer written as a
�escape sequence. The release notes say behavior is preserved. - Performance is not an across-the-board speedup. Unmarshal into a struct is 38% faster with half the allocations, but
map[string]anyis 48% slower and small-object Marshal is 79% slower. That line about "Marshal performance is broadly at parity" does not hold on small payloads. - The
formattag mechanism is written but not exposed, andGOEXPERIMENT=nojsonv2is the only wholesale escape hatch, with an expiry date.
That a package needed five and a half years to get its defaults right says something about how expensive those defaults were to begin with.
Code
All the code, benchmarks, and raw benchstat output: github.com/hxzhouh/blog-example/go1.27
crossver/jsonv1— 12 cross-version benchmarks for the v1 APIcrossver/behavior— 13 behavior compatibility comparisons;escape/is the character-by-character escape diffgo127/stdlib/jsonv2— 12 sections demonstrating v2 plus jsontextresults/— the raw output behind every table in this article
To reproduce:
go install golang.org/dl/go1.27rc2@latest && go1.27rc2 download
git clone https://github.com/hxzhouh/blog-example && cd blog-example/go1.27
./run.sh json