Go 1.27 makes encoding/json/v2 default after 5½ years – timeline and benchmarks

After a five‑year experimental phase, Go’s new json/v2 package becomes the default in Go 1.27; the author traces its history, presents on‑machine benchmark comparisons showing faster struct unmarshalling but slower map handling, and reveals three undocumented issues—including format‑tag removal, altered UTF‑8 output, and map[string]any slowdown—that developers must consider when migrating.

Radish, Keep Going!
Radish, Keep Going!
Radish, Keep Going!
Go 1.27 makes encoding/json/v2 default after 5½ years – timeline and benchmarks

Timeline

Encoding/json/v2 began as a private experiment repository github.com/go-json-experiment/json on 2021‑02‑19, before Go generics were released. The discussion thread #63397 (GitHub discussion) gathered 227 up‑votes and many comments. A formal proposal #71497 was submitted on 2025‑01‑31, describing the change as “the largest major revision of a standard Go package to date”. The proposal was accepted in early May 2026 and the package became the default in Go 1.27 after five years and six months of development.

Performance claims from release notes

Marshal performance is broadly at parity with the previous implementation, while unmarshal performance is significantly faster.

Local benchmarks confirm the unmarshal speedup for concrete structs (‑38.9 %). However, unmarshalling into map[string]any is slower (+48 %). Example output:

UnmarshalAny1000-10    1.846m ± 1%    2.735m ± 0%    +48.14%

Running the same benchmark with GOEXPERIMENT=nojsonv2 reproduces the original behavior, confirming the slowdown originates from the new engine.

Four classes of issues that motivated v2

Missing functionality

Custom time.Time formats (issue #21990)

Omit‑empty slices as [] instead of null (issue #11939)

Inline embedding without Go embedding (issue #6213)

API defects

json.NewDecoder(r).Decode(v)

does not reject trailing garbage (issue #36225)

Options can only be set on Encoder / Decoder, not on Marshal / Unmarshal (issue #41144) Compact and Indent are hard‑wired to

*bytes.Buffer

Performance ceiling

MarshalJSON() ([]byte, error)

forces a []byte allocation and a second validation pass. UnmarshalJSON requires a full scan to find the value end, then a second pass for parsing, leading to quadratic complexity in some cases (kubernetes/kube-openapi#315). Decoder.Token returns interface{}, causing boxing allocations (issue #40128). Encode / Decode buffer the entire value in memory (issue #33714).

Three of these can be mitigated with new APIs; the fourth (streaming) would require a breaking change.

Behaviour defects (unsolvable)

Allowing illegal UTF‑8 (RFC 8259 requires UTF‑8).

Allowing duplicate object keys (RFC 7493 recommends rejection).

Case‑insensitive field matching and merge semantics for nil slices.

These defects expose real security issues, e.g., CVE‑2017‑12635 where differing duplicate‑key handling in CouchDB gave attackers admin rights.

Three undocumented pitfalls

Pitfall 1 – format tag is unusable

Struct tags such as `json:"t,format:DateOnly"` now cause a compile‑time error:

json: unable to marshal from Go main.A: Go struct field T has unsupported `format` tag option

. The check lives in v2/fields.go:258 and returns errUnsupportedFormat unless the internal flag jsonflags.FormatTagSupported is set; no exported option can set that flag.

Pitfall 2 – Illegal UTF‑8 output bytes changed

When marshaling a string containing illegal UTF‑8, the old engine emitted the escape sequence \ufffd\ufffd, while the new engine emits the literal replacement character (U+FFFD). This change is not mentioned in the release notes, which only say that error messages may differ.

// v2 default rejects illegal UTF‑8
json.Unmarshal([]byte(`{"a":1,"a":2}`), &m) // → jsontext: duplicate object member name "a"
// To keep old behaviour:
json.Unmarshal([]byte(`{"a":1,"a":2}`), &m, jsontext.AllowDuplicateNames(true))

Pitfall 3 – map[string]any slowdown

Benchmark shows a +48 % slowdown for unmarshalling into map[string]any and a +79 % slowdown for Marshal on many small objects. This regression is not mentioned in the release notes.

Benchmark details

Benchmarks were run on three toolchains: go1.26.2, go1.27rc2, and go1.27rc2 + GOEXPERIMENT=nojsonv2. The payload is a struct containing time.Time, map[string]string, pointers, nested structs, and slices.

MarshalSmall-10            67.71ns ± 2%   121.05ns ± 1%   +78.79%
MarshalIndent100-10      155.3µ  ± 1%    105.2µ  ± 1%   -32.26%
UnmarshalStruct100-10    189.7µ ± 1%    117.8µ ± 1%   -37.92%
UnmarshalAny1000-10      1.846m ± 1%    2.735m ± 0%   +48.14%
DecoderStream1000-10     2.050m ± 0%    1.501m ± 17%  -26.81%
Valid1000-10             456.6µ ± 6%    197.0µ ± 6%   -56.85%

Geometric‑mean time improves by -12.63 % , while allocation counts drop by -37 % (e.g., Marshal1000-10 from 8.003k to 5.003k allocations).

Marshal1000-10           8.003k → 5.003k allocs   -37.5%
UnmarshalStruct1000-10   21.69k → 10.42k allocs   -52.0%
MarshalSmall-10           6 → 1 allocs            -83.3%

Features worth using in v2

omitzero

(zero‑value check via IsZero()) vs omitempty (JSON‑level check). embed field to capture unknown fields without manual map[string]json.RawMessage handling.

Functional marshaler via json.WithMarshalers to customize output without new types. jsontext.Value.Canonicalize() implements RFC 8785 canonicalization for deterministic signatures.

One‑click switch back to v1 semantics with json.Marshal(v, v1.DefaultOptionsV1()).

Migration guidance

Existing code can remain unchanged; the v1 API stays supported and most projects see a 38 % speedup for struct unmarshalling without changes.

Pay attention to code paths that heavily serialize small objects or unmarshal into map[string]any. Verify golden files, checksums, or webhook signatures for possible byte‑level differences caused by the illegal UTF‑8 handling.

Adopt v2 when strict validation (reject duplicate keys, illegal UTF‑8, unknown fields) or deterministic canonicalization is required.

Conclusion

The switch to v2 was driven by three default behaviours that could not be patched in v1: illegal UTF‑8, duplicate keys, and case‑insensitive matching, all of which have real security implications.

Across 13 behaviour tests only one byte‑level difference was observed: illegal UTF‑8 is now emitted as the literal replacement character instead of an escaped sequence.

Performance is mixed: struct unmarshalling is 38 % faster with roughly half the allocations, but map[string]any handling is 48 % slower and small‑object marshaling can be up to 79 % slower.

The format tag remains unsupported; the only way to revert the whole change is the temporary GOEXPERIMENT=nojsonv2 flag, which the Go team plans to remove in a future release.

All benchmark code, raw results, and reproducibility instructions are available in the public repository github.com/hxzhouh/blog-example/go1.27.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

MigrationperformanceGoencodingjsonbenchmark
Radish, Keep Going!
Written by

Radish, Keep Going!

Personal sharing

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.