For years, Go developers have been stalked by the velociraptor of JSON performance - always close enough to hear the claws, never quite fast enough to outrun it. With Go 1.25, we finally strapped a jetpack to our frameworks.
Near the end of June, I read a roundup on Go 1.25 that reminded me how much of my life I've spent dodging JSON raptors. Let's be honest: JSON is everywhere and kind of awful. As a salty old dinosaur, I still reach for it more than ProtoBuf or MessagePack. So if the language gives me sharper teeth and faster legs... I'm in.
TL;DR: Go 1.25 brings an opt-in overhaul for JSON (encoding/json/v2
, plus a lower-level encoding/json/jsontext
). Boot 'er up and you'll often see meaningful decode speedups with fewer allocations, coupled with semantics that finally match what most of us meant all along.
Then vs Now: outrunning the old fossils
Then (pre-1.25): using encoding/json
often felt like chiseling fossils with a spoon; reflection heavy, allocation hungry & slower than popular third-party libs. Many teams - sometimes mine included - adopted alternatives just to shave off milliseconds.
Now (1.25 with v2): enable the experiment and you get a reworked engine:
- faster, especially on Unmarshal paths
- fewer surprise behaviours (case sensitivity, dup keys, nil collections)
- a lower-level
jsontext
layer that's strict about what JSON is (UTF-8, uniqueness)
In other words, the spoon got upgraded to a jackhammer - oh, and someone finally put guardrails around the dig site. Cool.
Turn it on with the env var:
GOEXPERIMENT=jsonv2
Caution: json/v2
is opt-in and still evolving; test thoroughly before enabling in prod.
Show me the raptors: real-world benchmarks
You can micro-bench toy structs all day, but the jungle test is big, messy, heterogeneous JSON. We want measurable impact in real-world scenarios. I used GitHub Archive (hourly JSON lines of public events) because it's authless and large.
Dataset
Download any hour you like. I tested with data from this month:
mkdir -p data
wget -O data/2025-08-01-0.json.gz https://data.gharchive.org/2025-08-01-0.json.gz
Benchmark harness
The trick is to load the data once (outside the timed loop), then hammer the decoders. Here's a minimal _test.go
that samples and decodes into both a struct and map[string]any
:
package ghbench
import (
"bufio"
"bytes"
"compress/gzip"
"encoding/json"
jsonv2 "encoding/json/v2" // requires GOEXPERIMENT=jsonv2
"io"
"os"
"testing"
)
const (
pathGz = "data/2025-08-01-0.json.gz"
sampleLines = 5000
)
var samples [][]byte
type EventLoose struct {
ID string `json:"id"`
Type string `json:"type"`
Actor map[string]any `json:"actor"`
Repo map[string]any `json:"repo"`
Payload map[string]any `json:"payload"`
CreatedAt string `json:"created_at"`
Public *bool `json:"public"`
}
func TestMain(m *testing.M) {
f, err := os.Open(pathGz)
if err != nil { panic(err) }
defer f.Close()
var r io.Reader = f
if len(pathGz) > 3 && pathGz[len(pathGz)-3:] == ".gz" {
gz, err := gzip.NewReader(f)
if err != nil { panic(err) }
defer gz.Close()
r = gz
}
sc := bufio.NewScanner(r)
const maxLine = 20 << 20 // 20MB
sc.Buffer(make([]byte, 0, 1<<20), maxLine)
for sc.Scan() {
if len(samples) >= sampleLines { break }
line := append([]byte(nil), sc.Bytes()...)
if len(bytes.TrimSpace(line)) > 0 {
samples = append(samples, line)
}
}
if err := sc.Err(); err != nil { panic(err) }
if len(samples) == 0 { panic("no samples read") }
os.Exit(m.Run())
}
func BenchmarkJSON_V1_Loose(b *testing.B) {
b.ReportAllocs()
var e EventLoose
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, line := range samples {
e = EventLoose{}
if err := json.Unmarshal(line, &e); err != nil {
b.Fatalf("v1: %v", err)
}
}
}
}
func BenchmarkJSON_V2_Loose(b *testing.B) {
b.ReportAllocs()
var e EventLoose
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, line := range samples {
e = EventLoose{}
if err := jsonv2.Unmarshal(line, &e); err != nil {
b.Fatalf("v2: %v", err)
}
}
}
}
func BenchmarkJSON_V1_Map(b *testing.B) {
b.ReportAllocs()
var m map[string]any
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, line := range samples {
m = nil
if err := json.Unmarshal(line, &m); err != nil {
b.Fatalf("v1 map: %v", err)
}
}
}
}
func BenchmarkJSON_V2_Map(b *testing.B) {
b.ReportAllocs()
var m map[string]any
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, line := range samples {
m = nil
if err := jsonv2.Unmarshal(line, &m); err != nil {
b.Fatalf("v2 map: %v", err)
}
}
}
}
Our test harness shape scales cost by
N * sampleLines
. That's okay if runs are short but if samples get large, set a fixed benchtime.
Run it
# Classic
go test -bench=. -benchmem -count=10 > old.txt
# v2 engine + APIs (Go 1.25 only)
GOEXPERIMENT=jsonv2 go test -bench=. -benchmem -count=10 > new.txt
# Compare
go install golang.org/x/perf/cmd/benchstat@latest
benchstat old.txt new.txt
Note: Some of you may need to
echo 'export PATH=$PATH:$(go env GOPATH)/bin' >> ~/.bashrc && source ~/.bashrc
if benchstat isn't found.
This is where I insert a disclaimer advising against pasting random internet commands into your terminal without knowing what they do, but I don't need to do that... right?
Remember, one of the rules with benchmarks are to run your own! But for me, here were my results.
Results
I ran these on 13th Gen Intel(R) Core(TM) i9-13900KF, Linux amd64.
$ benchstat old.txt new.txt
name old time/op new time/op delta
JSON_V1_Loose-32 116.7ms ±1% 65.7ms ±0% -44%
JSON_V1_Map-32 122.5ms ±0% 64.5ms ±1% -47%
name old alloc/op new alloc/op delta
JSON_V1_Loose-32 61.4MiB ±0% 57.1MiB ±0% -7%
JSON_V1_Map-32 64.6MiB ±0% 59.8MiB ±0% -8%
name old allocs/op new allocs/op delta
JSON_V1_Loose-32 1.19M ±0% 885k ±0% -25%
JSON_V1_Map-32 1.29M ±0% 975k ±0% -24%
Benchmark | V1 (old) | V2 (new) | Improvement |
---|---|---|---|
Loose sec/op | 116.7 ms | 65.7 ms | 🚀 ~44% faster |
Map sec/op | 122.5 ms | 64.5 ms | 🚀 ~47% faster |
Loose B/op | 61.4 MiB | 57.1 MiB | 💾 ~7% less mem |
Map B/op | 64.6 MiB | 59.8 MiB | 💾 ~8% less mem |
Loose allocs/op | 1.19 M | 885 k | ✂️ ~25% fewer |
Map allocs/op | 1.29 M | 975 k | ✂️ ~24% fewer |
Overall | — | — | ⚡ ≈1.8× faster overall |
I saw a consistent ~1.8× speedup with lower memory use and fewer allocations.
You should see lower ns/op and allocs/op for v2 on most heterogeneous payloads. If your raptor doesn't slow down by much, try a bigger sample size, add a typed struct path, or throw in another "real" dataset.
Sneak in your own test for
json-iterator/go
andbytedance/sonic
to the same harness for a 4-way boss fight. I didn't do this because I wasn't interested in third party libs; we want to know only about Go, and Go's chain.
Field guide: v2 behavior changes
These are the semantic guardrails that separate Jurassic quirks from modern predators. Most are wins; a few may require tweaks. These examples are all on the Go Playground, but because I can't set build args on the playground I import github.com/go-json-experiment/json
.
1) Naming & tags
- A) Case sensitivity: field matching is case-sensitive by default. Fewer "mystery matches" means safer refactors.
- B)
omitempty
: omits based on JSON emptiness (null
,[]
,{}
,""
) rather than Go's concept of zero values. - C)
string
tag option: narrower, predictable behaviour (no odd deep conversions).
A) Case-sensitive field matching**
https://go.dev/play/p/MB5BTCPB6yB
summary
package main
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
)
type User struct{ FullName string } // no tag on purpose
func main() {
b := []byte(`{"fullname":"Casey"}`) // lower-case key
var u1, u2 User
_ = json.Unmarshal(b, &u1) // v1: case-insensitive match
_ = jsonv2.Unmarshal(b, &u2) // v2: case-sensitive (no match)
fmt.Println("v1 FullName:", u1.FullName) // "Casey"
fmt.Println("v2 FullName:", u2.FullName) // ""
}
B) omitempty is about JSON emptiness (not Go zero)
https://go.dev/play/p/8_hjrF6ECSC
summary
package main
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
)
type Box struct{} // encodes to {}
type Demo struct {
B Box `json:",omitempty"`
}
func main() {
in := Demo{} // B is a non-pointer struct with zero value
v1, _ := json.Marshal(in)
v2, _ := jsonv2.Marshal(in)
fmt.Println("v1:", string(v1)) // {"B":{}} (struct is never “empty” to v1's omitempty)
fmt.Println("v2:", string(v2)) // {} ({} is JSON-empty, so v2 omits it)
}
C) ,string
tag
https://go.dev/play/p/hvdl5UIOIbD
summary
package main
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
)
type Demo struct {
Nums []int `json:",string"`
}
func main() {
// Elements are strings, not numbers
input := []byte(`{"Nums":["1","2","3"]}`)
var a, b Demo
err1 := json.Unmarshal(input, &a) // v1: invalid use of ,string on []int
err2 := jsonv2.Unmarshal(input, &b) // v2: OK - applies to each element
fmt.Printf("v1 Nums=%v err=%v\n", a.Nums, err1)
fmt.Printf("v2 Nums=%v err=%v\n", b.Nums, err2)
}
The interesting differences with
,string
in v2 show up in edge cases. Your test suite can cover stricter semantics in real code.
2) Collections & binaries
- A) Nil slices/maps: marshal as
[]
/{}
(notnull
). Client code rejoices. - B) Arrays: fixed lengths are enforced on unmarshal - no silent trim/extend.
- C) Byte arrays: encoded as base64 strings, not integer lists.
A) Nil slices/maps: []/{} (not null)
https://go.dev/play/p/tJqxHht8lMA
summary
package main
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
)
func main() {
var s []int // nil slice
var m map[string]int // nil map
b1s, _ := json.Marshal(s)
b1m, _ := json.Marshal(m)
b2s, _ := jsonv2.Marshal(s)
b2m, _ := jsonv2.Marshal(m)
fmt.Println("v1 slice:", string(b1s)) // null
fmt.Println("v1 map: ", string(b1m)) // null
fmt.Println("v2 slice:", string(b2s)) // []
fmt.Println("v2 map: ", string(b2m)) // {}
}
If your clients expect null, verify and adapt.
B) Arrays: fixed length enforced
https://go.dev/play/p/G9EBWREyLub
summary
package main
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
)
type Trio [3]int
func main() {
good := []byte(`[1,2,3]`)
short := []byte(`[1,2]`)
var a, b Trio
_ = json.Unmarshal(good, &a) // ok in v1
fmt.Println("v1 Trio:", a)
// v1 on short: silently fills first elements, leaves the rest zero
_ = json.Unmarshal(short, &a)
fmt.Println("v1 short Trio:", a)
// v2: strict about array length
fmt.Println("v2 good err:", jsonv2.Unmarshal(good, &b))
fmt.Println("v2 short err:", jsonv2.Unmarshal(short, &b)) // expect error
}
C) Byte arrays: base64 string (not number lists)
https://go.dev/play/p/1xLblPna3gi
summary
package main
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
)
func main() {
s := []byte{1, 2, 3}
a := [3]byte{1, 2, 3}
b1s, _ := json.Marshal(s) // v1: "AQID"
b1a, _ := json.Marshal(a) // v1: [1,2,3]
b2s, _ := jsonv2.Marshal(s) // v2: "AQID"
b2a, _ := jsonv2.Marshal(a) // v2: "AQID" // (byte *array* also base64)
fmt.Println("v1:", string(b1s))
fmt.Println("v1:", string(b1a))
fmt.Println("v2:", string(b2s))
fmt.Println("v2:", string(b2a))
}
3) Consistency knobs
- A) Pointer receiver methods:
MarshalJSON
/UnmarshalJSON
with pointer receivers are handled consistently. - B) Merging vs replacing:
null
clears; objects merge; scalars replace. Goodbye half-merges.
A) Pointer receiver methods are honored consistently
https://go.dev/play/p/4NdtlaH_9eK
summary
package main
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
)
type T struct{ X int }
func (t *T) MarshalJSON() ([]byte, error) { return []byte(`{"x":` + fmt.Sprint(t.X) + `}`), nil }
func main() {
v := T{X: 7}
b1, _ := json.Marshal(v) // v1 behavior can be surprising for non-pointer receivers
b2, _ := jsonv2.Marshal(v) // v2 is more consistent about honoring pointer-receiver methods; if you relied on v1's value/pointer quirks, add tests
fmt.Println("v1:", string(b1))
fmt.Println("v2:", string(b2))
}
B) Merge vs replace on updates
https://go.dev/play/p/nPkIiUNBQ4z
summary
package main
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
)
type Profile struct {
Name string
Meta map[string]any
}
func main() {
dst1 := Profile{Name: "Raptor", Meta: map[string]any{"a": 1, "b": 2}}
patch := []byte(`{"Meta":{"b":99},"Name":null}`)
_ = json.Unmarshal(patch, &dst1) // v1: merging behavior historically inconsistent
fmt.Println("v1:", dst1)
dst2 := Profile{Name: "Raptor", Meta: map[string]any{"a": 1, "b": 2}}
_ = jsonv2.Unmarshal(patch, &dst2) // v2: objects merge; null clears; scalars replace
fmt.Println("v2:", dst2)
}
Expect Name cleared in v2, Meta.b updated to 99 (with a preserved). Exact v1 behavior may vary by shape.
4) Safety & correctness
- A) Duplicate object names: now an error on both read and write.
- B) HTML: HTML isn't escaped by default.
- C) UTF-8: stricter UTF-8 (invalid is error).
- D) Map key order: no guaranteed sorting (faster). If you need determinism for tests, sort before marshal.
A) Duplicate object names: v2 errors
https://go.dev/play/p/x60uPnwX8-U
summary
package main
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
)
func main() {
dup := []byte(`{"a":1,"a":2}`)
var m1, m2 map[string]int
_ = json.Unmarshal(dup, &m1) // v1: last one wins
err := jsonv2.Unmarshal(dup, &m2) // v2: error on duplicates
fmt.Println("v1 m:", m1) // map[a:2]
fmt.Println("v2 err:", err != nil) // true
}
B) HTML escaping: v1 escapes by default; v2 doesn't
https://go.dev/play/p/CU8rV0R-GmI
summary
package main
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
)
func main() {
s := map[string]string{"h": "<tag>&"}
b1, _ := json.Marshal(s) // v1: {"h":"\u003ctag\u003e\u0026"}
b2, _ := jsonv2.Marshal(s) // v2: {"h":"<tag>&"}
fmt.Println("v1:", string(b1))
fmt.Println("v2:", string(b2))
}
C) UTF-8 validation: v2 is strict
https://go.dev/play/p/8npfRTcQafv
summary
package main
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
)
func main() {
// {"x":"a\xffb"} – 0xff is invalid UTF-8 inside a JSON string
bad := []byte{'{', '"', 'x', '"', ':', '"', 'a', 0xff, 'b', '"', '}'}
var v1, v2 map[string]string
err1 := json.Unmarshal(bad, &v1) // v1 may accept & coerce to U+FFFD
err2 := jsonv2.Unmarshal(bad, &v2) // v2: error on invalid UTF-8
fmt.Printf("v1 err: %v\n", err1)
fmt.Printf("v1 x: %q\n", v1["x"]) // usually "a�b"
fmt.Printf("v2 err: %v\n", err2) // non-nil
}
D) Map key order: no guaranteed sorting
https://go.dev/play/p/1Vpr6LLKDOe
summary
package main
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
)
func main() {
m := map[string]int{"b": 2, "a": 1}
b1, _ := json.Marshal(m) // v1: sorted keys -> {"a":1,"b":2}
b2, _ := jsonv2.Marshal(m) // v2: order not guaranteed
fmt.Println("v1:", string(b1))
fmt.Println("v2:", string(b2))
}
Final Result
End result? Fewer sharp edges, fewer "it works on my computer" bugs in production.
I should point out that we'll pay a small tax for checks like duplicate keys, but the crash-proofing is worth it.
Migration playbook (a.k.a. "don't get eaten")
1) Flip it in CI
Run your suite with and without:
GOEXPERIMENT=jsonv2
This lets you discover behavior diffs before you submit your pull request.
2) Audit your hot spots
- Endpoints relying on case-insensitive struct binding.
- Tests that assert sorted JSON byte-for-byte.
- Places that tolerated duplicate keys or invalid UTF-8.
- Frontends that expect
null
instead of[]
/{}
.
3) Make two decisions
-
time.Duration
representation (string vs number). -
omitempty
expectations for your public API (document it).
4) Be explicit
- If you want deterministic JSON for diffs, manually sort keys before marshal.
- If dup keys may arrive, pre-clean inputs (v2 will reject them).
5) Celebrate the wins
- Share your
benchstat
table. - Include a flamegraph before/after if you want to flex. I won't mind, promise.
Closing
While the zeitgeist is AI, Rust, and whatever's going on in TypeScript land, Go 1.25 quietly sharpened the tools most of us use every day. It didn't just add speed - it drained the swamp and posted signs so we stop stepping on the same branches twice.
Of course it's never recommended to turn on experimental flags in production code, we all want fewer dependencies, fewer moving parts, fewer places things go wrong. With modern large codebases serializing and deserializing millions of JSON payloads a day, a 50% speed boost across the top is nothing to sneer at. I've played with json/v2
quite a lot, and it seems stable.
The JSON raptors still lurk, but with json/v2
we've got a flare gun, night-vision goggles, and a pack of loyal compys.
Lace up.
Benchmark.
Ship.
Top comments (0)