Why Go Introduced encoding/json/v2: Fixes, New API, and Performance Gains
The article analyzes the long‑standing issues of Go's original encoding/json package, explains the design and API of the experimental encoding/json/v2 and jsontext packages, and shows how the new implementation improves correctness, flexibility, and performance while preserving compatibility.
Background and Motivation
JSON (JavaScript Object Notation) has become the de‑facto data‑exchange format on the web. Go's encoding/json package has been the fifth most imported package in the language for over a decade, but real‑world usage has revealed several shortcomings that conflict with the evolving JSON standards (RFC 8259) and security expectations.
Problems with the Existing encoding/json (v1)
Behavior defects
Imprecise JSON syntax handling : The decoder accepts invalid UTF‑8 and duplicate object member names, both of which RFC 8259 disallows. This can silently corrupt data and has been exploited in CVE‑2017‑12635.
Slice and map null handling : Empty slices or maps are marshaled as null, which causes downstream unmarshaling errors. A survey [4] shows most Go users prefer empty slices/maps to be encoded as [] or {}.
Case‑insensitive unmarshaling : JSON object keys are matched to struct fields without regard to case, leading to surprising behavior and potential security issues.
Inconsistent method calls : The MarshalJSON method on pointer receivers is invoked inconsistently [5]; fixing it would be a breaking change because many projects rely on the current behavior.
API defects
Unmarshaling from an io.Reader via json.NewDecoder(r).Decode(v) cannot reject trailing garbage.
Options can be set on Encoder and Decoder but not on the Marshaler / Unmarshaler interfaces, preventing options from propagating to custom implementations.
Utility functions like Compact, Indent, and HTMLEscape write to a bytes.Buffer instead of directly to []byte or io.Writer, limiting flexibility.
Performance limitations
The MarshalJSON interface forces implementations to allocate a []byte result and to re‑format the JSON, adding overhead. UnmarshalJSON must read the entire JSON value to determine its end before invoking the custom method, causing quadratic‑time behavior when the method recursively calls Unmarshal or Marshal.
Why a New Major Version Is Needed
Because of Go 1 compatibility guarantees, many of the defects cannot be fixed in place. Introducing a new, experimental major version— encoding/json/v2 —allows a clean break while still offering a migration path.
Design of the Experimental Packages
encoding/json/jsontext
This package isolates the syntactic layer of JSON handling. It defines Encoder and Decoder types that operate purely on the token stream without using reflection. The core types are:
package jsontext
type Encoder struct { ... }
func NewEncoder(w io.Writer, opts ...Option) *Encoder
func (e *Encoder) WriteValue(v Value) error
func (e *Encoder) WriteToken(t Token) error
type Decoder struct { ... }
func NewDecoder(r io.Reader, opts ...Option) *Decoder
func (d *Decoder) ReadValue() (Value, error)
func (d *Decoder) ReadToken() (Token, error)
type Value []byte
type Token struct { ... }
func (t Token) Kind() KindThe package follows RFC 8259 §2 terminology, calling the raw JSON text a “JSON‑text”. Because it works only at the syntax level, it does not depend on Go reflection.
The diagram shows the separation: the lower half (purple) represents the syntax‑only jsontext types, while the upper half (blue) represents the higher‑level json/v2 API that assigns semantic meaning to the token stream.
encoding/json/v2
The v2 package builds on jsontext and provides a familiar API surface:
package json
func Marshal(in any, opts ...Option) (out []byte, err error)
func MarshalWrite(out io.Writer, in any, opts ...Option) error
func MarshalEncode(out *jsontext.Encoder, in any, opts ...Option) error
func Unmarshal(in []byte, out any, opts ...Option) error
func UnmarshalRead(in io.Reader, out any, opts ...Option) error
func UnmarshalDecode(in *jsontext.Decoder, out any, opts ...Option) errorUnlike v1, the functions accept options as first‑class parameters, enabling fine‑grained control over behavior such as strict UTF‑8 validation, duplicate‑key rejection, and custom omitempty handling.
Custom Marshaling and Unmarshaling
v2 retains the original Marshaler and Unmarshaler interfaces but adds MarshalerTo and UnmarshalerFrom, which receive a jsontext.Encoder or jsontext.Decoder so implementations can stream data directly without allocating an intermediate []byte. Example interface definitions:
type Marshaler interface { MarshalJSON() ([]byte, error) }
type MarshalerTo interface { MarshalJSONTo(*jsontext.Encoder) error }
type Unmarshaler interface { UnmarshalJSON([]byte) error }
type UnmarshalerFrom interface { UnmarshalJSONFrom(*jsontext.Decoder) error }Caller‑specified custom functions can be supplied via WithMarshalers and WithUnmarshalers options, allowing the caller to override the type’s default methods. This is useful for cases like the protojson package, where all proto.Message types are serialized using a custom implementation.
Behavior Differences Between v1 and v2
v2 reports an error on invalid UTF‑8.
v2 reports an error on duplicate object member names.
Empty slices and maps are marshaled as [] and {} respectively.
Field name matching is case‑sensitive.
The omitempty tag now omits fields whose value is any of null, "", [], or {}.
Serializing time.Duration without an explicit option now yields an error because there is no default representation.
All of these changes can be toggled back to v1 semantics via specific struct‑tag options or caller‑provided options, enabling a gradual migration.
Performance Optimizations
Benchmarks show that v2 Marshal is roughly on par with v1—sometimes faster, sometimes slower—while Unmarshal can be up to ten times faster. Existing types that implement the original Marshaler / Unmarshaler interfaces can gain additional speed by switching to MarshalerTo and UnmarshalerFrom. For example, the Kubernetes kube-openapi parser’s recursive UnmarshalJSON calls caused a performance bottleneck; replacing them with UnmarshalJSONFrom yielded several orders of magnitude improvement [54].
Further performance data and benchmark scripts are available in the go-json-experiment/jsonbench repository [55].
Migration Strategy
Because encoding/json/v2 is experimental, it is hidden by default. Enabling it requires setting the environment variable GOEXPERIMENT=jsonv2 or building with the goexperiment.jsonv2 build tag. When built under the experiment, the standard encoding/json package is reimplemented on top of v2, ensuring that existing code continues to work while benefiting from the new implementation.
The migration path is progressive:
Gradual migration : Users can continue calling the familiar Marshal and Unmarshal functions, selecting either pure v1 behavior, pure v2 behavior, or a mix via options.
Feature inheritance : New struct‑tag options (e.g., inline, format) added to v2 are automatically available to v1 when built with the experiment.
Reduced maintenance burden : A single code base now serves both versions, so bug fixes and performance improvements propagate to both.
Although the experiment is unstable and may change, it has already been used in production by several major projects. The Go team encourages users to run their test suites with GOEXPERIMENT=jsonv2 go test ./... and report regressions on the issue tracker [57].
Conclusion
The introduction of encoding/json/v2 and its companion jsontext package addresses long‑standing correctness, security, and performance issues in Go's JSON handling while preserving a familiar API surface. By providing a clear migration path and optional compatibility modes, the experiment balances stability with the need for modern JSON features.
BirdNest Tech Talk
Author of the rpcx microservice framework, original book author, and chair of Baidu's Go CMC committee.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
