Heavy Data Formats: JSONL, Parquet & Protobuf
JSON is human-readable and universal, but it's also the least efficient format for bulk AI data. When you're storing 10 million evaluation records, a training dataset with embeddings, or streaming data between microservices, the right format makes a 10x difference in storage cost and processing speed.
JSONL: Line-Delimited JSON
JSONL (JSON Lines) stores one JSON object per line. Unlike regular JSON arrays, JSONL files can be appended to incrementally, streamed line-by-line without loading the full file, and processed in parallel. This makes JSONL the standard format for AI training data, evaluation logs, and audit trails.
| Feature | JSON | JSONL |
|---|---|---|
| Read all records | Load full file into memory | Stream line by line |
| Append new records | Must re-serialize entire array | Just append new line |
| Parallel processing | Hard — need to parse whole file | Easy — split on newlines |
| Human readable | Yes (with pretty print) | Partially (one record per line) |
| Use case | Config, small APIs | Training data, logs, bulk exports |
import json
from pathlib import Path
from typing import Iterator
# Writing JSONL — stream results as they're generated
def write_eval_results(results_iter, output_path: Path) -> int:
"""Write evaluation results to JSONL, one per line. Returns record count."""
count = 0
with open(output_path, "w", encoding="utf-8") as f:
for result in results_iter:
f.write(json.dumps(result, ensure_ascii=False) + "\n")
count += 1
return count
# Reading JSONL — stream without loading everything into memory
def read_jsonl(path: Path) -> Iterator[dict]:
"""Stream records from a JSONL file one at a time."""
with open(path, encoding="utf-8") as f:
for line_num, line in enumerate(f, start=1):
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError as e:
print(f"Line {line_num}: invalid JSON — {e}")
# Practical: batch processing a large JSONL training dataset
def process_training_data(
jsonl_path: Path,
batch_size: int = 100,
) -> Iterator[list[dict]]:
"""Process a large JSONL file in batches."""
batch = []
for record in read_jsonl(jsonl_path):
batch.append(record)
if len(batch) >= batch_size:
yield batch
batch = []
if batch:
yield batch # final partial batch
# Count lines efficiently without reading all data
def count_jsonl_records(path: Path) -> int:
count = 0
with open(path, "rb") as f: # binary mode for speed
for _ in f:
count += 1
return count
# Example: writing LLM evaluation log
eval_log = Path("eval_log.jsonl")
sample_results = [
{"id": "p001", "score": 0.92, "model": "gpt-4o", "latency_ms": 1240},
{"id": "p002", "score": 0.78, "model": "gpt-4o", "latency_ms": 890},
]
records_written = write_eval_results(iter(sample_results), eval_log)
print(f"Wrote {records_written} records")
Parquet: Columnar Storage for AI Datasets
Parquet is a columnar binary format. Instead of storing each row sequentially (like CSV), it stores each column's values together. This means reads that access only a few columns (common in analytics) are dramatically faster — you only read the columns you need from disk.
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
from pathlib import Path
# Writing Parquet from pandas
def save_eval_dataset(records: list[dict], output_path: Path) -> None:
"""Save evaluation data to Parquet with optimal column types."""
df = pd.DataFrame(records)
# Optimize storage types
df["score"] = df["score"].astype("float32") # 4 bytes vs 8
df["tokens"] = df["tokens"].astype("int32") # 4 bytes vs 8
df["model"] = df["model"].astype("category") # encode repeated strings
# Snappy compression: fast, reasonable ratio (default)
df.to_parquet(output_path, compression="snappy", index=False)
print(f"Saved {len(df)} records to {output_path} "
f"({output_path.stat().st_size / 1024:.1f} KB)")
# Reading Parquet — columnar efficiency
def load_scores_only(parquet_path: Path) -> pd.Series:
"""Read only the score column — skips all other data on disk."""
return pd.read_parquet(parquet_path, columns=["score"])["score"]
# Filtering at read time (pushdown predicates)
def load_high_quality(parquet_path: Path, min_score: float = 0.8) -> pd.DataFrame:
"""Load only records with score >= min_score."""
df = pd.read_parquet(parquet_path)
return df[df["score"] >= min_score]
# Partitioned Parquet for very large datasets
def save_partitioned_by_model(df: pd.DataFrame, output_dir: Path) -> None:
"""Save dataset partitioned by model — queries on a specific model read only that partition."""
table = pa.Table.from_pandas(df)
pq.write_to_dataset(
table,
root_path=str(output_dir),
partition_cols=["model"],
compression="snappy",
)
# Creates: output_dir/model=gpt-4o/data.parquet
# output_dir/model=gpt-4o-mini/data.parquet
# Read only one partition
def load_model_results(output_dir: Path, model: str) -> pd.DataFrame:
model_path = output_dir / f"model={model}"
return pd.read_parquet(model_path)
For a typical AI evaluation dataset with 500K rows: CSV is ~150MB and takes 3s to read. Parquet with Snappy compression is ~30MB and takes 0.2s to read (for all columns) or 0.02s (for one column). If your dataset is growing, switch to Parquet immediately.
Protobuf: Typed Binary for Service Pipelines
Protocol Buffers (Protobuf) are Google's schema-first binary serialization format. They're smaller and faster than JSON, and unlike Parquet, they're designed for streaming between services — not analytics. Think of them as C#'s BinaryFormatter but cross-language, efficient, and with schema evolution.
// embedding_service.proto — define once, generate Python/C#/Go code
syntax = "proto3";
package ai;
message EmbeddingRequest {
string request_id = 1;
repeated string texts = 2;
string model = 3;
int32 dimensions = 4;
}
message EmbeddingResponse {
string request_id = 1;
repeated EmbeddingVector embeddings = 2;
int32 input_tokens = 3;
float latency_ms = 4;
}
message EmbeddingVector {
string text_id = 1;
repeated float values = 2;
int32 token_count = 3;
}
The .proto file is the schema — the single source of truth, much like a shared contract assembly. You don't hand-write the classes; you run the Protobuf compiler (protoc) to generate them, and it can emit Python, C#, Go, and more from the same file. That's what makes Protobuf a good fit for services written in different languages talking to each other.
# Generate Python classes from .proto file
pip install grpcio-tools
python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. embedding_service.proto
That command produces an embedding_service_pb2 module containing typed classes for each message. From there it feels like using generated DTOs: construct an object, call SerializeToString() to get compact bytes for the wire, and FromString() to parse them back — with nested messages handled automatically. The size print shows the payoff over JSON.
# Using generated Protobuf classes
from embedding_service_pb2 import EmbeddingRequest, EmbeddingResponse, EmbeddingVector
# Serialize to bytes
request = EmbeddingRequest(
request_id="req_abc123",
texts=["What is RAG?", "Explain embeddings"],
model="text-embedding-3-small",
dimensions=1536,
)
serialized: bytes = request.SerializeToString()
print(f"Size: {len(serialized)} bytes vs JSON: {len(str(request).encode())} bytes")
# Deserialize
parsed = EmbeddingRequest.FromString(serialized)
print(parsed.request_id) # "req_abc123"
print(list(parsed.texts)) # ["What is RAG?", "Explain embeddings"]
# Nested messages
response = EmbeddingResponse(
request_id="req_abc123",
embeddings=[
EmbeddingVector(text_id="t0", values=[0.1, 0.2, 0.3], token_count=5),
EmbeddingVector(text_id="t1", values=[0.4, 0.5, 0.6], token_count=3),
],
input_tokens=8,
latency_ms=45.2,
)
Format Decision Guide
| Format | Best For | Avoid When |
|---|---|---|
| JSON | Config, small API payloads, debugging | Millions of records, performance-critical |
| JSONL | Streaming logs, training data, append-heavy workloads | Random access by row, analytics with many filters |
| Parquet | Analytics, ML datasets, columnar access patterns | Streaming between services, small files |
| Protobuf | High-throughput service-to-service messaging, gRPC | Ad-hoc human-readable data, one-off analysis |
| CSV | Excel export, stakeholder sharing | Anything at scale (no types, slow) |
"""
Quick decision rules for AI engineering:
"I'm logging LLM evaluation results as they happen"
→ JSONL (append-only, streamable, human-readable in pinch)
"I'm storing a 1M-row evaluation dataset for analysis"
→ Parquet (10-20x smaller than JSON, fast columnar reads)
"I'm sending embeddings between a Python service and a Go service at 10k req/s"
→ Protobuf (schema-enforced, ~5x smaller than JSON, typed)
"I need to export results for a stakeholder to open in Excel"
→ CSV (only valid use case for CSV in AI engineering)
"I'm passing a config object or API payload"
→ JSON (human-readable, universal, small size acceptable)
"""
Key Takeaways
- JSONL: one record per line — ideal for streaming, append-only logs, and AI training datasets; stream with a generator, never load whole file
- Parquet: columnar binary — 5–20x smaller than JSON/CSV, reads only the columns you need; the right default for evaluation datasets over 10K rows
- Protobuf: typed binary with schema — for high-throughput service-to-service communication and gRPC; requires schema compilation step
- Partition Parquet datasets by high-cardinality filter columns (model, date, environment) to enable partition pruning and dramatically faster filtered reads
- JSONL can be appended to without rewriting; Parquet and Protobuf require full rewrite (or append new files for Parquet datasets)
- CSV is only for stakeholder exports and Excel compatibility — never for production AI data pipelines

Comments
Loading comments…