DEV Community

Cover image for Go 1.25 JSON v2: Benchmarks, Raptor Escapes, and a 1.8 Speedup
Ryan B
Ryan B

Posted on

Go 1.25 JSON v2: Benchmarks, Raptor Escapes, and a 1.8 Speedup

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
Enter fullscreen mode Exit fullscreen mode

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)
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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%
Enter fullscreen mode Exit fullscreen mode
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 and bytedance/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) // ""
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 [] / {} (not null). 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))  // {}
}

Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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))
}

Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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))
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)