encoding/json/v2: What Actually Changed, and What You Need to Do About It
encoding/json is one of the most-used packages in the entire Go ecosystem, and it hasn’t fundamentally changed since it shipped with Go 1.0 in 2012. That’s not a knock — it’s a testament to how well the original design held up. But fourteen years is a long time, and the cracks were well known: reflection-heavy performance, a handful of decisions (silently swallowing invalid UTF-8, accepting duplicate object keys, omitempty’s genuinely confusing semantics) that Go’s compatibility promise made impossible to fix in place. You can’t change the behavior of a function a million codebases depend on without breaking some meaningful fraction of them.
So the Go team did something it almost never does: it shipped a parallel package instead of patching the old one. encoding/json/v2 went from proposal to GOEXPERIMENT=jsonv2 in Go 1.25, sat behind that flag through Go 1.26, and as of Go 1.27 — released this month — it’s no longer experimental. The classic encoding/json package is now backed by the v2 engine internally. If you’ve done nothing at all, your code just got faster.
Here’s what’s actually in the box, what’s different, and what — if anything — you should change.
The one-paragraph version
If you only read one section: you don’t have to do anything. encoding/json still works exactly as it did, imports the same way, and behaves the same way by default — it’s just running on faster internals now, particularly for Unmarshal. The new encoding/json/v2 package is a separate, opt-in import with stricter defaults and a more capable API, useful if you want better error messages, streaming-friendly marshalers, or RFC 8259-strict behavior. Nobody is forcing a migration. Read on for where it’s actually worth the opt-in.
Why not just fix the old package?
This is worth understanding because it explains almost every design decision in v2. Go’s compatibility promise means encoding/json’s documented behavior from 2012 is still a contract today. A few examples of things that were arguably bugs, or at least surprising, but couldn’t be changed without breaking someone’s production code:
- Invalid UTF-8 in a JSON string gets silently replaced with the Unicode replacement character instead of erroring.
- Duplicate object member names (
{"id": 1, "id": 2}) are accepted, with the last one winning — a real source of confusion and, in some contexts, a security smell (different parsers in a pipeline can disagree about which value “wins”). - Struct field matching is case-insensitive by default, so
NameandnameandNAMEall bind to the same field unless you’re careful — convenient until it isn’t. nilslices and maps marshal to JSONnullinstead of[]or{}, which is a constant source of “why did the frontend getnullinstead of an empty array” bug reports.omitemptydoesn’t mean what most people think it means — it omits based on Go’s notion of a zero value for basic types, but the actual rule set (covered below) has enough edge cases that it’s a recurring source of Stack Overflow questions.
None of these are fixable in encoding/json without an opt-in flag or a new import path, because fixing them silently changes wire output for existing programs. Hence: a new package, with new defaults, that you have to explicitly choose to use.
Two packages, not one
The v2 work actually shipped as two packages that build on each other:
encoding/json/jsontext is the low-level layer — pure syntax, no reflection, no struct tags. It gives you Encoder and Decoder types that operate on Token and Value, and its whole job is guaranteeing you produce and consume syntactically valid JSON. Think of it as the tokenizer/state-machine layer.
encoding/json/v2 sits on top of jsontext and does the part everyone actually cares about: mapping Go values to and from JSON via reflection, struct tags, and marshaler interfaces. This is the package you’ll actually import for day-to-day work; jsontext is what you reach for if you’re doing something unusual — streaming transformation, building a JSON-processing tool, or writing a custom marshaler that needs fine control over the token stream.
This split is also what makes the new MarshalerTo/UnmarshalerFrom interfaces (more below) possible — they operate directly on a jsontext.Encoder/Decoder instead of allocating an intermediate []byte, which is where a lot of the real performance win comes from for types that implement them.
What actually behaves differently
This is the table that matters if you’re deciding whether to opt into encoding/json/v2 directly, because these are defaults, not bugs, and they will change your program’s behavior the moment you switch import paths.
| Behavior | v1 (encoding/json) |
v2 (encoding/json/v2) |
|---|---|---|
| Invalid UTF-8 in strings | Silently replaced with U+FFFD | Rejected with an error (opt out via jsontext.AllowInvalidUTF8) |
| Duplicate object member names | Accepted; last value wins | Rejected with an error (opt out via jsontext.AllowDuplicateNames) |
| Struct field name matching | Case-insensitive by default | Case-sensitive by default (opt in to loose matching via MatchCaseInsensitiveNames or the case:ignore tag) |
nil slice/map marshaling |
Encodes as null |
Encodes as []/{} (opt in to null via FormatNilSliceAsNull/FormatNilMapAsNull) |
omitempty |
Omits “empty” values (zero numbers, empty strings, nil/empty slices and maps, false bools) | Same tag, same meaning — unchanged for compatibility |
omitzero |
Didn’t exist in v1 until it was backported | Omits if the value is the type’s zero value, or if it implements an IsZero() bool method — this is the tag you actually want for structs like time.Time |
| Unknown JSON fields on unmarshal | Silently ignored | Silently ignored by default; opt into rejection with RejectUnknownMembers |
The omitempty vs omitzero distinction is the one most likely to bite people migrating a struct tag over out of habit. omitempty was never really “omit if zero” — it’s “omit if it encodes to something JSON considers empty,” which for a zero-value time.Time is not empty (it encodes to a real, non-empty timestamp string), so omitempty famously does nothing useful on time fields. omitzero, which is the tag actually designed for this, checks Go-level zero-ness (or a type’s own IsZero()), so it correctly omits a zero-value time.Time. If you’ve ever written a custom MarshalJSON just to work around omitempty not omitting a zero timestamp, omitzero is the fix, and — good news — it was backported and works in encoding/json v1 as of Go 1.24, before any of the rest of this landed.
A quick look at the case-sensitivity change, because it’s the one most likely to actually break something silently:
type Config struct {
APIKey string `json:"apiKey"`
}
// v1 (encoding/json): this unmarshals fine, "APIKEY" matches "apiKey"
// v2 (encoding/json/v2): case-sensitive matching means "APIKEY" doesn't
// match "apiKey" — APIKey is left unset, or Unmarshal returns an error
// instead if you've turned on RejectUnknownMembers
data := []byte(`{"APIKEY": "abc123"}`)
If you’re consuming JSON from a system you don’t control and it’s ever been loose about casing, that’s worth testing before you flip the import path, not after.
In practice: reading and writing JSON over HTTP
The table above is useful in the abstract, but the two places it actually shows up day to day are decoding a request body and declaring the structs you send to some other service. Both are a little different in v2 — not dramatically, but enough to matter.
Reading a request body. The v1 idiom almost everyone uses is:
var v MyType
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
// handle error
}
Decoder.Decode reads only as much as it needs to parse one JSON value and leaves anything after it in the stream alone — deliberate, since it’s what lets you call Decode repeatedly on the same reader for newline-delimited JSON or a long-lived stream. The v2 equivalent is:
var v MyType
if err := json.UnmarshalRead(r.Body, &v); err != nil {
// handle error
}
But UnmarshalRead isn’t a rename of the same behavior — it consumes the entire reader until io.EOF and errors if anything besides trailing whitespace is left over. A body with a second JSON value or stray bytes tacked onto the end, which v1 would silently ignore, v2 rejects outright. That’s arguably the more correct default for a single request body, but it also means UnmarshalRead is the wrong tool if you’re intentionally streaming multiple values off one connection — for that, you still want the token-level jsontext.Decoder feeding json.UnmarshalDecode in a loop, which is closer to what Decoder.Decode gave you for free in v1.
Two other request-handling habits carry over with new names: Decoder.DisallowUnknownFields() becomes the RejectUnknownMembers option shown above (works with both Unmarshal and UnmarshalRead), and the case-sensitivity change from the table means any client that’s ever sent inconsistently-cased field names will start failing to bind unless you add MatchCaseInsensitiveNames(true).
Declaring structs for outbound requests. Existing json:"name,omitempty" tags work completely unchanged in v2 — this isn’t a breaking change to tag syntax. The one addition worth adopting deliberately is omitzero, covered above for exactly the case that trips people up most: a zero-value time.Time field that omitempty silently refuses to omit.
type CreateOrderRequest struct {
CustomerID string `json:"customer_id"`
Note string `json:"note,omitempty"`
ShipBy time.Time `json:"ship_by,omitzero"` // correctly omitted when unset
}
The nil-slice-and-map default flip from the table is also worth remembering here specifically: if the API you’re posting to distinguishes null (“don’t touch this field”) from [] (“clear this list”), the default v2 behavior of marshaling nil as []/{} can silently change what you’re telling that API to do. Force the old null behavior with FormatNilSliceAsNull/FormatNilMapAsNull if the contract requires it.
Embedding is otherwise unchanged — anonymous embedded structs still promote their fields automatically, same as v1 — but there’s a new ,embed tag for named fields, handy for a catch-all that absorbs whatever extra properties an API’s schema allows without hand-maintaining every one of them:
type WebhookPayload struct {
Event string `json:"event"`
Extra map[string]any `json:",embed"` // extra key/value pairs flatten into the top level
}
Unexported fields are still ignored, and pointers are still the idiomatic way to distinguish “not sent” from “sent as zero value” — v2 didn’t add a native required/optional marker, so that pattern carries over unchanged.
The new API surface
Beyond the default-behavior changes, v2’s actual function and interface set is meaningfully more capable than v1’s, and this is the part that’s genuinely useful independent of the stricter defaults.
Options are first-class arguments, not something bolted onto an Encoder:
b, err := json.Marshal(v,
json.Deterministic(true),
json.OmitZeroStructFields(true),
)
In v1, most of these knobs only existed as fields on an Encoder/Decoder struct, which meant one-off Marshal calls couldn’t use them at all. Now every option composes and applies uniformly across Marshal, MarshalWrite (writes directly to an io.Writer, no intermediate buffer), and MarshalEncode (writes through a jsontext.Encoder you control).
Streaming-friendly marshalers. The new MarshalerTo/UnmarshalerFrom interfaces operate on *jsontext.Encoder/*jsontext.Decoder directly instead of round-tripping through a []byte:
func (t Timestamp) MarshalJSONTo(enc *jsontext.Encoder) error {
return enc.WriteToken(jsontext.String(t.Format(time.RFC3339)))
}
If you have types with custom marshaling logic sitting in a hot path — a request/response type that gets serialized thousands of times a second — this is where the real allocation savings show up, because you’re never materializing an intermediate byte slice just to hand it back to the outer encoder.
Caller-side marshalers, without touching the type. WithMarshalers/WithUnmarshalers let you override how a specific type serializes at the call site, instead of needing to control the type’s own MarshalJSON method — useful for types you don’t own, or for varying serialization behavior by call site (an internal API vs. a public-facing one) without maintaining two struct definitions:
opts := json.WithMarshalers(json.MarshalFunc(
func(t time.Time) ([]byte, error) {
return []byte(`"` + t.Format(time.RFC1123) + `"`), nil
},
))
json.Marshal(event, opts)
Real structured errors. v1’s JSON errors are a grab-bag of loosely-typed error values. v2 gives you SemanticError, which carries a byte offset, a JSON Pointer (RFC 6901) to the exact location, the JSON kind involved, and the Go type it was trying to populate. If you’ve ever had to debug a json: cannot unmarshal string into Go struct field error on a large nested payload with no idea which of forty fields it’s actually about, this alone is a good reason to reach for v2 on ingest-heavy services.
Performance: what’s real
Marshal performance is roughly at parity with v1 — the new engine isn’t meaningfully faster at writing JSON out. Unmarshal is where the real gains are, with the Go team’s own benchmarks showing improvements up to 10x in favorable cases, and types using the streaming MarshalerTo/UnmarshalerFrom interfaces seeing the largest gains since they skip intermediate allocations entirely. Treat “up to 10x” the way you’d treat any vendor benchmark — it’s a best case, not a guarantee — but the underlying architectural reason for the win (less reflection overhead, no intermediate buffer round-trips for streaming-aware types) is real and not marketing.
The part that matters for the average service: because encoding/json v1 is now backed by the v2 engine, you get a meaningful chunk of this Unmarshal speedup for free, on existing code, with zero changes. If unmarshaling JSON shows up anywhere near the top of your service’s profile, it’s worth re-running that profile after upgrading to Go 1.27 before doing anything else.
Should you actually migrate?
For most people, the honest answer is: don’t, not directly, not yet — but do upgrade the toolchain. Here’s a more concrete breakdown:
Just upgrade to Go 1.27 and change nothing. This covers the majority of services. You get the Unmarshal performance improvement automatically, your JSON output and error behavior stay identical, and there’s no migration risk at all. This is the right call for anything where JSON handling isn’t a bottleneck and isn’t a pain point.
Opt into encoding/json/v2 directly if: you’re building something JSON-processing-heavy where the streaming marshaler interfaces would meaningfully cut allocations (a proxy, a high-throughput API gateway, a log pipeline); you’re regularly debugging opaque unmarshal errors on complex payloads and want SemanticError’s structured output; you specifically want the stricter RFC 8259 behavior (rejecting invalid UTF-8 and duplicate keys) because you’re parsing JSON from an untrusted or semi-trusted source and the looser v1 behavior is actually a liability; or you’re writing a new service from scratch and would rather start on the stricter, more correct defaults than carry v1’s quirks forward.
Don’t migrate an existing large codebase wholesale just because it’s newer. The behavioral differences covered above are real breaking changes if any part of your system depends on the old behavior — and it’s easy to not know that it does until something’s nil slice starts arriving as [] instead of null on the wire and a frontend that checked === null breaks. If you do want to move a specific package or service over, both packages document options for making v2 behave like v1 (FormatNilSliceAsNull, MatchCaseInsensitiveNames, and so on) specifically to support an incremental migration rather than a flag-day rewrite.
If you need to disable the new engine entirely — say, you hit a behavioral edge case in the v2-backed v1 implementation and need the old code path back while you investigate — GOEXPERIMENT=nojsonv2 restores the original implementation. Worth knowing that this escape hatch is explicitly expected to be removed in a future release, so it’s a temporary safety valve, not a long-term pin.
Bottom line
encoding/json/v2 is a good example of how Go tends to ship big changes: not as a breaking release, but as an opt-in package that the old one quietly grows to depend on. Most people’s action item here is “upgrade the toolchain and get a free Unmarshal speedup.” The more interesting action item — better error messages, streaming marshalers, stricter RFC compliance — is there for the services that actually need it, sitting behind an explicit import path so nobody gets it by accident. That’s a fairly disciplined way to fix fourteen years of accumulated warts in a language that’s promised not to break your code since 2012.
Sources:
- A new experimental Go API for JSON — go.dev blog
- Go 1.27 Release Notes
- encoding/json/v2 package documentation — pkg.go.dev
- encoding/json/v2: new API for encoding/json — golang/go#71497
- encoding/json/v2: add new JSON API behind a GOEXPERIMENT=jsonv2 guard — golang/go#71845
- encoding/json/v2 design discussion — golang/go#63397