Skip to content
chornous.dev

Type two or more letters. Esc closes.

Index of sheets
Theme

Sheet 03 · Writing

All notes

Go 1.27 makes encoding/json/v2 stable: duplicate keys and invalid UTF-8 now fail, nil slices encode as []

The Go team announced on November 4, 2026 that encoding/json/v2 and encoding/json/jsontext are stable in Go 1.27. The new packages reject duplicate names and invalid UTF-8, match struct fields case-sensitively, and unmarshal up to 10x faster than v1, which now runs on top of v2.

Pl. 60 · pipeline drawing generated from the slug “go-1-27-encoding-json-v2”

The Go team announced the new JSON API on its blog on November 4, 2026, in a post by Michael Pratt, Joe Tsai, Daniel Martí, Johan Brandhorst-Satzkorn, Roger Peppe, Chris Hines and Damien Neil. encoding/json/v2 and encoding/json/jsontext are stable and ship in Go 1.27. Go 1.25 shipped them as an experiment, and the effort as a whole took about six years.

You don't have to migrate. Both packages fall under the Go 1 compatibility promise, and the team says v1 users "need not ever migrate to v2." The reasons to move anyway are safer defaults and faster decoding.

Two packages

jsontext handles syntax: tokens, values, streaming reads and writes. It doesn't use reflection. json/v2 handles semantics, mapping JSON values to Go values on top of jsontext.

import (
    "encoding/json/jsontext"
    json "encoding/json/v2"
)

// Read straight from an io.Reader. No Decoder needed.
var cfg Config
if err := json.UnmarshalRead(r, &cfg); err != nil {
    return err
}

The v2 API pairs each operation with three entry points: Marshal/Unmarshal for byte slices, MarshalWrite/UnmarshalRead for io.Writer and io.Reader, and MarshalEncode/UnmarshalDecode for jsontext encoders and decoders. Every function accepts options as variadic arguments, a gap in v1, where options lived on Encoder and Decoder and got lost when a custom UnmarshalJSON ran.

v1's RawMessage is now a type alias for jsontext.Value.

Default behavior changes

encoding/json v1 vs v2 defaults
Input or valuev1v2
Invalid UTF-8AcceptedError
Duplicate object member namesAcceptedError
Nil slice or mapnull[] or {}
Struct field matching on unmarshalCase-insensitiveCase-sensitive

Two more changes sit outside the table. v2 redefines omitempty to omit a field that would encode as an empty JSON value: null, "", [] or {}. It also returns an error when you serialize a time.Duration, since the type has no default JSON representation, and gives you options to choose one. Most of these behaviors have a struct tag option or a caller option that restores v1 semantics.

The duplicate-name rule closes a known attack. RFC 8259 leaves duplicate names undefined, so two parsers can read the same object two ways, and the post cites CVE-2017-12635 as a past exploit of that gap. If a gateway in your stack validates a JSON body and a Go service acts on it, v2 now rejects the ambiguous version up front.

The nil-slice change will show up in API responses. A handler that returned "items": null for a nil slice returns "items": [] under v2. The Go team cites a survey in which most Go users preferred the empty array, and other JSON implementations often refuse null where they expect an array. A client that checks === null will notice the switch.

Faster decoding

The post puts v2 Marshal speed about level with v1, sometimes a bit faster and sometimes a bit slower. Unmarshal runs up to 10x faster in the team's benchmarks. The larger gains come from two new interfaces:

type MarshalerTo interface {
    MarshalJSONTo(*jsontext.Encoder) error
}
type UnmarshalerFrom interface {
    UnmarshalJSONFrom(*jsontext.Decoder) error
}

v1's UnmarshalJSON forces the package to find the end of a value before calling you, and then you parse it again. Recursive calls make that quadratic. Kubernetes hit it in kube-openapi, and switching to UnmarshalJSONFrom sped up OpenAPI parsing by orders of magnitude. If you maintain a type with a hand-written UnmarshalJSON on a hot path, add UnmarshalJSONFrom next to it.

The team plans more performance work for the cases where v2 trails v1, with improvements expected in Go 1.28.

Mixing versions

The Go team rebuilt v1 on top of v2. v1 is v2 with a full set of compatibility options turned on. A v2 caller marshaling a type with a v1 MarshalJSON method works, and a v1 caller picks up a dependency's v2 MarshalJSONTo. Your dependencies can migrate on their own schedule.

You can also take v2 defaults with one v1 behavior kept, or the reverse:

// v2 semantics, but tolerate duplicate names for now
json.Marshal(v, json.DefaultOptionsV2(), jsontext.AllowDuplicateNames(true))

// v1 semantics, but reject duplicate names
json.Marshal(v, jsonv1.DefaultOptionsV1(), jsontext.AllowDuplicateNames(false))

The encoding/json docs list each behavior difference and the option that controls it, and a migration guide walks through common cases.

This week

  1. Upgrade a service to Go 1.27 and switch one decode path to json.UnmarshalRead. Run your tests and look for duplicate-key, UTF-8 and case-mismatch errors.
  2. Before you change a public API to v2, diff a few responses. Empty slices turning into [] and omitempty dropping "" can break strict clients.
  3. Search for time.Duration fields in JSON structs and choose a format option before v2 returns errors on them.
  4. Profile your custom UnmarshalJSON methods. Any that recurse into json.Unmarshal are candidates for UnmarshalJSONFrom.
  5. Leave working v1 code alone where nothing pushes you to move. The Go team has set no end date for v1 support.

Volodymyr Chornous