Input Size and Nesting Limits for Go JSON APIs in Practice
Limit request body size, nesting depth and element counts in a Go JSON API, with a token-stream checker and measured memory cost of an unchecked body.

A JSON body is a program for your decoder, and the decoder builds whatever the body asks for. Ask for a million-element array and you get a million-element slice. Ask for ten thousand nested arrays and you get ten thousand stack frames' worth of parsing. Nothing about this is a bug in encoding/json; the library does what it was designed to do, and what protects your service is the limits you put in front of it.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article is about those limits for a Go JSON API: how big the body may be, how deep the structure may nest, how many elements a container may hold, and what each one costs. It belongs next to strict decoding, the security checklist and request validation. The experiments ran on Go 1.25.6.
Three different limits
They protect against different things, and none of them substitutes for the others.
| Limit | What it prevents | Where it goes |
|---|---|---|
| Body size in bytes | Memory and bandwidth use before parsing | http.MaxBytesReader |
| Nesting depth | Stack and CPU use from pathological structure | A pre-check on the token stream |
| Elements per container | Memory amplification from a small body | The same pre-check, or validation after decoding |
Add a fourth for good measure: time. A server with ReadTimeout and a handler context that has a deadline bounds the case where the limits above are respected and the client sends bytes very slowly.
Limit 1: body size
http.MaxBytesReader(w, r.Body, n) wraps the body so that reading more than n bytes fails with a specific error, and it tells the server to close the connection after the response. It must be applied before you read. In the lab's handler the limit is 1 MiB, and the 2 MiB string request is answered with 413 (Request Entity Too Large) and never reaches the JSON parser.
Choose the limit per route. A login endpoint needs a few hundred bytes; a bulk import might need megabytes. A global 1 MiB default with explicit overrides for the exceptions is safer than the other way round. Note that checking Content-Length is not enough. Clients can lie, and chunked requests have no length, so you need the reader-level limit.
Remember the lesson from the presigned upload article: after io.ReadAll hits the limit it returns an error, and the data it read so far is not the whole body. Look at the error, not at the length.
Limit 2: nesting depth
The standard library has a built-in limit, and it is worth knowing how generous it is. The lab decodes 10,000 nested arrays successfully and fails at 10,001 with exceeded max depth. That limit exists to protect the parser from overflowing the stack. It is not a defence for your service: a legitimate document does not nest ten thousand levels, and the 10,000 you are allowed to build cost time and memory you did not budget for.
Pick a depth that matches your data. A typical API payload nests four or five levels. A limit of 16 or 32 is generous. The check needs to happen before you decode into values, because by then the memory is spent.
Limit 3: elements per container
A request with a small body can still describe a large structure. A million 1, entries is two megabytes, and the next section shows what that costs. Limit the number of elements in any array and keys in any object, and also the number of items you will accept for a field that you iterate in a loop doing work for each, such as a list of ids to look up.
A checker that does not build values
The lab's check function reads the body as tokens with json.Decoder.Token. It tracks the current depth and the count of elements in each open container, and fails as soon as either limit is passed. It allocates only the small stack of counters, regardless of how big the input is, because tokens are consumed one at a time and nothing is retained.
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"time"
)
var ErrLimit = errors.New("json limit exceeded")
// check walks the token stream without building values, so it costs a few allocations however large the input is.
func check(r io.Reader, maxDepth, maxItems int) error {
dec := json.NewDecoder(r)
depth := 0
items := make([]int, 0, 8) // elements seen in each open container
for {
tok, err := dec.Token()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if d, ok := tok.(json.Delim); ok {
switch d {
case '{', '[':
depth++
if depth > maxDepth {
return fmt.Errorf("%w: nesting deeper than %d", ErrLimit, maxDepth)
}
if len(items) > 0 {
items[len(items)-1]++
}
items = append(items, 0)
default:
depth--
items = items[:len(items)-1]
}
continue
}
if len(items) > 0 {
items[len(items)-1]++
if items[len(items)-1] > maxItems {
return fmt.Errorf("%w: more than %d elements in one container", ErrLimit, maxItems)
}
}
}
}
func nested(depth int) string { return strings.Repeat("[", depth) + strings.Repeat("]", depth) }
func array(n int) string { return "[" + strings.TrimSuffix(strings.Repeat("1,", n), ",") + "]" }
func handler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) // 1 MiB
if err != nil {
http.Error(w, `{"error":"body too large"}`, http.StatusRequestEntityTooLarge)
return
}
if err := check(bytes.NewReader(body), 16, 1000); err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusUnprocessableEntity)
return
}
var v any
if err := json.Unmarshal(body, &v); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return
}
w.Write([]byte(`{"ok":true}`))
}
func alloc() uint64 {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return m.TotalAlloc
}
func main() {
srv := httptest.NewServer(http.HandlerFunc(handler))
defer srv.Close()
post := func(label, body string) {
resp, _ := http.Post(srv.URL, "application/json", strings.NewReader(body))
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("%-34s %4d bytes -> %d %s\n", label, len(body), resp.StatusCode, strings.TrimSpace(string(b)))
}
post("ordinary object", `{"name":"Ada","tags":["a","b"]}`)
post("nesting 10 deep", nested(10))
post("nesting 40 deep", nested(40))
post("array of 1000 numbers", array(1000))
post("array of 1001 numbers", array(1001))
post("2 MiB string", `"`+strings.Repeat("x", 2<<20)+`"`)
// What the standard library does with no help from you.
var v any
fmt.Println("stdlib, 10000 deep: ", json.Unmarshal([]byte(nested(10000)), &v))
err := json.Unmarshal([]byte(nested(10001)), &v)
fmt.Println("stdlib, 10001 deep: ", err)
// What an unchecked body costs in memory: the input is 1 MB of "1," and decoding builds an []any of float64s.
body := []byte(array(500_000))
before := alloc()
start := time.Now()
json.Unmarshal(body, &v)
cost := float64(alloc()-before) / (1 << 20)
fmt.Printf("decoding a %.1f MB array of numbers into any allocated about %.0f MB (%.0fx the input), in under a second: %v\n",
float64(len(body))/(1<<20), cost, cost/(float64(len(body))/(1<<20)), time.Since(start) < time.Second)
}ordinary object 31 bytes -> 200 {"ok":true}
nesting 10 deep 20 bytes -> 200 {"ok":true}
nesting 40 deep 80 bytes -> 422 {"error":"json limit exceeded: nesting deeper than 16"}
array of 1000 numbers 2001 bytes -> 200 {"ok":true}
array of 1001 numbers 2003 bytes -> 422 {"error":"json limit exceeded: more than 1000 elements in one container"}
2 MiB string 2097154 bytes -> 413 {"error":"body too large"}
stdlib, 10000 deep: <nil>
stdlib, 10001 deep: invalid character '[' exceeded max depth
decoding a 1.0 MB array of numbers into any allocated about 46 MB (49x the input), in under a second: trueThe handler does three things in order: cap the body, run the check, and only then decode into a value. The results:
- The ordinary object and 10-deep nesting pass with 200.
- 40 levels of nesting is refused with 422 and a message naming the limit. The body was only 80 bytes. A size limit alone could never have caught it.
- An array of exactly 1000 numbers passes and 1001 is refused. Boundaries are worth testing precisely, as is done here.
- The 2 MiB string is refused with 413 by the reader before any parsing.
The standard library lines show the built-in depth cap. 10,000 levels decode with no error, and 10,001 returns invalid character '[' exceeded max depth. Leaving that as your only defence means an attacker gets ten thousand levels of recursion per request for free.
Why a size limit is not a memory limit
The last line of output is the one that surprises people. The program builds a body that is 500,000 copies of 1, in an array, about 1.0 MB, which is under the 1 MiB limit the handler uses. It decodes that into any and measures the allocation. The decoder allocated about 46 MB, roughly 49 times the size of the input.
Where does that come from? Decoding into any produces a []any, and every element is an interface holding a float64, which needs 16 bytes for the interface plus a heap allocation for the number, and the slice grows by doubling as it fills, leaving garbage behind. A body that is two bytes per element becomes dozens of bytes per element in memory. With 50 requests in flight, a 1 MB limit stops being a 50 MB limit and turns into a 2 GB one.
Typed decoding is somewhat kinder: a []int takes 8 bytes per element. But the principle holds. The amplification factor between wire size and memory is something you measure for your own types, and the element-count limit is what keeps it bounded. Pair the size limit with the count limit, and size the limit on the number of requests in flight too.
Other places JSON bites
Numbers. 1e999999 and numbers with thousands of digits. Decoding into float64 returns an error for out-of-range values; decoding into json.Number or *big.Int keeps the text and does the work later. Know which your struct uses.
Duplicate keys. {"role":"user","role":"admin"}. Go's decoder takes the last value. Another component in the chain (a gateway, a validator in a different language) may take the first, which is a classic request-smuggling shape. Decide where validation happens and validate the same bytes you act on. JSON v2 rejects duplicate names by default.
Long strings and keys. A single string of 900 KB inside a field you wanted to be 20 characters. Validate lengths after decoding, in characters, and cap them.
Unicode. Invalid UTF-8 is replaced silently by the decoder. Normalise or reject where it matters, for identifiers and anything compared for equality.
Unknown fields. Reject them, as covered in DisallowUnknownFields.
Streaming big inputs. If you need to accept large lists legitimately (an import with 100,000 rows), do not decode the whole array. Use the token stream to read one element at a time, validate and process each, and keep memory flat. The streaming JSON article shows the pattern, and then the limits become per element and a total count.
Putting it into the middleware stack
Make this a piece of middleware or a helper that every JSON endpoint calls, not a copy of the code in each handler. A helper like decodeJSON(w, r, &dst, Limits{Bytes: 1<<20, Depth: 16, Items: 1000}) does the reader cap, the token check and the decode, and returns a single typed error that the handler maps to 413, 422 or 400. Defaults live in one place, overrides are explicit and visible in review, and a new endpoint inherits safe behaviour instead of having to remember it.
Reading the body twice (once for the check and once for the decode) costs a little CPU and a buffer of the body size. With a 1 MiB cap that is cheap and keeps the code simple. If it matters, wrap a TeeReader or decode once into a typed struct and enforce limits with a custom UnmarshalJSON on the fields that hold collections.
Testing
Write tests with the boundary values from the lab: just under and just over each limit. Add a fuzz test for the decoder helper with testing.F, seeded with deeply nested arrays, long strings and truncated documents, asserting that it returns an error or a value but never panics and never takes more than a set time. Check that your limits are the ones the server really enforces by sending real HTTP requests as the lab does, because middleware ordering mistakes are the way these controls silently turn off.
The reference for the stream API used here is the encoding/json Decoder documentation, and the OWASP guidance on unrestricted resource consumption places these limits among the basic API controls.


