JSONL Performance
Benchmarks, metrics, and optimization strategies for high-performance JSONL processing
Performance Advantages
O(1) Memory
Memory is bounded by your largest single record plus the read buffer, not by file size. A 100GB file costs no more than a 100MB one.
Instant Start
The first record is complete at the first newline, so work starts before the rest of the file is read. Cost to reach it does not grow with file size.
Append Speed
Add records in O(1) time. No file rewriting required unlike JSON arrays.
Parse Speed Benchmarks
How to read these numbers:
- Every table below is a single-machine measurement on one MacBook Pro M1, wall-clock timed by the benchmark code shown alongside it.
- Each row is one recorded run, not an average over repeated trials. Treat small gaps between rows as noise.
- The "recorded environment" line on each table names the exact runtime the figures were measured on. Those runtimes are now several releases old, and the figures have not been re-measured: as of July 2026 the current releases are Python 3.14, Node.js 24 LTS, and Go 1.26.
- Because of that, read the ratio between rows (the point being made) rather than the absolute milliseconds. Absolute times and throughput belong to this hardware and this runtime version only. Re-run the code on your own machine and your own data before sizing anything.
Python: JSON vs JSONL Parsing
Recorded environment: 100,000 records, ~50MB file, Python 3.11, MacBook Pro M1. Python 3.11 is the interpreter these figures were measured on, not a recommendation - they have not been re-measured on Python 3.14.
| Format | Library | Parse Time | Memory Peak | Time to First Record |
|---|---|---|---|---|
| JSON Array | json (stdlib) | 1,450ms | 280MB | 1,450ms |
| JSONL | json (stdlib) | 1,380ms | 8MB | <1ms |
| JSONL | orjson | 410ms | 8MB | <1ms |
| JSONL.gz | orjson + gzip | 680ms | 12MB | 15ms |
Key takeaways:
- JSONL held about 35x less memory in this run: 280MB peak against 8MB
- orjson parsed about 3.4x faster than the stdlib here: 1,380ms against 410ms
- Compressed JSONL still beat the uncompressed JSON array: 680ms against 1,450ms
# Benchmark code
import json
import orjson
import gzip
import time
import tracemalloc
def benchmark_json_array():
tracemalloc.start()
start = time.time()
with open('data.json', 'r') as f:
data = json.load(f) # Load entire array
for record in data:
process(record)
elapsed = time.time() - start
peak = tracemalloc.get_traced_memory()[1] / 1024 / 1024
tracemalloc.stop()
print(f"JSON Array: {elapsed*1000:.0f}ms, {peak:.0f}MB")
def benchmark_jsonl_orjson():
tracemalloc.start()
start = time.time()
with open('data.jsonl', 'rb') as f:
for line in f:
record = orjson.loads(line)
process(record)
elapsed = time.time() - start
peak = tracemalloc.get_traced_memory()[1] / 1024 / 1024
tracemalloc.stop()
print(f"JSONL (orjson): {elapsed*1000:.0f}ms, {peak:.0f}MB")
JavaScript/Node.js: Streaming Performance
Recorded environment: 100,000 records, ~50MB file, Node.js 20.x, MacBook Pro M1. Node.js 20.x is the runtime these figures were measured on, not a recommendation - they have not been re-measured on Node.js 24 LTS.
| Approach | Parse Time | Memory Peak | Throughput |
|---|---|---|---|
| JSON.parse() entire file | 2,100ms | 350MB | 47 rec/ms |
| readline + JSON.parse() | 1,820ms | 12MB | 55 rec/ms |
| ndjson stream | 1,240ms | 10MB | 81 rec/ms |
// Fast JSONL streaming with ndjson
const fs = require('fs');
const ndjson = require('ndjson');
const stream = fs.createReadStream('data.jsonl')
.pipe(ndjson.parse())
.on('data', (record) => {
// Process each record as it arrives
process(record);
})
.on('end', () => {
console.log('Complete');
});
Go: Concurrency Benchmark
Recorded environment: 1,000,000 records, ~500MB file, Go 1.21, MacBook Pro M1, 10 cores. Go 1.21 is the toolchain these figures were measured on, not a recommendation - they have not been re-measured on Go 1.26.
| Approach | Parse Time | Throughput | CPU Usage |
|---|---|---|---|
| Single-threaded | 3,200ms | 313 rec/ms | ~100% (1 core) |
| 10 goroutines | 420ms | 2,381 rec/ms | ~900% (9 cores) |
About 7.6x faster in this run: 3,200ms against 420ms across 10 goroutines. JSONL's line-based format is trivially parallelizable, since a worker can be handed any chunk of whole lines without parsing what came before it.
// Go: Parallel JSONL processing
package main
import (
"bufio"
"encoding/json"
"os"
"sync"
)
func processChunk(lines [][]byte, wg *sync.WaitGroup) {
defer wg.Done()
for _, line := range lines {
var record map[string]interface{}
json.Unmarshal(line, &record)
// Process record...
}
}
func main() {
file, _ := os.Open("data.jsonl")
defer file.Close()
scanner := bufio.NewScanner(file)
const chunkSize = 10000
var chunk [][]byte
var wg sync.WaitGroup
for scanner.Scan() {
chunk = append(chunk, append([]byte(nil), scanner.Bytes()...))
if len(chunk) >= chunkSize {
wg.Add(1)
go processChunk(chunk, &wg)
chunk = nil
}
}
if len(chunk) > 0 {
wg.Add(1)
go processChunk(chunk, &wg)
}
wg.Wait()
}
Memory Efficiency
Memory Usage: JSON Array vs JSONL
These rows are a projection, not a set of measurements. Nobody ran a 10GB file to fill in this table. The JSON array column applies the 5-6x in-memory overhead rule of thumb to each file size; the JSONL column assumes flat streaming memory. The one place this page actually measured it, the Python benchmark above, is the anchor: a ~50MB file peaked at 280MB as a JSON array, which is about 5.6x, against 8MB streamed as JSONL.
| File Size | Records | Projected JSON Array Memory | Projected JSONL Memory | Approx. Savings |
|---|---|---|---|---|
| 10 MB | 20,000 | ~60 MB | ~5 MB | ~90% |
| 100 MB | 200,000 | ~550 MB | ~8 MB | ~98% |
| 1 GB | 2,000,000 | ~5.5 GB | ~10 MB | >99% |
| 10 GB | 20,000,000 | ~55 GB (OOM) | ~12 MB | >99% |
Why the difference?
- JSON arrays load entire structure into memory (all records + array overhead)
- JSONL streams one record at a time, discarding after processing
- JSON parser allocates temporary objects during deserialization
- Memory overhead for a parsed JSON array commonly lands around 5-6x file size, which is the multiplier this table projects with. It is a rule of thumb, not a constant - it moves with record shape, key length, and how your runtime represents strings and numbers. Measure yours with the profiler below.
Real-World Memory Profiling
Measure memory usage in your own applications:
# Python: Profile memory usage
import tracemalloc
import json
tracemalloc.start()
# Your processing code here
with open('data.jsonl', 'r') as f:
for line in f:
obj = json.loads(line)
process(obj)
current, peak = tracemalloc.get_traced_memory()
print(f"Current: {current / 1024 / 1024:.1f} MB")
print(f"Peak: {peak / 1024 / 1024:.1f} MB")
tracemalloc.stop()
Compression Benchmarks
Compression Ratios
JSONL compresses extremely well because every line repeats the same field names. The sizes below are a single measurement of one 100.0 MB JSONL file of flat, one-record-per-line user objects, each compressed once with the exact command shown:
gzip -6 -k data.jsonl # -> data.jsonl.gz
gzip -9 -k data.jsonl # maximum deflate level
bzip2 -9 -k data.jsonl # -9 is bzip2's default
xz -6 -k data.jsonl # -6 is xz's default
zstd -3 -k data.jsonl # -3 is zstd's default
| Compression | Compressed Size | Size Reduction | Compress Speed | Decompress Speed |
|---|---|---|---|---|
| None | 100.0 MB | - | - | - |
| gzip -6 | 12.4 MB | 88% | Moderate | Fast |
| gzip -9 | 11.8 MB | 88% | Slow | Fast |
| bzip2 -9 | 8.9 MB | 91% | Slow | Slow |
| xz -6 | 7.2 MB | 93% | Slowest | Moderate |
| zstd -3 | 11.2 MB | 89% | Fastest | Fastest |
What was and was not measured:
- The sizes are one run over one file, rounded to 0.1 MB. Size reduction is derived from them and rounded to whole percent - the extra decimal place was false precision on a single sample.
- Your own ratios will differ, sometimes a lot. They depend on how much of each line is repeated field names and how low the cardinality of the values is. Wide records with short values compress best.
- The speed columns are deliberately ordinal. The original timings were seconds measured on one laptop with tool builds that were never recorded, which is not a number anyone else can reproduce. Compression ratio at a fixed level is stable across tool versions; speed is not.
- Reproduce it yourself with
timeand the commands above on your real data. That takes about a minute and is worth more than any table here.
Recommendations:
- gzip -6 - Best balance of speed and compression, and readable by everything (default choice)
- zstd - Best for real-time pipelines. The zstd project's own published benchmarks show it compressing several times faster than zlib at a comparable ratio
- xz - Best for long-term archival (smallest size, slowest to write)
Streaming Compressed JSONL
Process compressed JSONL without decompressing entire file first:
Python
import gzip
import json
# Stream-decompress and process
with gzip.open('data.jsonl.gz', 'rt') as f:
for line in f:
obj = json.loads(line)
# Memory stays constant!
Node.js
const fs = require('fs');
const zlib = require('zlib');
const readline = require('readline');
// readline is not a writable stream, so you cannot .pipe() into it.
// Hand the gunzip stream to createInterface as its "input" instead.
async function main() {
const rl = readline.createInterface({
input: fs.createReadStream('data.jsonl.gz').pipe(zlib.createGunzip()),
crlfDelay: Infinity
});
for await (const line of rl) {
if (line.trim() === '') continue;
const obj = JSON.parse(line);
// Process...
}
}
main().catch(err => {
console.error(err);
process.exitCode = 1;
});
Command Line
# Decompress and process on-the-fly with jq
zcat data.jsonl.gz | jq '.name'
# Decompress, filter, compress again
zcat input.jsonl.gz | grep '"status":"active"' | gzip > filtered.jsonl.gz
Performance impact: Decompression costs CPU time. In the Python table above - the only place on this page where both were actually timed on the same machine and file - adding gzip took the orjson run from 410ms to 680ms. Whether that is a net loss depends on your storage: reading roughly one eighth as many bytes usually more than pays for the CPU, and the slower the disk or network, the more compression wins.
Streaming Efficiency
Time to First Record
How quickly can you start processing? This one is a property of the two formats, not a benchmark result, so it needs no hardware to state:
JSON Array
The whole file
A conforming parser cannot hand you record one until it has reached the closing bracket, because the document is not valid until then. Cost grows with file size.
JSONL
One line
Each line is a complete document on its own, so record one is ready at the first newline. Cost does not grow with file size.
What that looked like when measured: in the Python benchmark above, on a ~50MB file, the JSON array run reached its first record after 1,450ms and the JSONL run reached it faster than the 1ms timer could resolve. The gap widens with file size, since one side scales with the file and the other does not.
This box used to claim a "12,000x faster startup" on a 1GB file. That figure has been removed: no benchmark on this page produced it, and dividing by a time that only registers as "under 1ms" yields whatever multiplier you want, since the divisor is a timer floor rather than a measurement. The direction is solid and the ratio is not.
Processing Throughput
This table is not a separate benchmark. Every row is the same measurement from the Parse Speed Benchmarks section above, restated as records per second so the approaches line up in one place. The "Derived from" column shows the arithmetic, so you can check each figure against the table it came from. Figures are rounded to two significant digits.
| Language | Library | Approx. records/sec | Derived from |
|---|---|---|---|
| Python | json (stdlib) | ~72,000 | Python table: 100,000 records in 1,380ms. Baseline for the two Python rows |
| Python | orjson | ~240,000 | Python table: 100,000 records in 410ms. About 3.4x the stdlib on that run |
| Node.js | readline + JSON.parse() | ~55,000 | Node.js table: 55 records/ms, single-threaded |
| Node.js | ndjson | ~81,000 | Node.js table: 81 records/ms, streaming parser |
| Go | encoding/json | ~310,000 | Go table: 1,000,000 records in 3,200ms, single goroutine |
| Go | encoding/json (parallel) | ~2,400,000 | Go table: 1,000,000 records in 420ms across 10 goroutines |
Do not read this as a language shootout:
- The Go rows were measured on a different file from the Python and Node.js rows - 1,000,000 records and ~500MB, against 100,000 records and ~50MB. Different record shapes parse at different speeds, so comparing Go's number directly against Python's is not apples to apples.
- The comparison that does hold is within a language, where the file and machine were identical: stdlib against orjson, readline against ndjson, one goroutine against ten.
- All the caveats from the Parse Speed Benchmarks section carry over - one machine, one run per row, runtime versions now several releases old.
- Two rows used to appear here, Rust with serde_json and command-line jq, carrying specific records/sec figures. No benchmark on this page produced them and no source was recorded, so they have been removed rather than dressed up. Directionally, a compiled Rust parser using serde_json belongs in the same band as Go's encoding/json rather than Python's stdlib, and jq is slower than a purpose-written parser in any of these languages because it is a general-purpose expression engine that builds a full value tree for every line. Those are expectations, not measurements. Benchmark them on your own data if the number matters.
Network Streaming Performance
Serving an HTTP endpoint as JSONL rather than one JSON array changes when the client can start, not how many bytes cross the wire. The three panels below are the shape of the result, not timings - see the note underneath.
JSON Array
Last byte
Nothing is parseable until the response completes, so the client sits idle for the whole transfer
JSONL Streaming
First line
Record one is usable as soon as it arrives, while the rest is still in flight
Total Transfer
About equal
Streaming moves no fewer bytes. It changes when work can start, not how long the wire is busy
Key benefit: time to first useful result drops from the full transfer duration to roughly one round trip plus one record, while total transfer stays about the same. On a slow connection or a large response that is the difference between a usable interface and a spinner.
These panels previously showed 18.2s, 0.15s and 18.5s, and claimed perceived performance was "120x better". Those numbers are gone. No benchmark on this page produced them, and network timings depend on bandwidth, latency, response size, and server behavior - four things this page never specified, any one of which moves the result by an order of magnitude. The ordering above holds for any of them; the specific seconds did not belong to anything.
Real-World Performance Scenarios
These are worked illustrations, not case studies. No client, no production system, and no measurement sits behind them. Memory figures are projected from the same 5-6x overhead rule of thumb used in the Memory Efficiency section, and compressed sizes from the ratios in the Compression section. Everything else is stated as a direction rather than a duration, because this page has no basis for putting a stopwatch on someone else's pipeline. Timings that used to appear here have been removed.
Scenario 1: Log Processing Pipeline
Task: Process 50GB daily application logs (25M records), extract errors, write to database
JSON Array Approach
- Memory required: roughly 250-300GB (50GB at the 5-6x overhead above)
- Peak memory arrives before the first row is written
- Result: out of memory on any machine you would reasonably rent for this
JSONL Approach
- Memory required: flat, bounded by one record plus buffers
- Database writes begin with the first line, not after the last
- Result: completes, and the same code runs on 5GB or 500GB
Compressed, that 50GB of logs would land near 6GB, applying the ~88% gzip -6 reduction measured in the Compression section to a similar record shape. Log lines repeat field names heavily, so they tend to sit at the favorable end of that range.
Scenario 2: ML Training Data Preparation
Task: Transform 10M training examples for GPT fine-tuning
In-Memory Processing
- Three serial passes: load, transform, write
- Peak memory scales with the dataset, so the instance must be sized for the whole corpus
- Nothing is written until the transform finishes
- A failure at 90% loses the entire run
Streaming Pipeline
- One pass: read, transform, and write overlap
- Peak memory is flat and independent of example count
- Output grows continuously, so progress is observable
- A failure is resumable from the last line written
Expect the streaming version to finish sooner, because the write overlaps the transform instead of following it, and to run on a general-purpose instance rather than a memory-optimized one. How much sooner depends on your transform cost and disk, so this is a direction, not a multiplier - the "2.2x faster, 225x less memory" that used to sit here was not measured.
Scenario 3: Real-Time Analytics Dashboard
Task: Display live events in web dashboard as they arrive
Polling JSON Endpoint
- Poll every 5 seconds
- Return full array (growing)
- Client re-downloads all data
- Latency: 5+ seconds
JSONL Streaming
- Server-sent events (JSONL)
- Push events as they occur
- Client processes incrementally
- Latency: bounded by network round trip, not a poll interval
Both wins follow from the design rather than from a benchmark. Latency: a 5 second poll means an event can sit up to 5 seconds before the client asks for it, while a pushed event leaves as it happens. Bandwidth: polling re-sends the entire array every cycle, so bytes scale with dataset size multiplied by poll frequency, whereas each streamed event crosses the wire exactly once. The saving therefore grows with how large the dataset is and how often you poll - which is why no single percentage is quoted here.
Performance Optimization Tips
Do
- Use fast JSON libraries (orjson, simdjson, jsoniter)
- Stream large files instead of loading into memory
- Compress with gzip or zstd for storage
- Parallelize processing across multiple cores
- Use buffered I/O with large buffer sizes (1MB+)
- Filter before parsing when possible
- Build offset indexes for random access
- Partition large datasets by date/category
Don't
- Load entire file into array before processing
- Use unbuffered file I/O
- Parse every line if you only need subset
- Store uncompressed JSONL in production
- Read compressed files multiple times (cache if needed)
- Ignore memory profiling in production
- Process multi-GB files on single thread
- Use JSONL for tiny datasets (<1MB)