Skip to content

encoding/json: skyrocketing memory allocation in encoding when jsonv2 experiment enabled #75026

@kokes

Description

@kokes

Go version

go version go1.26-devel_a8564bd Thu Aug 14 12:20:59 2025 -0700 darwin/arm64

Output of go env in your module/workspace:

AR='ar'
CC='clang'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='clang++'
GCCGO='gccgo'
GO111MODULE='auto'
GOARCH='arm64'
GOARM64='v8.0'
GOAUTH='netrc'
GOBIN=''
GOCACHE='/Users/okokes/Library/Caches/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/Users/okokes/Library/Application Support/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -arch arm64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -ffile-prefix-map=/var/folders/s3/wfx4nrj12bz2pmxlr1sfymgm0000gp/T/go-build766535619=/tmp/go-build -gno-record-gcc-switches -fno-common'
GOHOSTARCH='arm64'
GOHOSTOS='darwin'
GOINSECURE=''
GOMOD=''
GOMODCACHE='/Users/okokes/go/pkg/mod'
GONOPROXY='github.com/rapid7'
GONOSUMDB='github.com/rapid7'
GOOS='darwin'
GOPATH='/Users/okokes/go'
GOPRIVATE='github.com/rapid7'
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/Users/okokes/sdk/gotip'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/Users/okokes/Library/Application Support/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/Users/okokes/sdk/gotip/pkg/tool/darwin_arm64'
GOVCS=''
GOVERSION='go1.26-devel_a8564bd Thu Aug 14 12:20:59 2025 -0700'
GOWORK=''
PKG_CONFIG='pkg-config'

What did you do?

I tried enabling the jsonv2 experiment both with and without the use of the new API. I noticed that when not using the new API (i.e. no code changes, just flipping a GOEXPERIMENT env), there was a huge increase memory allocations (in terms of bytes). Allocation count actually dropped significantly and the encoding peformance is a ~wash (or slightly slower with jsonv2).

It is my understanding that going forward (i.e. once the GOEXPERIMENT is retired), encoding/json will default to the v2 implementation under the hood (keeping the same v1 API). So once that happens, people upgrading to 1.26+ while still using the v1 API will see a sizable increase in allocation unless this is addressed.

I tried go1.25rc2, go1.25.0 and gotip (a8564bd), reproduced it in all three.

What did you see happen?

I'm encoding map[string]string values and the huge increase in allocations (in terms of bytes) is evident as we're growing the value length (though the allocations do grow even for short values).

go version go1.26-devel_a8564bd Thu Aug 14 12:20:59 2025 -0700 darwin/arm64
goos: darwin
goarch: arm64
cpu: Apple M3 Pro
                                        │ bench-baseline.txt │          bench-jsonv2.txt           │
                                        │       sec/op       │   sec/op     vs base                │
Encoding/keyCount=10,valLength=10               1.008µ ± 10%   1.230µ ± 1%  +22.02% (p=0.000 n=10)
Encoding/keyCount=10,valLength=1000             6.414µ ±  2%   8.118µ ± 1%  +26.56% (p=0.000 n=10)
Encoding/keyCount=1000,valLength=10             184.9µ ±  3%   179.6µ ± 1%   -2.88% (p=0.000 n=10)
Encoding/keyCount=1000,valLength=1000           741.7µ ±  1%   841.8µ ± 1%  +13.50% (p=0.000 n=10)
Encoding/keyCount=100000,valLength=10           30.18m ±  2%   25.14m ± 3%  -16.72% (p=0.000 n=10)
Encoding/keyCount=100000,valLength=1000         84.41m ±  2%   79.22m ± 2%   -6.15% (p=0.001 n=10)
geomean                                         362.2µ         379.9µ        +4.87%

                                        │ bench-baseline.txt │             bench-jsonv2.txt             │
                                        │        B/op        │      B/op       vs base                  │
Encoding/keyCount=10,valLength=10                 832.0 ± 0%       840.0 ± 0%     +0.96% (p=0.000 n=10)
Encoding/keyCount=10,valLength=1000               832.0 ± 0%     33139.0 ± 0%  +3883.05% (p=0.000 n=10)
Encoding/keyCount=1000,valLength=10             71.35Ki ± 0%     79.80Ki ± 0%    +11.84% (p=0.000 n=10)
Encoding/keyCount=1000,valLength=1000           71.35Ki ± 2%   4112.05Ki ± 0%  +5663.40% (p=0.000 n=10)
Encoding/keyCount=100000,valLength=10           7.077Mi ± 0%    11.058Mi ± 0%    +56.24% (p=0.000 n=10)
Encoding/keyCount=100000,valLength=1000         26.57Mi ± 6%    259.06Mi ± 0%   +875.15% (p=0.000 n=10)
geomean                                         93.37Ki          544.9Ki        +483.56%

                                        │ bench-baseline.txt │          bench-jsonv2.txt           │
                                        │     allocs/op      │  allocs/op   vs base                │
Encoding/keyCount=10,valLength=10                 22.00 ± 0%    19.00 ± 0%  -13.64% (p=0.000 n=10)
Encoding/keyCount=10,valLength=1000               22.00 ± 0%    21.00 ± 0%   -4.55% (p=0.000 n=10)
Encoding/keyCount=1000,valLength=10              2.002k ± 0%   1.016k ± 0%  -49.25% (p=0.000 n=10)
Encoding/keyCount=1000,valLength=1000            2.002k ± 0%   1.021k ± 0%  -49.00% (p=0.000 n=10)
Encoding/keyCount=100000,valLength=10            200.0k ± 0%   100.0k ± 0%  -49.99% (p=0.000 n=10)
Encoding/keyCount=100000,valLength=1000          200.0k ± 0%   100.0k ± 0%  -49.99% (p=0.000 n=10)
geomean                                          2.065k        1.267k       -38.64%
Code
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"math/rand"
	"testing"
)

func BenchmarkEncoding(b *testing.B) {
	for _, keyCount := range []int{10, 1000, 100_000} {
		for _, valLength := range []int{10, 1000} {
			b.Run(fmt.Sprintf("keyCount=%d,valLength=%d", keyCount, valLength), func(b *testing.B) {
				dt := make(map[string]string, keyCount)
				for k := range keyCount {
					val := make([]byte, valLength)
					for j := 0; j < valLength; j++ {
						val[j] = 'a' + byte(rand.Intn(26))
					}

					dt[fmt.Sprintf("key%d", k)] = string(val)
				}

				b.ResetTimer()
				for b.Loop() {
					if err := json.NewEncoder(io.Discard).Encode(dt); err != nil {
						// if err := json.MarshalWrite(io.Discard, dt); err != nil {
						b.Fatal(err)
					}
				}
			})
		}
	}
}
Wrapper shell script
#!/usr/bin/env bash

set -eu

COUNT=10

GOMAXPROCS=1 gotip test -count=$COUNT -bench=. -benchmem -run=NONE . | tee bench-baseline.txt
GOEXPERIMENT=jsonv2 GOMAXPROCS=1 gotip test -count=$COUNT -bench=. -benchmem -run=NONE . | tee bench-jsonv2.txt

gotip version
benchstat bench-baseline.txt bench-jsonv2.txt

Looking at the memory profile didn't show anything immediately suspicious, but I haven't dug around it too much.

(pprof) top
Showing nodes accounting for 15.48GB, 99.68% of 15.53GB total
Dropped 8 nodes (cum <= 0.08GB)
Showing top 10 nodes out of 19
      flat  flat%   sum%        cum   cum%
   14.44GB 92.98% 92.98%    14.44GB 92.98%  bytes.growSlice
    0.37GB  2.41% 95.38%     0.37GB  2.41%  reflect.copyVal
    0.20GB  1.27% 96.66%    15.53GB   100%  /Users/okokes/git/personal/perf.BenchmarkEncoding.func1
    0.16GB  1.06% 97.72%     0.16GB  1.06%  encoding/json.NewEncoder
    0.15GB  0.98% 98.69%     0.15GB  0.98%  slices.Grow[go.shape.[]uint8,go.shape.uint8] (inline)
    0.09GB  0.59% 99.28%     0.09GB  0.59%  encoding/json/v2.getStrings
    0.06GB   0.4% 99.68%    14.50GB 93.38%  bytes.(*Buffer).grow
         0     0% 99.68%    14.31GB 92.15%  bytes.(*Buffer).Grow (inline)
         0     0% 99.68%     0.19GB  1.23%  bytes.(*Buffer).Write
         0     0% 99.68%    15.17GB 97.67%  encoding/json.(*Encoder).Encode

What did you expect to see?

I expected the memory allocations to be the same or better than when the experiment is turned off.

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugReportIssues describing a possible bug in the Go implementation.NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.Performance

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions