encoding/json/v2 under encoding/json (Go 1.27)
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/v2viajson.DefaultOptionsV1()
- List the high-impact default differences (nil slice,
omitempty, case, duplicates, UTF-8,time.Duration, HTML escape)
- Opt out with
GOEXPERIMENT=nojsonv2and know it is temporary
- Sketch an option-by-option or
jsonsplitmigration for production services
- Note the adjacent Go 1.27
net/httpResponse.Body.Closedrain 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/amd64If 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:
- Nil slice: v1 → JSON
null; v2 →[].
omitempty: v1 dropsfalse/0; v2’somitemptydrops a field only when it would encode asnull,"",{}, or[]— sofalseand0reappear. Preferomitzeroon bools/numbers/pointers/interfaces (same meaning in both packages).
DefaultOptionsV1then override is the supported gradual path (v2 mixkeeps 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.
Migration playbook
- Upgrade to 1.27, keep v1 imports. Fix tests that assert on
err.Error()strings; assert types (*json.SyntaxError, …) instead.
- Optional escape hatch:
GOEXPERIMENT=nojsonv2while you file upstream issues — not a long-term plan.
- API migration (safe): switch call sites to
encoding/json/v2withjson.DefaultOptionsV1().
- Behaviour migration: flip one option at a time (
FormatNilSliceAsNull(false), …) behind tests.
- Production servers: consider
github.com/go-json-experiment/jsonsplit(CallBothButReturnV1+ difference reporting) beforeOnlyCallV2.
- New code: prefer
encoding/json/v2defaults; useRejectUnknownMembers(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, andDurationare the first production pages.
- Keeping partial structs after failed v2
Unmarshal.
- Relying on
GOEXPERIMENT=nojsonv2past the migration. Release notes say it is expected to go away.
- Copying experiment-era tags (
format:,inlineinstead ofembed).
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 diagnosingSources
- Go 1.27 release notes — json/v2: https://go.dev/doc/go1.27
- Official migration guide: https://go.dev/doc/jsonv2-migration
encoding/jsonpackage docs (Migrating to v2 /DefaultOptionsV1): https://pkg.go.dev/encoding/json
encoding/json/v2: https://pkg.go.dev/encoding/json/v2
- Background article (Oct 2026; upgrade against go.dev if drift): https://importstatic.com/go/go-json-v2-migration