nowhere null

Benchmarking Go 1.27's encoding/json: The `any` Trap

03 July 2026

Background

I’m developing software that needs strong JSON library performance, especially on the decoding side.

encoding/json/v2 has been experimental in Go for almost a year. It started from this issue. Starting in Go 1.25, it became part of the experimental feature set. In Go 1.27 (coming this August), v2 becomes the default backend for encoding/json.

Questions in my mind

Benchmark & Results

I asked GPT-5.5 to run the benchmark. The test uses Go 1.27rc1 and runs on my MacBook Air M2 with 24 GB of memory. The test data is NDJSON with 100,000 entries. A sample from the data looks like this:

{"type":"request","connection_id":996,"timestamp":"2026-07-01T00:42:05.503678Z","log_type":"DownstreamStart","http":{"version":"HTTP/1.1","method":"GET","scheme":"http","authority":"testhttp:8080","path":"/api/v1/resource/4"},"headers":{"x-api-key":["rqt_api_dummy-apikey-local"],"x-forwarded-host":["testhttp:8080"],"x-forwarded-port":["8080"],"x-forwarded-proto":["http"],"x-forwarded-scheme":["http"],"x-real-ip":["172.18.0.1"]},"body":{}}

The benchmark runs four cases:

Decode results

Rank Library Total time Mean/pass Throughput ns/record Allocated bytes/record Allocs/record Relative to v1
1 encoding/json/v2 native 983ms 197ms 213.32 MiB/s 1967 1755 53.04 1.76x
2 goccy/go-json 994ms 199ms 211.06 MiB/s 1988 2797 83.38 1.74x
3 encoding/json (nojsonv2 v1) 1.726s 345ms 121.55 MiB/s 3451 2139 71.49 1.00x
4 encoding/json (default jsonv2) 2.216s 443ms 94.65 MiB/s 4433 2089 69.84 0.78x

Encode results

Rank Library Total time Mean/pass Throughput ns/record Allocated bytes/record Allocs/record Relative to v1
1 encoding/json/v2 native 890ms 178ms 235.71 MiB/s 1780 565 10.99 1.41x
2 goccy/go-json 991ms 198ms 211.73 MiB/s 1981 1334 5.84 1.26x
3 encoding/json (default jsonv2) 1.115s 223ms 188.15 MiB/s 2230 677 17.98 1.12x
4 encoding/json (nojsonv2 v1) 1.252s 250ms 167.51 MiB/s 2504 1748 39.74 1.00x

At first glance, when using encoding/json with the default jsonv2 backend, decoding is slower than legacy v1 in this benchmark. It is only about 80% of legacy v1 throughput. For encoding, the compatibility path is modestly faster than v1 in this run.

From the Go 1.27 release notes:

Marshal performance is broadly at parity with the previous implementation, while unmarshal performance is significantly faster.

That does not match this benchmark. What happened?

Why?

So I asked GPT-5.5 to analyze why this happens. This is what it found.

We start from profiling:

Function Flat Flat % Cumulative Cumulative % Meaning
encoding/json/v2.makeInterfaceArshaler.func2 390ms 3.10% 10.32s 82.10% Generic interface decode path.
encoding/json/v2.makeMapArshaler.func3 180ms 1.43% 10.18s 80.99% Generic map decode path.
encoding/json/v2.makeStringArshaler.func2 180ms 1.43% 2.29s 18.22% Generic string decode path.
runtime.mallocgc 270ms 2.15% 2.19s 17.42% Allocation and GC work.
encoding/json/v2.makeSliceArshaler.func3 90ms 0.72% 2.13s 16.95% Generic slice decode path.
encoding/json/v2.newAddressableValue 10ms 0.08% 1.64s 13.05% Addressable reflection value construction.
reflect.unsafe_New 50ms 0.40% 1.55s 12.33% Reflection allocation.
reflect.New 10ms 0.08% 1.50s 11.93% Reflection allocation wrapper.
reflect.Value.Set 80ms 0.64% 1.35s 10.74% Reflection assignment.
reflect.Value.assignTo 170ms 1.35% 1.29s 10.26% Reflection conversion and assignment.
reflect.Value.SetMapIndex 130ms 1.03% 1.22s 9.71% Reflection map insertion.

Pay attention to the extra reflection cost. This is one of the main places where the additional decode time is spent.

The extra decode cost mostly comes from the compatibility path falling out of the optimized any decoder and into generic reflection-heavy decoding.

Native v2 has an optimized any path, but it only applies when duplicate-name compatibility is disabled:

// encoding/json/v2/arshal_default.go
if optimizeCommon && t == anyType && !uo.Flags.Get(jsonflags.AllowDuplicateNames|jsonflags.FormatTag) && (uo.Unmarshalers == nil || !uo.Unmarshalers.(*Unmarshalers).fromAny) {
    v, err := unmarshalValueAny(dec, uo)
    if v != nil {
        va.Set(reflect.ValueOf(v))
    }
    return err
}

The encoding/json v1 compatibility options enable duplicate names:

// encoding/json/internal/jsonflags/flags.go
DefaultV1Flags = 0 |
    AllowDuplicateNames |
    AllowInvalidUTF8 |
    EscapeForHTML |
    EscapeForJS |
    PreserveRawStrings |
    Deterministic |
    FormatNilMapAsNull |
    FormatNilSliceAsNull |
    MatchCaseInsensitiveNames |
    CallMethodsWithLegacySemantics |
    FormatByteArrayAsArray |
    FormatBytesWithLegacySemantics |
    FormatDurationAsNano |
    MatchCaseSensitiveDelimiter |
    MergeWithLegacySemantics |
    OmitEmptyWithLegacySemantics |
    ParseBytesWithLooseRFC4648 |
    ParseTimeWithLooseRFC3339 |
    ReportErrorsWithLegacySemantics |
    StringifyWithLegacySemantics |
    UnmarshalArrayFromAnyLength

Because AllowDuplicateNames is true, the optimized v2 any fast path is skipped. The decoder goes through:

Legacy v1 has a direct any path for this exact benchmark shape:

// encoding/json/decode.go
// The xxxInterface routines build up a value to be stored
// in an empty interface. They are not strictly necessary,
// but they avoid the weight of reflection in this common case.
func (d *decodeState) valueInterface() (val any)
func (d *decodeState) objectInterface() map[string]any

This benchmark decodes every record into any, so that difference dominates.

So decoding into any through the compatibility path is the culprit in this benchmark. Let’s ask GPT-5.5 to run another test without decoding into any:

Rank Library Time Mean/pass Throughput ns/record Allocated bytes/record Allocs/record Relative to v1
1 encoding/json (default jsonv2) 14.346s 2.869s 152.84 MiB/s 28691 7149 131.42 1.69x
2 encoding/json (nojsonv2 v1) 24.203s 4.841s 90.59 MiB/s 48405 8347 188.00 1.00x

This time we see the decode performance gain.

Can we use the fast path?

As we saw above, we cannot use the fast path because of the condition below:

if optimizeCommon && t == anyType && !uo.Flags.Get(jsonflags.AllowDuplicateNames) && uo.Format == "" && (uo.Unmarshalers == nil || !uo.Unmarshalers.(*Unmarshalers).fromAny)

Specifically, AllowDuplicateNames is true by default. It’s tempting to remove it from the condition. However, the comment above this condition clearly says:

// Optimize for the any type if there are no special options.
// We do not care about stringified numbers since JSON strings
// are always unmarshaled into an any value as Go strings.
// Duplicate name check must be enforced since unmarshalValueAny
// does not implement merge semantics.

Imagine the case below:

var v any = map[string]any{
    "x": map[string]any{"old": float64(1)},
}
json.Unmarshal([]byte(`{"x":{"new":2}}`), &v)
Path Result
encoding/json with GOEXPERIMENT=nojsonv2 {"x":{"new":2}}
encoding/json default jsonv2 compatibility {"x":{"new":2}}
native encoding/json/v2 {"x":{"old":1,"new":2}}

In this case, we cannot simply remove the AllowDuplicateNames check because of the behavior.

Conclusions

At the start, GPT-5.5 chose any because the NDJSON data has mixed event shapes. After the analysis, we noticed that decoding into any through the compatibility route uses reflection-heavy generic decoding, which adds overhead. So we accidentally found an edge case where the v2-backed compatibility path can be slower than v1.

In another run without decoding into any, we saw the struct decode performance gains promised by v2.

In hindsight, you can’t blame GPT-5.5 for being lazy. Sometimes, when facing mixed or complex data types, you tend to use any. Now we know the cost.

The software I mentioned at the beginning of the post will be released soon as part of Reqfleet, the load testing service.