Guide

How to test JSON parsers: numbers, Unicode and huge files

Big integers, Unicode, nesting, NDJSON, invalid and empty input, and 100 MB files — the JSON cases that break parsers, with sample files for each.

JSON has a small grammar, so it’s easy to assume every parser handles it the same way. They don’t. Implementations disagree on large numbers, on what counts as valid input, on how deep nesting can go and on how much memory a file is allowed to use. These are the cases worth testing, whether you’re writing a parser, choosing a library or hardening an import endpoint.

1. Every type, and the awkward values

Start with JSON with every data type. It is one small object containing strings with Unicode and escapes, integers, negatives, a float, an exponent (6.02e+23), true, false, null, an empty string, an empty array, an empty object, a mixed array, four levels of nesting and an ISO date string.

The most important value in it is "big_integer": 9007199254740993, which is 2⁵³ + 1. JavaScript numbers are IEEE 754 doubles, so they can’t represent it:

Shell
curl -s https://files.samplefiles.com/data/json/sample-types.json \
  | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).big_integer))'
# 9007199254740992  (off by one, silently)

This is a real problem for IDs from databases and APIs: Twitter famously added id_str fields for this reason. Your options:

  • JavaScript: in Node 21+ and current Chrome, JSON.parse passes the reviver a third context argument whose source is the raw text, so you can turn large numbers into BigInt. Libraries such as json-bigint do the same on older runtimes.
  • Python: json parses integers with arbitrary precision. Floats become doubles unless you pass parse_float=decimal.Decimal.
  • Go: use json.Decoder.UseNumber() instead of decoding into float64.

Also check round-tripping. The array holds 3.0, which Python reads as a float and JavaScript writes back out as 3. If your system signs or hashes JSON, a parse-and-reserialize step changes the bytes.

2. Unicode and escapes

The unicode field holds "Grüße, 世界, مرحبا, 🌍": Latin with diacritics, CJK, right-to-left Arabic and an emoji outside the Basic Multilingual Plane. In JavaScript and Java, that emoji is two UTF-16 code units, so length, substring and truncation can split it in half. The escaped field checks \", \\, \n and \t.

RFC 8259 requires UTF-8 for JSON exchanged between systems. If your parser reads bytes, test what it does with a byte-order mark: Python’s json.loads rejects a decoded string that starts with one and tells you to use utf-8-sig. For heavier string testing, the Unicode stress-test text has ZWJ emoji sequences, combining marks, zero-width characters and homoglyphs that you can embed in JSON fixtures.

3. Nesting and structure

Nested orders has 100 orders, each with a nested customer, address and array of line items. It is the shape most real APIs return, and a good test for code that flattens JSON into tables.

Deep nesting is also a resource problem. Recursive parsers run out of stack: Python raises RecursionError at roughly a thousand levels by default, and some C libraries crash outright. We don’t host pathological files, so generate one in your test suite and confirm you get a clean error:

Shell
python3 -c "print('[' * 100000 + ']' * 100000)" > deep.json

Set an explicit depth limit if your parser offers one, and a request body size limit in front of it either way.

4. Invalid and empty input

Almost-JSON. Invalid JSON uses single quotes, an unquoted key and trailing commas. These are things people write by hand and JavaScript accepts in object literals, but strict JSON parsers reject them. Compare it with the JSON5 config, which uses the same features plus comments and hex numbers on purpose and parses with a JSON5 library. Make sure your error message includes the line and column:

Python
import json

try:
    json.load(open("invalid.json"))
except json.JSONDecodeError as e:
    print(f"Line {e.lineno}, column {e.colno}: {e.msg}")

Empty input. A zero-byte .json file is not valid JSON. JSON.parse('') throws “Unexpected end of JSON input”, and Python reports “Expecting value: line 1 column 1 (char 0)”. Report it as “file is empty”, not as a syntax error. By contrast, the empty array and empty object are valid documents. An import that gets [] should succeed with zero records, not fail.

Wrong file. A JSON array named .csv is a common export mistake. JSON has no magic bytes, so decide whether to sniff for a leading [ or {, or to trust the extension and fail clearly.

5. NDJSON and JSON Lines

Newline-delimited JSON puts one complete value on each line. It is what log shippers, BigQuery exports and LLM fine-tuning APIs use, because you can process it one line at a time in constant memory.

Parse line by line, skip blank lines, and report the line number of a bad record instead of failing the whole file:

Python
import json

with open("sample-100k-records.ndjson", encoding="utf-8") as f:
    for n, line in enumerate(f, 1):
        if not line.strip():
            continue
        try:
            record = json.loads(line)
        except json.JSONDecodeError as e:
            print(f"Skipping line {n}: {e.msg}")

Also test a file with no final newline, one with CRLF line endings, and one with a truncated last line, which is what you get when a writer crashes mid-record.

6. Size: when parse() stops working

JSON.parse and json.load read the whole document into memory, and the parsed objects take several times the file size. Measure with real sizes:

Shell
/usr/bin/time -l node -e 'JSON.parse(require("fs").readFileSync("sample-100mb.json","utf8"))'   # macOS; use -v on Linux

Past a few hundred megabytes, switch to a streaming parser that emits one record at a time: ijson in Python, stream-json in Node, or jq --stream in the shell. Better still, ask producers for NDJSON.

For choosing between JSON, CSV and Parquet in the first place, see CSV vs JSON vs Parquet. All the files above are on the JSON parsing use case.

Sample files for this guide