encoding/json decoding with hard limits on object key count, string length,
and decoded size — enforced token by token, as the document is read, not
after it has already been built.
$ go test -run '^$' -bench . -benchmem
deep nesting — 5,000 levels of [[[...]]] (encoding/json accepts this fine; its own ceiling is 10,000)
encoding/json 970µs 449 KB 15,019 allocs parsed
jsonlimit 3µs 5 KB 47 allocs refused — nesting depth 33 exceeded max depth 32 at offset 33
key explosion — 200,000 keys in one object
encoding/json 80.6ms 26.4 MB 607,716 allocs parsed
jsonlimit 2.3ms 2.3 MB 46,762 allocs refused — key count 10001 exceeded max keys 10000 at offset 98899
giant string — one 50 MB string value
encoding/json 36.3ms 62.9 MB 6 allocs parsed
jsonlimit (Decode only) 44.5ms 197.1 MB 27 allocs refused — but slower and heavier than parsing it (see below)
jsonlimit (+ Reader byte cap) 0.9ms 14.7 MB 27 allocs refused — before the string is even fully read
ordinary document — ~4,000 objects, nothing wrong with it
encoding/json 7.57ms 3.57 MB 96,515 allocs parsed
jsonlimit 5.46ms 4.21 MB 116,455 allocs parsed — 1.3-1.5x *faster*, not slower
All four rows are from go test -bench . -benchmem, Go 1.27.1, Apple M5 Pro.
The full commands and raw output are in Benchmarks. Read the
third row carefully — it is the one case where this package, used the
obvious way, is a net loss, and that is reported honestly below rather than
cut from the table.
encoding/json has no limit on the number of keys in an object, the length
of a single string, or how much memory a decoded value ends up occupying.
Every byte-size limit an HTTP server puts on a request body is a limit on
input bytes, and input bytes are a poor proxy for what decoding costs:
| A request body can contain | json.Unmarshal result |
|---|---|
| 200,000 keys in one object | one map[string]any, 200,000 entries deep, built in full |
| one 50 MB string | decoded in full, and held in memory as a Go string |
5,000 levels of [[[...]]] nesting |
decoded in full — see below for why this one is less interesting than it looks |
Unlike the classic "attacker sends a million levels of nesting and your
parser recurses off the end of the stack" bug, encoding/json's own
scanner already refuses excessive nesting, unconditionally:
// This limits the max nesting depth to prevent stack overflow.
// This is permitted by https://tools.ietf.org/html/rfc7159#section-9
const maxNestingDepth = 10000That's $GOROOT/src/encoding/json/scanner.go:148, checked directly against
Go 1.27.1 on this machine. It is not configurable, and it needs no library
to benefit from — json.Unmarshal([]byte(strings.Repeat("[", 10001)+...), &v)
fails on its own, with a plain *json.SyntaxError, at exactly 10,001
levels:
$ go doc -all encoding/json | grep -i depth # nothing — it isn't exposed as an option
encoding/json/v2's tokenizer (encoding/json/jsontext, new in Go 1.27 —
see the next section) hardcodes the identical constant at
jsontext/state.go:53, so this isn't a v1-only accident that a rewrite
fixed; it's a considered, permanent ceiling in both encoders. This
package does not raise, lower, or otherwise touch that ceiling by default
(Limits.MaxDepth, when set, enforces a stricter bound underneath it —
see Options). If your only concern is a crash from pathological
nesting, encoding/json already has you covered, for both v1 and v2,
and no third-party code changes that.
Go 1.27 is the first release where encoding/json/v2 and its tokenizer,
encoding/json/jsontext, ship as real, usable standard-library packages —
confirmed on this machine by building and running a program that imports
encoding/json/v2 with no GOEXPERIMENT set at all:
$ grep -n "JSONv2" "$(go env GOROOT)/src/internal/buildcfg/exp.go"
JSONv2: true,
JSONv2: true is the default baseline for the Go 1.27 toolchain, not an
opt-in experiment you have to enable. It is real and it is on.
It still does not add a key-count limit, a string-length limit, or a
decoded-size limit — checked directly by reading
encoding/json/jsontext/options.go rather than assuming. The two options
that sound like they might be exactly this are in the file, but commented
out:
/*
// TODO(https://go.dev/issue/56733): Implement WithByteLimit and WithDepthLimit.
// Remember to also update the "Security Considerations" section.
// WithByteLimit sets a limit on the number of bytes of input or output bytes
// that may be consumed or produced for each top-level JSON value.
...
func WithByteLimit(n int64) Options { ... }
// WithDepthLimit sets a limit on the maximum depth of JSON nesting
...
func WithDepthLimit(n int) Options { ... }
*/That's jsontext/options.go:272-304. Both functions are inside a /* */
block — dead source, not compiled, gated behind an open, unresolved issue
(go.dev/issue/56733). Even if they shipped as written, they would only
cover bytes and depth, the two limits this package cares about least: depth
because §1 already handles it, and a total byte count because that's
already easy to enforce yourself with an io.LimitReader (or this
package's Reader, which does the same thing with a typed error). Nothing
in v2, shipped or drafted, bounds key count or string length — the two
limits that matter most and are hardest to retrofit around Unmarshal.
This is the actual gap this package fills: Limits.MaxKeys and
Limits.MaxStringLength, neither of which encoding/json or
encoding/json/v2 offers in Go 1.27, plus Limits.MaxDepth and
Limits.MaxDecodedSize as defense-in-depth below stdlib's own ceiling.
go get github.com/agentscope-ai-java/jsonlimitGo 1.27+. No runtime dependencies.
import "github.com/agentscope-ai-java/jsonlimit"
value, err := jsonlimit.Decode(r, jsonlimit.DefaultLimits())
if err != nil {
var le jsonlimit.LimitError
if errors.As(err, &le) {
// le.Limit(), le.Observed(), le.Offset() — a 413, not a 400
return
}
// plain malformed JSON — a 400
return
}Decode takes an io.Reader and returns exactly what json.Unmarshal
would return into a var v any: nil, bool, float64, string,
[]any, or map[string]any. A limit violation comes back as one of
*DepthLimitError, *KeyLimitError, *StringLengthError, or
*DecodedSizeError — all implement LimitError. A syntax error comes back
as whatever encoding/json itself would return, unwrapped, so an existing
errors.As(err, &syntaxErr) keeps working unmodified.
Cap the input independently of anything Decode does, before it ever
reaches the decoder:
body := jsonlimit.NewReader(r.Body, 5<<20) // 5 MiB, hard cap on the wire
value, err := jsonlimit.Decode(body, jsonlimit.DefaultLimits())See What it does not do for why both matter and neither alone is enough for the giant-string case.
The other two languages in this series (a Node and a Python sibling of
this package, json-guard and jsonguard) both work the same way: one
linear pass over the raw text, counting brackets and string boundaries,
before the real parser ever runs. They have to — JSON.parse and
json.loads are opaque, single-shot calls with no way to observe or
interrupt them mid-parse. That pre-scan is a second full pass over the
input, and it shows up as their main measured cost: roughly 2x in the Node
sibling, 12-18x in the Python one.
Go doesn't need a pre-scan, because encoding/json.Decoder exposes
Token(): a method that returns exactly one JSON token — a delimiter, a
string, a number, a bool, or nil — per call, advancing through the input
lazily. Decode calls Token() in a loop and builds the same
map[string]any / []any tree json.Unmarshal would, but it checks every
limit as each token arrives, in the same pass that builds the value,
instead of scanning first and parsing second:
- On every
{or[, before pushing a new container onto the stack: compare the new depth toMaxDepthand refuse before that container is ever allocated. - On every string that lands in key position: count it against
MaxKeysand refuse before its value is read at all. - On every string, key or value: compare its decoded length to
MaxStringLength. - After every token: add its estimated footprint to a running total and
compare it to
MaxDecodedSize.
Because the walk is driven by an explicit []*container stack rather than
a recursive Go function calling itself once per nesting level, Decode's
own call stack never grows with the input's depth — independent of
whatever MaxDepth is configured to allow. A naive alternative
implementation (recurse into each nested value with an ordinary Go
function) would tie Decode's safety to Go's own goroutine stack growth
limit instead of to MaxDepth, which is exactly the kind of
implementation detail this package exists to not depend on.
The result: on ordinary, well-formed input, this package is not paying for
a second pass the way its siblings are, and the benchmarks below show that
directly — Decode measures faster than json.Unmarshal on an ordinary
document, not merely "less slow than expected." The one place this
single-pass design still doesn't come for free is a single very large
string, covered honestly in the next section.
Limits, passed to Decode:
| Field | Type | What it bounds |
|---|---|---|
MaxDepth |
int |
Nesting levels. A flat object or array is 1. |
MaxKeys |
int |
Object members across the whole document, not per object. |
MaxStringLength |
int |
Any single string's decoded length in bytes, keys included. |
MaxDecodedSize |
int64 |
An estimate of the decoded value's memory footprint — see below. |
DefaultLimits() returns {MaxDepth: 64, MaxKeys: 100_000, MaxStringLength: 1<<20, MaxDecodedSize: 8<<20}. The zero value, Limits{},
disables every check here (still subject to encoding/json's own
depth-10000 ceiling, which this package never touches). Zero or negative on
any individual field disables that one check.
MaxKeys counts syntactically: {"a":1,"a":2} is 2 keys even though the
decoded map has one entry, matching what the decoder actually pays for.
MaxDecodedSize is a fixed-overhead estimate, not unsafe.Sizeof or a
runtime memory profile: every string's decoded byte length, plus 48 bytes
for every object or array (map[string]any header and initial buckets, or
a []any header) and 16 bytes for every number, bool, or null (an any
holding one). It's deliberately conservative and documented, not exact —
Go's actual per-entry map and slice overhead varies with load factor and
growth history in ways not worth chasing for a resource-abuse guard.
Reader, wrapping an io.Reader:
| Constructor | What it bounds |
|---|---|
NewReader(r, max) |
Total bytes Read will ever deliver. Non-positive max disables it. |
Modeled on net/http.MaxBytesReader's read-ahead trick (request one byte
past the remaining budget, so input that lands exactly on the limit isn't
mistaken for input that overflows it), with no dependency on net/http and
its own error type.
All implement LimitError, with Limit(), Observed(), and Offset()
(int64 in each case; Offset() is -1 when no input offset applies).
| Error | Observed() |
Notes |
|---|---|---|
DepthLimitError |
always Limit()+1 |
refuses at the first level past the limit |
KeyLimitError |
always Limit()+1 |
refuses at the first key past the limit |
StringLengthError |
the string's true length | see below for why this isn't Limit()+1 |
DecodedSizeError |
the running total at the crossing | grows unevenly per token, unlike depth or keys |
BytesLimitError |
always Limit() |
raised by Reader; Offset() is always -1 |
var le jsonlimit.LimitError
if errors.As(err, &le) {
log.Printf("refused: %s (limit %d, observed %d, offset %d)",
err, le.Limit(), le.Observed(), le.Offset())
}StringLengthError.Observed()is the string's real length, notLimit()+1, because it can't be anything else.Decoder.Token()handsDecodethe whole decoded string in a single call — by the timeDecodecan measure it,encoding/json's own tokenizer has already read and unescaped every byte of it. Unlike depth and key checks, which refuse before the next unit of work happens,MaxStringLengthon its own only refuses after that unit of work (reading and unescaping one string) has already happened. For a single enormous string, that's most of the cost. This is exactly the scenarioReaderexists for: it caps raw bytes off the wire, beforeToken()ever gets far enough to buffer the string in the first place. See Benchmarks — used alone,Decode'sMaxStringLengthis measurably worse than plainjson.Unmarshalon a 50 MB string; paired withReader, the same input is refused about 39x faster and with about a quarter of the memory.- It is not a schema validator. It bounds shape, not meaning. Run your
existing struct-level
Unmarshalor a validator afterward; this exists to keep a hostile document from costing you anything before that. - It does not bound array length directly. A million-element array of
small numbers is bounded only by
MaxDecodedSize(and, on the wire, byReader).MaxKeyscovers object members, where the per-entry cost is materially higher (a map entry vs. a slice append); for flat arrays,MaxDecodedSizeis the relevant proxy. - It decodes into
any, not into your own struct types.Decodebuilds the same generic treejson.Unmarshal(data, &v)would forvar v any. If you needUnmarshal-into-a-struct with these limits applied, decode intoanyfirst and convert, or reuseLimits' accounting logic directly —Decodedoesn't (and, being a token walk rather than a reflection-drivenUnmarshal, structurally can't easily) drive arbitrary struct field assignment the wayencoding/json.Unmarshaldoes. - It reads exactly one JSON value and stops. Trailing bytes after that
value are never inspected;
Decodedoesn't error on them, and doesn't consume them either. MaxDecodedSize's accounting is an estimate, not a measurement of actual Go heap bytes — see Options for the exact formula. It's conservative and deterministic, not a promise aboutruntimestatistics.
go test -run '^$' -bench . -benchmem -benchtime=30x -count=3Go 1.27.1, darwin/arm64, Apple M5 Pro. Figures below are the mean of 3
runs at -benchtime=30x (-benchtime=15x for the 50 MB giant-string
cases, which are otherwise slow to repeat); raw go test -bench output,
unedited, is reproducible with the command above.
Deep nesting, 5,000 levels of [[[...]]] — comfortably under
encoding/json's own 10,000 ceiling, so Unmarshal accepts it, and this
measures jsonlimit's own MaxDepth: 32 catching what stdlib doesn't:
| time/op | bytes/op | allocs/op | |
|---|---|---|---|
json.Unmarshal |
970 µs | 449 KB | 15,019 |
jsonlimit.Decode |
2.7 µs | 4.7 KB | 47 |
| ~360x faster | ~96x less | ~320x fewer |
Key explosion, 200,000 keys in one object, MaxKeys: 10_000:
| time/op | bytes/op | allocs/op | |
|---|---|---|---|
json.Unmarshal |
80.6 ms | 26.4 MB | 607,716 |
jsonlimit.Decode |
2.3 ms | 2.3 MB | 46,762 |
| ~35x faster | ~11.6x less | ~13x fewer |
Giant string, one 50 MB string value, MaxStringLength: 1 MiB — the
case worth reading carefully:
| time/op | bytes/op | allocs/op | |
|---|---|---|---|
json.Unmarshal |
36.3 ms | 62.9 MB | 6 |
jsonlimit.Decode alone |
44.5 ms | 197.1 MB | 27 |
jsonlimit.Decode + Reader(1 MiB) |
0.9 ms | 14.7 MB | 27 |
Decode's own MaxStringLength, used by itself, is ~1.2x slower and
~3.1x heavier than just letting json.Unmarshal parse the whole thing —
worse than "undramatic," an outright regression, and reported here rather
than left out. The reason is in
What it does not do: Decode must fully read and
unescape the string before it can measure it, and does so through
Decoder's general-purpose streaming machinery, which costs more per byte
than Unmarshal's direct, single-shot decode of an in-memory []byte.
Paired with Reader capping the same input to 1 MiB on the wire, the
picture reverses completely: ~39x faster, ~4.3x less memory, because
the byte cap stops the read while the string is a fraction built, long
before Decode's string check would ever get a chance to run.
Ordinary document, ~4,000 objects with nested fields and array values, nothing pathological — the cost paid on every normal request:
| time/op | bytes/op | allocs/op | |
|---|---|---|---|
json.Unmarshal |
7.57 ms | 3.57 MB | 96,515 |
jsonlimit.Decode |
5.46 ms (range: 4.95-6.16 ms) | 4.21 MB | 116,455 |
jsonlimit.Decode measured roughly 1.3-1.5x faster than
json.Unmarshal here across three runs, while using about 18% more memory
and 21% more allocations. The likely reason: Unmarshal into any goes
through encoding/json's general-purpose, reflection-driven decode state
machine, while Decode's token walk builds map[string]any / []any
directly with no reflection at all. That's a genuinely different
performance profile from the Node and Python siblings, both of which pay a
real, unavoidable tax (2x and 12-18x respectively) on ordinary input for
their pre-scan; nothing here is being pre-scanned twice.
go build ./...
go vet ./...
go test -race ./...
gofmt -l .
go test -run '^$' -bench . -benchmemMIT