> ## Content Index
> Fetch the complete content index at: https://huizhou92.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# encoding/json/v2 Waited Five and a Half Years to Become Go’s Default
- URL: https://huizhou92.com/encoding-json-v2-waited-five-and-a-half-years-to-become-gos-default/
- Published: 2026-08-10T06:56:48.000Z
- Updated: 2026-08-10T06:56:48.000Z
- Description: From a Private Experiment to the Standard Library — the Full Timeline, Local Benchmarks, and Three Gotchas the Release Notes Skip
- Author: huizhou92
- Tags: #Migrated-1788833207488, #Import 2026-09-08 02:07

#### 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](https://github.com/hxzhouh/blog-example/tree/main/go1.27?ref=huizhou92.com). 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](https://github.com/golang/go/discussions/63397?ref=huizhou92.com). 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](https://github.com/golang/go/issues/71497?ref=huizhou92.com) 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](https://github.com/golang/go/issues/76406?ref=huizhou92.com) 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.

![Five and a half years from a private experiment to the default standard library implementation (image generated with doubao-seedream)](https://cdn-images-1.medium.com/max/800/0*aGYGFzOWa7350u1U.png)

#### **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](https://github.com/golang/go/issues/21990?ref=huizhou92.com)), omitting fields by value ([#11939](https://github.com/golang/go/issues/11939?ref=huizhou92.com) and five other issues all ask for this), encoding a nil slice as `[]` rather than `null` ([#37711](https://github.com/golang/go/issues/37711?ref=huizhou92.com)), inlining without relying on Go embedding ([#6213](https://github.com/golang/go/issues/6213?ref=huizhou92.com)). 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](https://github.com/golang/go/issues/36225?ref=huizhou92.com)). 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](https://github.com/golang/go/issues/41144?ref=huizhou92.com)). `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.
- `UnmarshalJSON` takes a complete JSON value, so the json package must first scan the whole thing to find where the value ends, and then `UnmarshalJSON` parses it a second time internally. If the implementation calls `Unmarshal` recursively, this becomes **quadratic**. The performance collapse parsing `spec.Swagger` in [kubernetes/kube-openapi#315](https://github.com/kubernetes/kube-openapi/issues/315?ref=huizhou92.com) came from exactly this. Not a theoretical problem.
- `Decoder.Token` returns an interface, so boxing numbers and strings always allocates ([#40128](https://github.com/golang/go/issues/40128?ref=huizhou92.com)).
- `Encode`/`Decode` appear to take an `io.Writer`/`io.Reader`, but buffer the entire value in memory ([#33714](https://github.com/golang/go/issues/33714?ref=huizhou92.com)).

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](https://www.rfc-editor.org/rfc/rfc8259?ref=huizhou92.com) requires UTF-8.
- Duplicate object names are allowed, while [RFC 7493](https://www.rfc-editor.org/rfc/rfc7493?ref=huizhou92.com) recommends rejecting them. Field names match case-insensitively ([#14750](https://github.com/golang/go/issues/14750?ref=huizhou92.com)). `MarshalJSON`/`UnmarshalJSON` are skipped when a value is not addressable ([#22967](https://github.com/golang/go/issues/22967?ref=huizhou92.com) 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 `len` and `cap` without zeroing them ([#21092](https://github.com/golang/go/issues/21092?ref=huizhou92.com)).
- 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.

![Only "behavioral defects" out of the four categories forced v2 (image generated with doubao-seedream)](https://cdn-images-1.medium.com/max/800/0*UwfJw8g2ymhUzpJi.png)

#### **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**](https://www.cve.org/CVERecord?id=CVE-2017-12635&ref=huizhou92.com) 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.

```go
// 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](https://github.com/hxzhouh/blog-example/tree/main/go1.27/crossver/behavior?ref=huizhou92.com) 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:

```css
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](https://github.com/hxzhouh/blog-example/tree/main/go1.27/crossver/behavior/escape?ref=huizhou92.com): `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](https://github.com/hxzhouh/blog-example/tree/main/go1.27/crossver/jsonv1?ref=huizhou92.com) and [raw output](https://github.com/hxzhouh/blog-example/tree/main/go1.27/results?ref=huizhou92.com) 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:

![Table Image](https://cdn-images-1.medium.com/max/800/1*Chp2VB60czq0fJDAgp9Mmg.png)

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

![Winners on the left, losers on the right; the longest bar is small-object Marshal (image generated with doubao-seedream)](https://cdn-images-1.medium.com/max/800/0*hcL5iZrZDJhfo6TU.png)

#### **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](https://github.com/hxzhouh/blog-example/tree/main/go1.27/go127/stdlib/jsonv2?ref=huizhou92.com).

**`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.

```go
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:

```go
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:

```css
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](https://www.rfc-editor.org/rfc/rfc8785?ref=huizhou92.com), so you do not have to write that yourself for JSON signing and deduplication:

```go
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:

```less
json.Marshal(v, v1.DefaultOptionsV1())   // v2 API, v1 behavior
```

#### **Three 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:

```css
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 option
```

I tried `DateOnly`, `'2006-01-02'`, `RFC3339`, `sec`, `base64`, and `hex`. Same error every time. Reading the source explained it: [v2/fields.go:258](https://github.com/golang/go/blob/go1.27rc2/src/encoding/json/v2/fields.go?ref=huizhou92.com#L258) records an `errUnsupportedFormat` **unconditionally** whenever it finds a non-empty `format`, and the only thing that clears it in [v2/arshal\_default.go:1132](https://github.com/golang/go/blob/go1.27rc2/src/encoding/json/v2/arshal%5Fdefault.go?ref=huizhou92.com#L1132) is the internal flag `jsonflags.FormatTagSupported`.

That flag lives in [internal/jsonflags](https://github.com/golang/go/tree/go1.27rc2/src/encoding/json/internal/jsonflags?ref=huizhou92.com), 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?**

1. 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% `unmarshal` speedup for free.
2. 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 of `json.Marshal` byte for byte.
3. 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 `Deterministic` plus `Canonicalize` beats 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**

1. 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.
2. 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.
3. Performance is not an across-the-board speedup. Unmarshal into a struct is 38% faster with half the allocations, but `map[string]any` is 48% slower and small-object Marshal is **79%** slower. That line about "Marshal performance is broadly at parity" does not hold on small payloads.
4. The `format` tag mechanism is written but not exposed, and `GOEXPERIMENT=nojsonv2` is 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**](https://github.com/hxzhouh/blog-example/tree/main/go1.27?ref=huizhou92.com)

- [crossver/jsonv1](https://github.com/hxzhouh/blog-example/tree/main/go1.27/crossver/jsonv1?ref=huizhou92.com) — 12 cross-version benchmarks for the v1 API
- [crossver/behavior](https://github.com/hxzhouh/blog-example/tree/main/go1.27/crossver/behavior?ref=huizhou92.com) — 13 behavior compatibility comparisons; `escape/` is the character-by-character escape diff
- [go127/stdlib/jsonv2](https://github.com/hxzhouh/blog-example/tree/main/go1.27/go127/stdlib/jsonv2?ref=huizhou92.com) — 12 sections demonstrating v2 plus jsontext
- [results/](https://github.com/hxzhouh/blog-example/tree/main/go1.27/results?ref=huizhou92.com) — the raw output behind every table in this article

To reproduce:

```bash
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
```