TL;DR
Go’s new encoding/json/v2 is the first real rewrite of the standard library’s JSON handling in fifteen years. It’s a reported 2–10x faster at unmarshaling, cleans up a pile of long-standing footguns, and moves configuration from magic struct tags into explicit function options. The catch: it changes several v1 defaults you may be quietly relying on — nil slices now encode as [] instead of null, duplicate object keys are rejected, and field matching is case-sensitive. As of Go 1.26 it lives behind GOEXPERIMENT=jsonv2, with the plan to make it the default in Go 1.27. This is a practical walkthrough of turning it on, the changes that will bite you, and how to migrate without breaking your API contracts.
Why a second JSON package at all
encoding/json shipped with Go 1.0 in 2012 and barely changed since. That stability paid off for a long time, then slowly turned into a tax. The v1 package carried a set of decisions that looked reasonable in 2011 and aged into daily annoyances: it silently accepts duplicate object keys, replaces invalid UTF-8 with the Unicode replacement character instead of erroring, and marshals a nil slice as null rather than an empty array. Each of those has bitten real production code, including mine.
The one that cost me an afternoon was the nil-slice-as-null behavior. I had a Go service returning a list of items to a TypeScript frontend. When the list was empty, the backend sometimes sent "items": null and sometimes "items": [], depending on whether a slice had been initialized or left nil somewhere upstream. The frontend called .map() on the response and blew up only on the null path. It was an intermittent, hard-to-reproduce crash that came down to a Go zero-value quirk. I fixed it back then with a make([]Item, 0) and a mental note. v2 fixes it at the source.
That is the spirit of the whole package. The official experimental announcement frames it as fixing “15 years of accumulated sharp edges” while keeping the API recognizable. If you’ve written Go, json.Marshal and json.Unmarshal still work the way you expect. They just have saner defaults and a much faster engine underneath.
If you’re tracking where the language is heading more broadly, this fits the same evolutionary arc as Go 1.26’s other standard-library changes: fewer surprises, more explicit control.
What’s actually in the box
json/v2 is not one package but two, split along a clean line:
encoding/json/jsontexthandles syntax: the raw grammar of JSON. It gives you a streamingEncoderandDecoder, plusValueandTokentypes, and it doesn’t touch reflection at all. Think of it as the tokenizer layer.encoding/json/v2handles semantics: mapping Go values to and from JSON. This is whereMarshal,Unmarshal, and the struct-tag machinery live.
That separation pays off in performance-sensitive code, because you can drop down to jsontext and stream tokens without paying for reflection when you don’t need it. For everyday use you’ll import v2 and never think about the split.
Turning it on
On Go 1.25 or 1.26, the packages are hidden unless you build with the experiment flag. There’s no go get and no third-party module. It’s already in your toolchain, just gated.
# One-off run
GOEXPERIMENT=jsonv2 go run main.go
# Persist it for a session
export GOEXPERIMENT=jsonv2
go build ./...
go test ./...
Once the flag is set, the import paths become visible:
// main.go — run with: GOEXPERIMENT=jsonv2 go run main.go
package main
import (
"fmt"
json "encoding/json/v2"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Roles []string `json:"roles"`
}
func main() {
u := User{ID: 7, Name: "Ada"} // Roles left nil on purpose
b, err := json.Marshal(u)
if err != nil {
panic(err)
}
fmt.Println(string(b))
}
Run it and you get:
{"id":7,"name":"Ada","roles":[]}
Under v1, that same struct marshals to "roles":null. That single difference is the one most likely to change bytes on the wire in your service, so it’s worth internalizing before anything else.
If you can’t move off an older Go toolchain yet, the same code is available today as a standalone module, github.com/go-json-experiment/json, which is the upstream where v2 was developed. The import path differs but the API is the same, so it’s a fair way to experiment on Go 1.23 or 1.24 before your production toolchain catches up.
The breaking changes, ranked by how likely they are to bite
Here’s the full set of default changes, most disruptive first. Every one of them is controllable. v2 keeps the old behavior available as an option; it’s just no longer the default.
| Behavior | v1 default | v2 default | How to restore v1 |
|---|---|---|---|
| Nil slice | null | [] | json.FormatNilSliceAsNull(true) |
| Nil map | null | {} | json.FormatNilMapAsNull(true) |
| Field matching | case-insensitive | case-sensitive | json.MatchCaseInsensitiveNames(true) or case:ignore tag |
| Duplicate object keys | accepted | rejected | jsontext.AllowDuplicateNames(true) |
| Invalid UTF-8 | replaced silently | rejected | jsontext.AllowInvalidUTF8(true) |
time.Duration | encoded as int64 nanoseconds | requires explicit format | json:",format:nano" |
Case sensitivity is the sneaky one
The nil-slice change is loud. You’ll see it in the first test that checks a response body. Case sensitivity is quiet, because it fails by silently not matching rather than by erroring. Consider input from an API that uses SCREAMING_CASE or PascalCase keys:
data := []byte(`{"NAME":"Ada"}`)
var u User // Name is tagged json:"name"
if err := json.Unmarshal(data, &u); err != nil {
panic(err)
}
fmt.Printf("Name=%q\n", u.Name)
Output:
Name=""
In v1 that would have populated u.Name with "Ada" because matching ignored case. In v2 the field stays at its zero value and no error is raised. If you consume upstream JSON whose casing doesn’t exactly match your tags, opt back in per-field:
type User struct {
Name string `json:"name,case:ignore"`
}
Or globally at the call site with json.MatchCaseInsensitiveNames(true). I’d reach for the per-field tag, since it documents which fields have loose upstream contracts instead of loosening the whole struct.
Duplicate keys now error
v1 happily unmarshaled {"id":1,"id":2} and kept the last value. That’s a genuine security hole: different parsers picking different values for the same key is the root of a whole class of request-smuggling and access-control bugs. v2 rejects it:
data := []byte(`{"id":1,"id":2}`)
var u User
err := json.Unmarshal(data, &u)
fmt.Println(err != nil) // true
The error message is descriptive (something along the lines of a duplicate object member name for "id"; the exact wording may shift while the package is experimental). If you have a legitimate reason to accept duplicates, passing jsontext.AllowDuplicateNames(true) restores the old leniency (that option and AllowInvalidUTF8 live in the jsontext package, since they’re syntactic, but you pass them straight to json.Unmarshal like any other option). Think hard before you reach for it.
Options replace tag soup
The change I like most has nothing to do with speed. In v1, everything was a struct tag or a global, and anything you couldn’t express as a tag you couldn’t express at all. v2 makes options first-class function arguments that you pass to Marshal and Unmarshal directly. The package reference lists the full set.
b, err := json.Marshal(u,
json.FormatNilSliceAsNull(true), // keep v1 null-for-nil
json.Deterministic(true), // stable map key ordering
jsontext.WithIndent(" "), // pretty-print
)
That Deterministic(true) option is a small quality-of-life win worth calling out: it guarantees identical inputs produce identical output bytes, which makes golden-file tests and content hashing reliable without you sorting maps by hand. You can bundle options once and reuse them:
prettyV1 := json.JoinOptions(
json.FormatNilSliceAsNull(true),
json.FormatNilMapAsNull(true),
jsontext.WithIndent(" "),
)
out, _ := json.Marshal(payload, prettyV1)
The struct-tag vocabulary grew too. The most useful new tag is omitzero, which finally fixes the ancient confusion around omitempty:
type Event struct {
Name string `json:"name"`
Start time.Time `json:"start,omitzero"` // dropped when the zero Time
Tags []string `json:"tags,omitempty"` // dropped when empty JSON ([], "", null)
}
omitempty in v1 was notorious for not omitting a zero time.Time (because a struct is never “empty” by its rule). omitzero checks the Go zero value (or an IsZero() method if your type has one), which is almost always what people meant in the first place. If you’ve ever shipped "start":"0001-01-01T00:00:00Z" into a JSON response by accident, this tag is for you.
Streaming and the O(n²) trap
The headline performance number is unmarshal speed, but the more interesting fix is structural. In v1, if a custom type implemented MarshalJSON, encoding a large slice of that type could degrade to O(n²): each element’s MarshalJSON allocated a fresh []byte, which then got re-parsed and re-copied into the parent buffer. Nest that a couple of levels deep and throughput falls off a cliff.
v2 adds streaming custom-marshaler interfaces that write directly into the shared encoder:
// Old, allocation-heavy:
// func (t T) MarshalJSON() ([]byte, error)
//
// New, streams into the parent encoder:
func (t T) MarshalJSONTo(enc *jsontext.Encoder) error {
return enc.WriteToken(jsontext.String(t.value))
}
If you’d rather not add methods to a type, v2 also takes function-based marshalers registered per type through json.WithMarshalers. Those helpers lean on Go generics so you get a type-safe func(T) handler instead of the old interface{} juggling. For bulk output you can drive the encoder yourself and avoid building a giant in-memory value at all:
import (
json "encoding/json/v2"
"encoding/json/jsontext"
)
func writeAll(w io.Writer, users []User) error {
enc := jsontext.NewEncoder(w)
for _, u := range users {
if err := json.MarshalEncode(enc, u); err != nil {
return err
}
}
return nil
}
The decode side mirrors it with json.UnmarshalDecode over a jsontext.Decoder, which pairs naturally with a range loop the way Go’s iterators do: read one value, process it, discard it, without holding the whole document in memory. The Go team reported that Kubernetes’ kube-openapi saw “orders of magnitude” improvement switching a hot path to the streaming interface. That’s the kind of gain you only get from fixing the algorithmic shape of the problem, and no amount of parser tuning would have matched it.
Benchmarking it yourself
I want to be precise here: I did not run these numbers on my own hardware, because the machine I wrote this on is still on Go 1.24 and the package needs 1.25+. The 2–10x figure comes from the Go team’s own benchmarks and third-party measurements, and I’m labeling it as reported rather than measured. Marshal is roughly at parity with v1; the big wins are on the unmarshal path, where the new parser does far less work.
Don’t take anyone’s benchmark on faith, including mine. Here’s a harness you can drop into your own repo and run against your actual payloads, which is the only benchmark worth trusting:
package bench
import (
"os"
"testing"
json "encoding/json/v2"
)
func BenchmarkUnmarshal(b *testing.B) {
data, err := os.ReadFile("testdata/payload.json") // your real data
if err != nil {
b.Fatal(err)
}
b.ReportAllocs()
for b.Loop() {
var out []User
if err := json.Unmarshal(data, &out); err != nil {
b.Fatal(err)
}
}
}
GOEXPERIMENT=jsonv2 go test -bench=Unmarshal -benchmem ./bench/
Run the same benchmark once with the flag and once without (swapping the import back to encoding/json), and compare with benchstat. Your mileage depends heavily on the shape of your data. Documents with lots of small objects and string-heavy payloads tend to show the largest gap, while a handful of big numeric arrays will show less.
A safe migration plan
The good news is that v2 is designed for gradual adoption. v1 is being reimplemented on top of v2 internally, and the two are meant to coexist for years. The Go team has said v1 won’t be deprecated for years after v2 stabilizes, so you don’t have to flip everything at once. Here’s the order I’d follow:
- Turn on the flag in CI first, not prod. Build and run your full test suite with
GOEXPERIMENT=jsonv2. Most failures will be golden-file or response-body assertions catching the nil-slice and case-sensitivity changes. That’s your blast-radius report, for free. - Fix contracts, not symptoms. Where a test breaks because a nil slice now encodes as
[], decide which representation your API actually promises. Usually[]is correct and the test was wrong; occasionally a client depends onnulland you setFormatNilSliceAsNull(true)at that boundary. - Add
case:ignorewhere upstream casing is loose. Audit every place you unmarshal third-party JSON and tag the fields whose casing you don’t control. - Adopt
omitzeroin place of theomitempty-on-a-struct workarounds you’ve accumulated. - Leave the streaming interfaces for later. They’re an optimization you can defer safely. Convert the one or two hot paths that profiling flags, and leave the rest on plain
Marshal.
If you build data-heavy services, this is the same performance-versus-safety tradeoff conversation that plays out in Python land with msgspec versus Pydantic: a stricter, faster serializer that asks you to be explicit about your data’s shape. Go just happens to be baking that upgrade into the standard library instead of a third-party package.
FAQ
Is Go json v2 stable and production-ready?
Not yet, officially. As of Go 1.26 it’s experimental, gated behind GOEXPERIMENT=jsonv2, and the API can still change. The plan is to graduate it to the default in Go 1.27. Plenty of teams are already testing it in CI, but I wouldn’t gate a production release on experimental behavior that might shift.
What’s the difference between encoding/json and encoding/json/v2?
Same core API (Marshal/Unmarshal), but v2 has stricter, safer defaults (rejects duplicate keys and invalid UTF-8, matches field names case-sensitively), encodes nil slices/maps as []/{} instead of null, adds first-class option arguments, introduces the omitzero tag, and ships a much faster unmarshaler.
How much faster is Go json v2?
Reported benchmarks put unmarshaling at roughly 2–10x faster than v1, driven by a more efficient parser. Marshaling is about the same speed as v1. The gains depend on your data shape, so benchmark your own payloads rather than trusting a single headline number.
Do I need to rewrite my structs to use json v2?
No. Existing struct tags keep working. You only touch code where a changed default affects your output: restoring null for nil slices at a specific boundary, adding case:ignore for loosely-cased upstreams, or swapping omitempty for omitzero. Everything else is a recompile.
Can I use encoding/json/v2 on older Go versions?
The standard-library path needs Go 1.25 or newer with the experiment flag. If you’re stuck on Go 1.23 or 1.24, the identical implementation is available as the standalone module github.com/go-json-experiment/json.
Sources
- A new experimental Go API for JSON — The Go Blog — the official announcement, rationale, and behavior changes
- encoding/json/v2 — Go Packages — full API reference for functions, options, and struct tags
- encoding/json/jsontext — Go Packages — the syntactic streaming layer
- go-json-experiment/json — GitHub — the upstream module usable on older Go toolchains
- Go 1.26 Release Notes — current stable release context
Bottom line
json/v2 is the rare standard-library change that’s both faster and safer, and the migration cost is mostly “run your tests and read the diffs.” The performance is a nice bonus; the real value is that the defaults finally match what most Go developers meant all along: no more null slices leaking into frontends, no more silently accepted duplicate keys, and case-insensitive matching stops quietly papering over schema mismatches. Turn on GOEXPERIMENT=jsonv2 in CI this week, see what breaks, and you’ll be ready the day Go 1.27 makes it the default.