⚡ TL;DR
Migrate a Go REST API to encoding/json/v2 in 7 steps: struct tags, omitempty, unknown fields, error handling, and jsontext streaming, plus a v1 vs v2 comparison.
Go JSON v2: A Practical Migration Guide to encoding/json/v2
Go’s JSON story just got its first real rewrite in over a decade. In November 2026 the Go team published the official announcement for encoding/json/v2, the redesign that first shipped as an experiment in Go 1.25 and graduates to a supported API in Go 1.27 (The Go Blog, Nov 2026). I’ve been reading through the proposal and the official migration guide, and the good news is this: for a typical REST API, the migration path is shorter than you’d expect. This post walks through it piece by piece, starting from a handler you’ve probably written a hundred times.
TL;DR
json.Marshal(v)andjson.Unmarshal(b, v)still compile after you swap the import toencoding/json/v2— the signature shape didn’t change, so most call sites migrate for free (go.dev, Nov 2026).- Unmarshal performance is the headline: the Go team’s benchmarks show v2 decoding up to 10x faster than v1, while marshal stays roughly at parity (The Go Blog, Nov 2026).
- v2 tightens defaults: object member names match case-sensitively, duplicate names error out, and
omitemptynow omits any field that encodes as an empty JSON value (null,"",[],{}).- A compatibility escape hatch exists — pass
jsonv1.DefaultOptionsV1()as an option and v2 behaves exactly like v1, so you can flip behaviors off one at a time.
Contents
- What Is encoding/json/v2?
- What Actually Changes?
- How Does a Real Handler Migrate?
- What Happens to omitempty and Unknown Fields?
- How Do Errors Change?
- Does Streaming Get Easier with jsontext?
- When Should You Switch?
- FAQ
What Is encoding/json/v2?
encoding/json/v2 is a second Go package for JSON that lives next to v1, not instead of it. It ships with a second package, encoding/json/jsontext, that handles pure syntax: reading and writing JSON tokens and values without any Go struct semantics attached (The Go Blog, Nov 2026). That split matters. In v1, marshaling and encoding were one operation; in v2, “marshal” means “turn a Go value into JSON semantics” and “encode” means “write it to a stream,” and you can hook into either layer.
The experiment first landed in Go 1.25 behind the GOEXPERIMENT=jsonv2 flag (The Go Blog, Sep 2025). As of the November 2026 announcement, the API is set to be fully supported in Go 1.27, and both packages fall under the Go 1 compatibility promise. The design goal, per the proposal, was to fix fifteen years of accumulated edge cases without breaking the ecosystem, which is why v1 stays exactly where it is and keeps working.
If you care about the fundamentals behind this kind of API redesign (separating concerns, keeping interfaces small), the same thinking shows up across languages; I’ve written about it in the art of programming patterns in Dart.
What Actually Changes?
Here’s the honest summary: the happy path barely changes, and the edge cases change a lot. The table below covers the differences the official migration guide calls out.
| Behavior | encoding/json (v1) | encoding/json/v2 |
|---|---|---|
| Field name matching | Case-insensitive fallback | Case-sensitive by default; MatchCaseInsensitiveNames option to restore v1 |
| Unknown fields | Ignored unless Decoder.DisallowUnknownFields is set | Ignored by default; RejectUnknownMembers option to error |
| Duplicate object names | Silently allowed | Error by default; jsontext.AllowDuplicateName option to allow |
| Invalid UTF-8 | Silently replaced with U+FFFD | Reports an error |
| Nil slice / nil map | Encoded as null | Encoded as [] / {}; json.FormatNilSliceAsNull(false) restores v1 |
omitempty | Omits zero values, empty strings, and empty slices/maps | Omits fields that encode as empty JSON values: null, "", [], {} |
time.Duration | Encoded as a number (it’s an int64) | Errors — no default representation; you supply one |
| Options | One Encoder/Marshaler struct | Plain function options passed per call |
| Custom marshaler | MarshalJSON() ([]byte, error) | MarshalJSONTo(*jsontext.Encoder) error (streaming); v1 interfaces still honored |
Two of these will bite real codebases. Case-sensitive matching means a client sending {"userId": 1} against a struct field tagged json:"userid" now gets an error where v1 silently matched. And the omitempty redefinition changes what disappears from your responses. I’ll cover both below.
How Does a Real Handler Migrate?
Take a boring handler: it decodes a request, saves an order, and writes a response. Here’s the v1 version.
// BEFORE: encoding/json (v1)
package orders
import (
"encoding/json"
"net/http"
"time"
)
type CreateOrderRequest struct {
UserID string `json:"userId"`
Items []string `json:"items"`
Coupon string `json:"coupon,omitempty"`
CreatedAt time.Time `json:"-"`
}
func Create(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
resp := map[string]any{"id": "ord_123", "items": req.Items}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "encode failed", http.StatusInternalServerError)
}
}Step 1: swap the import. Change encoding/json to encoding/json/v2. json.Marshal and json.Unmarshal keep their shapes, so the decode of a []byte body would already work. The Decoder/Encoder types, though, moved to jsontext and gained option arguments.
Step 2: replace the decoder with json.UnmarshalRead or a jsontext.Decoder. Streaming from an io.Reader becomes a one-liner with options attached:
// AFTER: encoding/json/v2, step by step
import (
"encoding/json/v2"
"encoding/json/jsontext"
"net/http"
"time"
)
type CreateOrderRequest struct {
UserID string `json:"userId"`
Items []string `json:"items"`
Coupon string `json:"coupon,omitempty"`
CreatedAt time.Time `json:"-"`
}
func Create(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.UnmarshalRead(
r.Body,
&req,
json.RejectUnknownMembers(true), // was dec.DisallowUnknownFields()
); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
enc := jsontext.NewEncoder(w)
if err := enc.WriteValue([]byte(`{"id":"ord_123"}`)); err != nil {
http.Error(w, "encode failed", http.StatusInternalServerError)
}
}Most real handlers would marshal the response struct instead of hand-writing JSON; json.MarshalWrite(w, resp) handles that, and jsontext.WithIndent(" ") is the option that replaces Encoder.SetIndent. That’s the whole migration for this handler: two imports, two call sites, and one option renamed. If your structs follow clean interface boundaries rather than passing JSON blobs around, you’ll feel this even less. It’s the same principle I covered in SOLID principles with Flutter examples.
What Happens to omitempty and Unknown Fields?
These two deserve their own section because they’re where output actually changes.
omitempty, redefined. In v1, omitempty drops a field when it holds a Go zero value: 0, "", false, a nil pointer, or a slice/map with length zero. That’s Go-centric, and it’s surprising: you can’t omit a slice that’s non-nil but empty, and you can’t omit a custom “empty” value. v2 redefines it: a field is omitted when it would encode as an empty JSON value, meaning null, "", [], or {} (The Go Blog, Nov 2026). Practically: nil pointers still drop, empty slices now drop too, and a struct-typed field whose encoding is {} gets dropped as well. The safer mental model is “v2 omits anything that serializes to an empty JSON container.” If you need finer control, the omitzero tag (added to v1 in Go 1.24) gives you explicit, Go-zero-value-based control in both worlds.
Unknown fields. v1’s DisallowUnknownFields had a nasty habit of silently stopping to work the moment a field’s type implemented UnmarshalJSON, because a custom unmarshaler takes over and the decoder’s setting never reaches it (The Go Blog, Nov 2026). v2’s RejectUnknownMembers(true) is a per-call option, and it composes correctly through nested types. Strict APIs, the kind that should fail fast on typos like user_id vs userId, get a real win here.
How Do Errors Change?
v1’s errors were strings with context smeared on. v2 gives you typed errors you can program against. The packages expose distinct error types for syntax problems (malformed JSON found by jsontext) and semantic problems (JSON that’s valid but doesn’t fit your Go types), and the migration guide notes v2 additionally reports errors for structurally invalid situations v1 ignored: duplicate names, invalid UTF-8, a time.Duration with no representation, or a "string" tag on a type where it makes no sense (go.dev, Nov 2026).
That strictness cuts both ways. Your decode paths get better: instead of a 400 with “json: cannot unmarshal,” you can return which byte offset and which member name failed. But your test suite will also start seeing errors on inputs v1 accepted quietly. Budget an afternoon for the error-behavior diff, especially around duplicate names from clients that hand-assemble JSON.
Does Streaming Get Easier with jsontext?
Yes, and this is the part I’d actually migrate for. jsontext is a token-level API: a jsontext.Encoder has WriteToken and WriteValue, and a jsontext.Decoder has ReadToken and ReadValue. You can walk a 10 GB JSON file one token at a time, or peek at just the first field of each object in an NDJSON stream, without allocating structs at all.
For custom types, v2 adds streaming interfaces: implement MarshalJSONTo(*jsontext.Encoder) error or UnmarshalJSONFrom(*jsontext.Decoder) error and your type never materializes an intermediate []byte the way v1’s MarshalJSON forces. The Go team cites the Kubernetes kube-openapi project getting orders-of-magnitude improvements from exactly this switch (The Go Blog, Nov 2026). Types implementing the old v1 interfaces keep working in v2, so this upgrade is optional and per-type.
If you just need to eyeball an API response while debugging, my JSON formatter tool does the same job in the browser.
When Should You Switch?
- You’re on Go 1.25 or 1.26: you can try v2 today behind
GOEXPERIMENT=jsonv2, but it’s still flagged experimental, so keep it off the critical path. Compile-and-run experiments only. - You’re on Go 1.27 (or will be): v2 is supported, but it’s still a separate import, and nothing forces you off v1. Start with the low-risk move: keep v1, and add v2 types where you want
jsontextstreaming. - Read-heavy services: the up-to-10x unmarshal improvement is the strongest reason to migrate (The Go Blog, Nov 2026). Independent benchmarks are more mixed: Daniel Lemire’s analysis found some workloads where v2 is roughly 1.5x slower (lemire.me, Aug 2026), so benchmark your own payloads before committing.
- APIs with strict request validation:
RejectUnknownMemberscomposing correctly through nested types is a genuine upgrade overDisallowUnknownFields. Worth migrating for on its own. - Everyone else: wait one minor release. The official migration guide’s option-by-option path (
DefaultOptionsV1()first, then flip v1 behaviors off one at a time, with thejsonsplittool logging differences in production) is designed for exactly that pace.
My verdict: v2 is the future of JSON in Go and the migration cost for typical handlers is low, but the case-sensitive matching and omitempty changes mean you should migrate behind a full test suite, not in a drive-by commit.
FAQ
Is encoding/json/v2 available in Go 1.25?
Yes, but only as an experiment. Set GOEXPERIMENT=jsonv2 when building. It becomes a fully supported API in Go 1.27.
Do I have to rewrite all my struct tags?
No. Basic tags like json:"userId" work unchanged in v2. The omitempty semantics shift, and v2 adds new tags such as embed, but most existing tags carry over as-is.
Is json v2 faster than json v1?
Decoding is significantly faster; the Go team reports up to 10x in their benchmarks. Marshaling is roughly at parity, sometimes slightly faster, sometimes slower. Benchmark your own payloads.
Will encoding/json (v1) be removed?
No. v1 remains in the standard library and is now implemented on top of v2 internally. Existing code keeps compiling and behaving the same under the Go 1 compatibility promise.
What’s the safest migration strategy?
Import v2, pass jsonv1.DefaultOptionsV1() to match old behavior, then disable v1 compatibility options one at a time. The official migration guide documents this path, and the jsonsplit tool can log v1-vs-v2 differences in production.

Written by Mihir Pipermitwala
Senior Full Stack Consultant & Mobile Engineering Specialist building user-focused applications in healthcare with Flutter, Dart and React Native. I write about what I actually build and test — every tutorial here comes from real project work.
More about Mihir →