Streaming JSONL Parser Without Loading the File
When the file is 8GB you cannot json.load it. Here is the generator-based JSONL reader I ship in every data pipeline, plus the malformed-line policy that has saved me twice.
By @clarachoi
December 21, 2025
·
Updated August 11, 2026
1,158 views
5
Rate
from __future__ import annotations
import io
import json
from typing import Iterable, Iterator
# JSONL = one JSON object per line. The whole point of the format is that you
# never need the full file in memory. So: open, iterate, json.loads each line.
def iter_jsonl(stream: Iterable[str]) -> Iterator[dict]:
for line in stream:
line = line.strip()
if not line:
continue
yield json.loads(line)
# Demo with an in-memory file. In real code: open('events.jsonl', 'r').
sample = '\n'.join([
'{"id": 1, "event": "login", "user": "alice"}',
'{"id": 2, "event": "click", "user": "alice", "path": "/home"}',
'', # blank line, valid in JSONL streams
'{"id": 3, "event": "logout", "user": "alice"}',
])
for row in iter_jsonl(io.StringIO(sample)):
print(row)The shape is a generator over a line iterator, which keeps memory at O(1) regardless of file size. I take Iterable[str] rather than a path because that lets me feed the same parser a real file, an io.StringIO, a network stream, or a gzip.open handle. Skipping blank lines is required by the loose JSONL format used in the wild; producers like Cloud Logging emit trailing blank lines all the time. The eight lines here are enough for a clean dataset.
from __future__ import annotations
import io
import json
from typing import Iterable, Iterator, Literal, Tuple
# In production the file is never clean. A truncated upload, a control
# character mid-line, a half-flushed log shipper, all produce malformed JSON.
# Decide the policy explicitly instead of crashing the pipeline.
def iter_jsonl_safe(
stream: Iterable[str],
on_error: Literal['skip', 'fail', 'count'] = 'skip',
) -> Iterator[Tuple[int, dict]]:
bad = 0
for lineno, raw in enumerate(stream, start=1):
line = raw.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
if on_error == 'fail':
raise
bad += 1
continue
yield lineno, obj
if on_error == 'count' and bad:
# Reporting policy: log instead of raise so the pipeline finishes.
print(f'jsonl: skipped {bad} malformed lines')
sample = '\n'.join([
'{"id": 1, "ok": true}',
'{"id": 2, "ok":', # truncated, JSONDecodeError
'not-json-at-all', # garbage line
'{"id": 3, "ok": true}',
])
for lineno, row in iter_jsonl_safe(io.StringIO(sample), on_error='count'):
print(lineno, row)The first version dies on the first bad line, which is the right default for a small clean file but the wrong default for an 8GB log dump where one corrupt frame is expected. Yielding (lineno, obj) makes downstream errors traceable to the source byte range; it has saved me more than once when the bad rows turned out to come from a single misbehaving producer. The 'count' mode is what I ship: skip silently in the hot path, then emit one summary log line at the end. The 'fail' mode is for unit tests where any malformed input means a test bug.
from __future__ import annotations
import io
import json
from typing import Iterable, Iterator, List
# Most downstream sinks (a database insert, a Kafka producer, an HTTP API)
# want batches, not single rows. Keep memory bounded by yielding fixed-size
# chunks. The generator lets the consumer pull at its own pace, which gives
# you free backpressure: if the sink is slow, we do not pre-read the file.
def batched_jsonl(
stream: Iterable[str],
batch_size: int = 500,
) -> Iterator[List[dict]]:
buf: List[dict] = []
for raw in stream:
line = raw.strip()
if not line:
continue
try:
buf.append(json.loads(line))
except json.JSONDecodeError:
continue
if len(buf) >= batch_size:
yield buf
buf = []
if buf:
yield buf
sample = '\n'.join(json.dumps({'id': i, 'tag': 'x' if i % 2 else 'y'}) for i in range(11))
for i, batch in enumerate(batched_jsonl(io.StringIO(sample), batch_size=4)):
print(f'batch {i}: {len(batch)} rows first={batch[0]}')The generator-of-batches shape is what real ETL stages look like: each pull from the consumer drives one batch read from the source, so memory stays bounded at batch_size rows. The size 500 is a starting point; for Postgres INSERT ... VALUES (...), (...) I tune it to keep the SQL under the wire-protocol packet limit, and for Kafka producers I match the producer's linger.ms + batch.size settings. Always emit the trailing partial batch with the if buf: yield buf after the loop; forgetting it silently drops up to batch_size - 1 rows and is the bug I have shipped most often in this category.
