encoding/json/v2 under encoding/json (Go 1.27)

Authors

K19G

grok-bot

Updated

October 31, 2026

encoding/json/v2 under encoding/json (Go 1.27)

Thesis

Go 1.27 did not merely add encoding/json/v2. It also rewired the existing encoding/json package so that v1 calls run on the v2 engine with a compatibility option set (DefaultOptionsV1). Your import paths can stay on v1 forever under the Go 1 compatibility promise; your error strings can still change; and the moment you flip an import to encoding/json/v2, the defaults flip to stricter, more interoperable behaviour. This chapter is the transition guide: what changed even if you never import v2, how to opt in/out, and a side-by-side you can run on Go 1.27+.

It does not replace the stdlib survey in 985-encoding-json-csv-xml.qmd or the performance notes in 261-json-performance-patterns.qmd. Read those for tags, encoders, and pooling; stay here for the 1.27 semantics shift.

Verified with Go 1.27.2 (linux/amd64) on 2026-10-09. Primary: Go 1.27 release notes, json/v2 migration guide, encoding/json “Migrating to v2”.

Learning goals

  • Explain “v1 API, v2 engine” and when error text can change without an import change
  • Produce identical bytes from encoding/json/v2 via json.DefaultOptionsV1()
  • List the high-impact default differences (nil slice, omitempty, case, duplicates, UTF-8, time.Duration, HTML escape)
  • Opt out with GOEXPERIMENT=nojsonv2 and know it is temporary
  • Sketch an option-by-option or jsonsplit migration for production services
  • Note the adjacent Go 1.27 net/http Response.Body.Close drain behaviour that bites JSON clients

Mental model

Go ≤1.26                         Go 1.27+
────────                         ────────
encoding/json  → old engine      encoding/json  → json/v2 engine
                                      + DefaultOptionsV1 (implicit)

encoding/json/v2 (experiment)    encoding/json/v2  → strict defaults
encoding/json/jsontext           encoding/json/jsontext (tokens)
Knob Effect
Keep import "encoding/json" Behaviour preserved; error message text may differ; faster unmarshal
import jsonv2 "encoding/json/v2" New API + strict defaults
jsonv2.Marshal(v, json.DefaultOptionsV1()) v2 API, v1 semantics
Later options after DefaultOptionsV1() Override one behaviour at a time
GOEXPERIMENT=nojsonv2 at build time Restore pre-1.27 engine behind v1 (escape hatch; expected to vanish)

DefaultOptionsV1 (from encoding/json) is the full boolean bundle that defines v1 semantics — including FormatNilMapAsNull, OmitEmptyWithLegacySemantics, FormatDurationAsNano, MatchCaseSensitiveDelimiter, and friends. Later options win.

Case 0 — toolchain

go version
# go version go1.27.2 linux/amd64

If you are still on ≤1.26, read this chapter against the published 1.27 notes and upgrade before treating the listings as executable.

Case 1 — same struct, four marshal paths

// json_transition.go — v1 vs v2 vs Options
package main

import (
    jsonv1 "encoding/json"
    "encoding/json/jsontext"
    jsonv2 "encoding/json/v2"
    "fmt"
    "time"
)

type User struct {
    Name    string   `json:"name"`
    Tags    []string `json:"tags"`
    Admin   bool     `json:"admin,omitempty"`
    Retries int      `json:"retries,omitempty"`
}

func show(label string, b []byte, err error) {
    if err != nil {
        fmt.Printf("%-12s ERROR: %v\n", label, err)
        return
    }
    fmt.Printf("%-12s %s\n", label, b)
}

func main() {
    u := User{Name: "ana"} // Tags==nil, Admin==false, Retries==0

    b, err := jsonv1.Marshal(u)
    show("v1", b, err)
    b, err = jsonv2.Marshal(u)
    show("v2", b, err)
    b, err = jsonv2.Marshal(u, jsonv1.DefaultOptionsV1())
    show("v2+OptsV1", b, err)
    b, err = jsonv2.Marshal(u, jsonv1.DefaultOptionsV1(),
        jsonv2.FormatNilSliceAsNull(false))
    show("v2 mix", b, err)

    _ = jsontext.AllowInvalidUTF8 // used below
    _ = time.Second
}
v1           {"name":"ana","tags":null}
v2           {"name":"ana","tags":[],"admin":false,"retries":0}
v2+OptsV1    {"name":"ana","tags":null}
v2 mix       {"name":"ana","tags":[]}

Three lessons in four lines:

  1. Nil slice: v1 → JSON null; v2 → [].
  2. omitempty: v1 drops false / 0; v2’s omitempty drops a field only when it would encode as null, "", {}, or [] — so false and 0 reappear. Prefer omitzero on bools/numbers/pointers/interfaces (same meaning in both packages).
  3. DefaultOptionsV1 then override is the supported gradual path (v2 mix keeps v1 options except nil-slice formatting).

Case 2 — unmarshal mismatches that hide in green tests

// continue in main(), after the marshal demos
fmt.Println("== case-insensitive field names ==")
in := []byte(`{"NAME":"bob"}`)
var u1, u2 User
fmt.Printf("v1 name=%q err=%v\n", u1.Name, jsonv1.Unmarshal(in, &u1))
// reset u1 for clarity
u1 = User{}
_ = jsonv1.Unmarshal(in, &u1)
_ = jsonv2.Unmarshal(in, &u2)
fmt.Printf("v1 name=%q\n", u1.Name)
fmt.Printf("v2 name=%q\n", u2.Name)

fmt.Println("== duplicate names ==")
dup := []byte(`{"name":"alice","name":"mallory"}`)
var d1, d2 User
err1 := jsonv1.Unmarshal(dup, &d1)
err2 := jsonv2.Unmarshal(dup, &d2)
fmt.Printf("v1 name=%q err=%v\n", d1.Name, err1)
fmt.Printf("v2 name=%q err=%v\n", d2.Name, err2)

fmt.Println("== invalid UTF-8 ==")
bad := []byte{'{', '"', 'n', 'a', 'm', 'e', '"', ':', '"', 'c', 'a', 'f', 0xff, 'e', '"', '}'}
var x1, x2 User
fmt.Printf("v1 err=%v name=%q\n", jsonv1.Unmarshal(bad, &x1), x1.Name)
fmt.Printf("v2 err=%v\n", jsonv2.Unmarshal(bad, &x2))
fmt.Printf("v2+allow err=%v\n",
    jsonv2.Unmarshal(bad, &x2, jsontext.AllowInvalidUTF8(true)))

Actual Go 1.27.2 output:

== case-insensitive field names ==
v1 name="bob"
v2 name=""
== duplicate names ==
v1 name="mallory" err=<nil>
v2 name="alice" err=jsontext: duplicate object member name "name"
== invalid UTF-8 ==
v1 err=<nil> name="caf�e"
v2 err=jsontext: invalid UTF-8 within "/name" after offset 12
v2+allow err=<nil>
Behaviour v1 default v2 default Compatibility lever
Field name match Case-insensitive Case-sensitive jsonv2.MatchCaseInsensitiveNames(true) or tag case:ignore
Duplicate object names Last wins Error jsontext.AllowDuplicateNames(true)
Invalid UTF-8 in strings Replace with U+FFFD Error jsontext.AllowInvalidUTF8(true)

Trap: after a failed Unmarshal, the destination may be partially written (duplicate-name case kept "alice"). Discard it.

Case 3 — time.Duration and HTML escaping

type Job struct {
    Name    string        `json:"name"`
    Timeout time.Duration `json:"timeout"`
}
j := Job{Name: "backup", Timeout: 90 * time.Second}
show("v1 dur", jsonv1.Marshal(j))
show("v2 dur", jsonv2.Marshal(j))
show("v2+nano", jsonv2.Marshal(j, jsonv1.FormatDurationAsNano(true)))

type Q struct {
    Q string `json:"q"`
}
q := Q{Q: "<a&b>"}
show("v1 html", jsonv1.Marshal(q))
show("v2 html", jsonv2.Marshal(q))
show("v2+esc", jsonv2.Marshal(q, jsontext.EscapeForHTML(true)))
v1 dur       {"name":"backup","timeout":90000000000}
v2 dur       ERROR: json: unable to marshal from Go time.Duration within "/timeout": no default representation
v2+nano      {"name":"backup","timeout":90000000000}
v1 html      {"q":"\u003ca\u0026b\u003e"}
v2 html      {"q":"<a&b>"}
v2+esc       {"q":"\u003ca\u0026b\u003e"}

v2 refuses to invent a Duration encoding; pass FormatDurationAsNano(true) (defined on the v1 package’s Options) or change the field type / custom marshaler. HTML escaping is off by default in v2 — turn it on if you embed JSON in HTML.

Portable tags (same bytes under both packages)

type Portable struct {
    Name    string   `json:"name,case:ignore"`
    Tags    []string `json:"tags,omitempty"`
    Admin   bool     `json:"admin,omitzero"`
    Retries int      `json:"retries,omitzero"`
}
  • omitempty on the slice sidesteps null vs [] by omitting both empty forms.
  • omitzero replaces legacy omitempty on bool/int.
  • case:ignore keeps accepting "NAME" under v2.

Round-trip test idea: jsonv1.Marshal and jsonv2.Marshal must return identical bytes for your golden structs.

Migration playbook

  1. Upgrade to 1.27, keep v1 imports. Fix tests that assert on err.Error() strings; assert types (*json.SyntaxError, …) instead.
  2. Optional escape hatch: GOEXPERIMENT=nojsonv2 while you file upstream issues — not a long-term plan.
  3. API migration (safe): switch call sites to encoding/json/v2 with json.DefaultOptionsV1().
  4. Behaviour migration: flip one option at a time (FormatNilSliceAsNull(false), …) behind tests.
  5. Production servers: consider github.com/go-json-experiment/jsonsplit (CallBothButReturnV1 + difference reporting) before OnlyCallV2.
  6. New code: prefer encoding/json/v2 defaults; use RejectUnknownMembers(true) on untrusted request bodies.

Experiment-era blog posts (GOEXPERIMENT=jsonv2 on 1.25/1.26) may still show removed tag options (format:, unknown:) or the old inline name — 1.27 renamed that to embed and dropped several experiment-only knobs. Trust go doc on 1.27+, not the experiment posts.

Adjacent Go 1.27 note — Response.Body.Close drains

Unrelated to the json packages, but it shows up in the same upgrade window for HTTP+JSON clients: Go 1.27’s net/http HTTP/1 Response.Body now drains unread content on Close (up to a conservative limit) to improve connection reuse. For most programs this is a silent win. If you were accidentally leaving huge unread bodies and relying on MaxIdleConns quirks, profiles may change — set Transport.DisableKeepAlives only if you truly do not want reuse, and prefer reading/decoding the body fully (json.NewDecoder(resp.Body).Decode + Close) as you already should.

The trap

  • “We did not import v2, so nothing changed.” The engine underneath v1 did. Golden error strings are the usual breakage.
  • Trivial import swap without reading defaults. Nil slices, omitempty, and Duration are the first production pages.
  • Keeping partial structs after failed v2 Unmarshal.
  • Relying on GOEXPERIMENT=nojsonv2 past the migration. Release notes say it is expected to go away.
  • Copying experiment-era tags (format:, inline instead of embed).

The boring rule

Stay on encoding/json until you need v2’s API or defaults. When you move, start with DefaultOptionsV1(), peel options off under tests, and pin wire contracts with omitzero / case:ignore / explicit nil-slice policy. Measure unmarshal wins; do not assume marshal is free on every workload.

Try this

go install golang.org/dl/go1.27.2@latest   # or use your distro's 1.27
# put the listings in a module with `go 1.27` and `go run .`
GOEXPERIMENT=nojsonv2 go test ./...       # compare only while diagnosing

Sources