DEV Community

Cover image for 7 Go jsonv2 breaks I verified in Go 1.27
frank chu
frank chu

Posted on

7 Go jsonv2 breaks I verified in Go 1.27

7 Go jsonv2 breaks I verified in Go 1.27

All 7 of Go 1.27's jsonv2 behavior breaks fired exactly as documented when I ran them against v1 on the same inputs — and one of them will silently change your API output if you migrate blindly.

The setup: Go 1.27.0, linux/amd64, a test program that runs each input through both encoding/json (v1) and encoding/json/v2 (v2) and prints both results. No mocks, no stubs — here is the whole experiment and its real output.

The 7 diffs, side by side

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

type S struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}
Enter fullscreen mode Exit fullscreen mode

1. Duplicate member names: v1 takes the last, v2 errors

dup := []byte(`{"name":"a","name":"b","age":3}`)
Enter fullscreen mode Exit fullscreen mode
dup: v1 err=<nil> val={Name:b Age:3} | v2 err=jsontext: duplicate object member name "name" val={Name:a Age:0}
Enter fullscreen mode Exit fullscreen mode

v1 silently keeps the last value. v2 refuses the whole object. Note the partial state: v2 kept the first name and zeroed age — the error does not roll anything back, so check the error, not the struct.

2. Invalid UTF-8: v1 replaces, v2 errors

bad := []byte("\"a\xffb\"")
Enter fullscreen mode Exit fullscreen mode
badutf8: v1 err=<nil> val="a�b" | v2 err=jsontext: invalid UTF-8 after offset 2 val=""
Enter fullscreen mode Exit fullscreen mode

v1 swaps the bad byte for U+FFFD and carries on. That is silent data corruption if the string later gets hashed, signed, or stored: the bytes you read are not the bytes you were sent. v2 points at the exact offset.

3. Nil slice: null becomes []

var sl []int
v1.Marshal(sl) // null
v2.Marshal(sl) // []
Enter fullscreen mode Exit fullscreen mode

4. Nil map: null becomes {}

var m map[string]int
v1.Marshal(m) // null
v2.Marshal(m) // {}
Enter fullscreen mode Exit fullscreen mode

These two are the friendly ones — they fix the classic complaint from clients that cannot unmarshal null into an array. But they are still wire-format changes: any consumer doing string comparison or snapshot testing against your JSON will see diffs.

5. Case-insensitive matching is gone — and it fails silently

upper := []byte(`{"NAME":"zoe","AGE":9}`)
Enter fullscreen mode Exit fullscreen mode
case: v1 err=<nil> val={Name:zoe Age:9} | v2 err=<nil> val={Name: Age:0}
Enter fullscreen mode Exit fullscreen mode

This is the one that scares me. v2 does not error here. It just ignores NAME and AGE and hands you a zero struct. If an upstream service sends ID where you expect id, v1 filled the field and v2 quietly drops it. The blog calls case-insensitive matching "a potential security vulnerability" — fair — but the migration failure mode is silent data loss, not a loud error. Grep your producers for uppercase keys before you switch.

6. omitempty no longer omits 0

type O struct {
    A []int `json:"a,omitempty"`
    B string `json:"b,omitempty"`
    C int    `json:"c,omitempty"`
}
o := O{A: []int{}, B: "", C: 0}
Enter fullscreen mode Exit fullscreen mode
omitempty: v1={} v2={"c":0}
Enter fullscreen mode Exit fullscreen mode

v2 redefines omitempty to mean "omits if it encodes as an empty JSON value" — and the empty JSON values are exactly null, "", [], and {}. 0 is not on that list, so c now ships. This is the blind-migration trap: a struct that used to serialize to {} now serializes to {"c":0}, and every client that treats "absent" and "zero" differently will notice. false survives the same way, for the same reason.

7. time.Duration has no default representation anymore

type WithDur struct {
    Wait time.Duration `json:"wait"`
}
w := WithDur{Wait: 1500 * time.Millisecond}
Enter fullscreen mode Exit fullscreen mode
duration: v1 out={"wait":1500000000} err=<nil> | v2 out={"wait" err=json: cannot marshal from Go time.Duration within "/wait": no default representation
Enter fullscreen mode Exit fullscreen mode

v1 happily emitted 1500000000 — nanoseconds, with nothing in the JSON saying so. A consumer reading that as milliseconds would wait 17 days instead of 1.5 seconds. v2 refuses to guess and errors instead, with an option to let the caller pick the unit. This is the right call, and it is a breaking change for anyone serializing durations today.

What about the "up to 10x" unmarshal speedup?

The blog claims v2 unmarshal is "significantly faster than v1, with benchmarks demonstrating improvements of up to 10x." I ran 200,000 unmarshal iterations of an 86-byte record on this machine:

unmarshal 200000 iters: v1=330.522083ms (1653 ns/op) v2=243.609297ms (1218 ns/op) ratio=1.36x
Enter fullscreen mode Exit fullscreen mode

1.36x, not 10x — on a small flat struct. That does not contradict the blog: "up to" is doing a lot of work, and the big wins plausibly live in nested types with custom unmarshalers, where v2's streaming UnmarshalJSONFrom avoids the parse-twice penalty the blog describes. But if you migrate for speed on simple payloads, expect ~1.4x, not an order of magnitude. Both encoders produced byte-identical output for this record, so the speedup is real, just modest at this shape.

Trailing data, for completeness, is unchanged in spirit: both versions error, v2 just names the offset (invalid character 'g' after top-level value after offset 21).

The migration checklist this leaves me with

  1. Search for duplicate keys in your producers — v2 turns them into hard errors.
  2. Search for uppercase JSON keys — v2 drops them silently.
  3. Audit every omitempty on ints and bools — 0 and false will now appear on the wire.
  4. Audit every serialized time.Duration — v2 errors unless you choose a representation.
  5. Re-run your golden-file tests — nil slices and maps change from null to []/{}.

None of this makes v2 a bad upgrade. Every change above is defensible, and three of them (duplicates, bad UTF-8, duration units) close real bug classes. But v2 is not a drop-in: two of its seven changes fail silently, and silent is worse than loud. Migrate with the checklist, not with sed.

Sources: Go blog, "A new Go API for JSON" (November 4, 2026) — https://go.dev/blog/jsonv2 . All v1/v2 outputs above are from my own run of Go 1.27.0 on this machine; the benchmark is a single-machine, single-shape measurement, not a claim about all workloads.

Top comments (0)