Parquet vs JSONL for Crawl Artifact Storage
Crawl artifacts get written once and read many times, usually by something that wants three fields out of forty. That asymmetry is what the format choice is really about — not storage cost, which compression largely settles, but whether a reader has to decompress every field of every row to answer a question about two of them. This page is part of Storing & Versioning Crawl Artifacts in Cloud Storage.
Comparison Table
| Dimension | Parquet | JSON Lines |
|---|---|---|
| Layout | Columnar, in row groups | Row-oriented, one JSON object per line |
| Reading a subset of columns | Reads only those columns | Reads and parses every field |
| Schema | Typed and embedded; enforced at write | None; each line stands alone |
| Schema drift | Fails loudly at write or read | Absorbed silently until something downstream breaks |
| Predicate pushdown | Row-group statistics let readers skip blocks | None; every line must be parsed |
| Compression | Per column, so like values compress together | Whole-file, over mixed types |
| Appending | Not incremental; a file is written whole | Natural — one line at a time |
| Shell tooling | Needs a library or a CLI | head, grep, jq, wc -l all work |
| Typical size, 1M rows | 130-180 MB | 310 MB gzipped, 2.1 GB raw |
| Best role | The analytical copy that gets queried | The transport copy the crawler writes |
The pattern most pipelines converge on
Rather than choosing, use each where it is strong: the crawler streams JSON Lines as it goes, and a conversion step produces the Parquet the scoring layer reads.
#!/usr/bin/env python3
# /opt/audit/artifacts/convert.py
# JSONL -> Parquet with an explicitly pinned schema, so type drift fails the
# conversion rather than landing silently in a partition.
from __future__ import annotations
import os
import sys
import pyarrow as pa
import pyarrow.json as paj
import pyarrow.parquet as pq
# The schema is declared, not inferred. Inference reads the first block and
# will happily type a column int64 today and double tomorrow, producing
# partitions that cannot be read together.
SCHEMA = pa.schema([
("url", pa.string()),
("final_url", pa.string()),
("status", pa.int32()),
("depth", pa.int16()),
("lcp_ms", pa.float64()),
("cls", pa.float64()),
("indexable", pa.bool_()),
("canonical_url", pa.string()),
("crawled_at", pa.timestamp("us", tz="UTC")),
])
def convert(src: str, dst: str, compression: str = "zstd") -> int:
table = paj.read_json(
src,
parse_options=paj.ParseOptions(explicit_schema=SCHEMA,
unexpected_field_behavior="error"),
)
if table.schema != SCHEMA:
sys.exit(f"FAIL: schema mismatch in {src}\n{table.schema}")
pq.write_table(table, dst, compression=compression,
use_dictionary=["status", "canonical_url"],
row_group_size=100_000)
return table.num_rows
if __name__ == "__main__":
n = convert(sys.argv[1], sys.argv[2])
print(f"converted {n:,} rows -> {sys.argv[2]}")
The decisions that matter:
explicit_schemarather than inference. Inference reads only the first block, so a column that is integer for the first hundred thousand rows and floating point afterwards produces a file whose type depends on batch ordering. Pinning it turns that into a conversion failure with a clear message.unexpected_field_behavior="error". A new field appearing in the crawler output is a schema change and should be a deliberate one. Silently ignoring it means the field never reaches the analytical layer and nobody notices for a quarter.use_dictionaryon low-cardinality columns. Status codes and canonical URLs repeat heavily; dictionary encoding is where most of the size advantage over gzipped JSONL actually comes from.row_group_size=100_000. Row groups are the unit a reader can skip. Too large and predicate pushdown stops helping; too small and the per-group metadata overhead grows.
Verification
set -euo pipefail
SRC=/data/crawl/2026-07-31/pages.jsonl
DST=/data/crawl/2026-07-31/pages.parquet
python3 -m audit.artifacts.convert "$SRC" "$DST"
# 1. Row counts match exactly.
JSONL_ROWS=$(wc -l < "$SRC")
PARQ_ROWS=$(python3 -c "import pyarrow.parquet as pq,sys;print(pq.read_metadata(sys.argv[1]).num_rows)" "$DST")
[ "$JSONL_ROWS" -eq "$PARQ_ROWS" ] || { echo "FAIL: $JSONL_ROWS vs $PARQ_ROWS"; exit 1; }
echo "PASS: $PARQ_ROWS rows preserved"
# 2. The schema matches the pinned one, field for field.
python3 -c "
import pyarrow.parquet as pq, sys
from audit.artifacts.convert import SCHEMA
got = pq.read_schema(sys.argv[1])
assert got == SCHEMA, f'schema drift:\n{got}'
print('PASS: schema matches the pinned definition')" "$DST"
# 3. A columnar read touches only the columns asked for.
python3 -c "
import pyarrow.parquet as pq, sys
t = pq.read_table(sys.argv[1], columns=['status','lcp_ms'])
assert t.num_columns == 2, t.schema
print(f'PASS: read 2 of {len(pq.read_schema(sys.argv[1]))} columns')" "$DST"
Expected output is three PASS lines. The schema assertion is the one to keep in CI permanently — it is the only cheap defence against a type change that will not surface until a query spans two partitions written months apart.
Failure Modes
A read spanning several months fails on a type mismatch
One partition has a column as int64 and another as double, because an early conversion used inference. Rewrite the affected partitions against the pinned schema rather than casting at read time; a cast at read time has to be repeated by every consumer forever.
The Parquet file is larger than the gzipped JSONL
Almost always a row group size far smaller than the row count, so per-group metadata dominates, or dictionary encoding disabled on the columns that would benefit most. Check with pq.read_metadata(path).row_group(0) and compare the number of row groups against the row count.
The crawler crashes and the batch is gone
Parquet is written whole, so rows buffered toward an incomplete row group are lost. This is exactly why the crawler should write JSON Lines and the conversion should run after the crawl exits cleanly — the transport format needs to be append-safe, and the analytical format does not. Retention for both is covered in the parent guide, and only the columnar copy needs to survive long enough for drift-detection queries to run against it.
FAQ
Why not write Parquet directly from the crawler?
Because Parquet is written whole. Rows accumulate toward a complete row group, and if the crawler process dies before the group is flushed, every buffered row is gone. On a long crawl that can be tens of thousands of records. JSON Lines appends one record at a time, so a crash costs at most the record in flight, which is why the transport format and the analytical format are usually different files with one conversion between them.
Is the size difference the main reason to use Parquet?
No. Gzipped JSON Lines is close enough on storage that the difference rarely justifies a conversion step on its own. The reason is columnar reads: a scoring job that needs status and LCP out of forty-three fields reads two columns from Parquet and decompresses every byte of a gzipped JSONL file to get the same answer. On repeated queries over months of partitions, that difference compounds far past the storage saving.
Why pin the schema instead of letting the writer infer it?
Because inference reads only the first block of input, so the type of a column depends on which rows happen to arrive first. A field that is integer for the first hundred thousand records and floating point afterwards produces a file typed by batch ordering, and the failure appears much later when a query spans two partitions typed differently. Pinning turns an invisible data defect into a conversion error with a clear message on the day it is introduced.
Related
- Storing & Versioning Crawl Artifacts in Cloud Storage — the parent guide covering bucket layout, checksums, versioning and retention
- Expiring Old Crawl Artifacts with Lifecycle Rules — retention policy, which differs between the transport and analytical copies
- Building Score Aggregation Pipelines — the columnar reader that makes the conversion worth doing