Choosing Between JSONL and Parquet
Row-oriented text versus columnar binary - the mental model, the honest tradeoffs, and the hybrid pattern most data teams land on
The Core Difference: Rows or Columns
Almost every practical difference between JSONL and Parquet falls out of one design choice: how bytes are grouped on disk. JSONL groups bytes by record. Parquet groups bytes by column. Internalize that and you can predict which format wins without memorizing a feature list.
JSONL Is Row-Oriented Text
One record per line, each line a complete, self-contained JSON object. Reading a record means reading one line; writing one means appending a line. Nothing else in the file has to change.
{"id": 1, "user": "ada", "amount": 12.50, "ok": true}
{"id": 2, "user": "grace", "amount": 99.00, "ok": false}
{"id": 3, "user": "alan", "amount": 4.25, "ok": true}The bytes for record 2 sit together. The bytes for the "amount" field are scattered across the whole file.
Parquet Is Columnar Binary
Parquet splits a table into row groups, and inside each row group stores one contiguous chunk per column. A footer records where every chunk lives, so a reader can jump straight to the columns it wants.
row group 1
chunk: id [1, 2, 3]
chunk: user ["ada", "grace", "alan"]
chunk: amount [12.50, 99.00, 4.25]
chunk: ok [true, false, true]
footer: schema + chunk offsets + statisticsAll the "amount" bytes sit together. Reading one whole record means touching four separate places.
The one-line takeaway: JSONL is optimized for getting data in, one record at a time, from anywhere. Parquet is optimized for getting it out, one column at a time, over and over. They are less competitors than two ends of the same pipeline.
When JSONL Wins
Streaming and Append-Only Writes
Appending to JSONL is an open, seek to end, write, close. No footer to rewrite, no row group to close out, no metadata to reconcile - and a crashed writer leaves a file still readable up to the last complete line. Parquet cannot do this: schema and chunk offsets are written last, so the file is only valid once the writer finishes cleanly. A truncated Parquet file is usually a total loss.
Landing and Ingest Zones
The first place data touches down is the worst place to enforce structure. Producers change without warning, and a strict writer that rejects a malformed payload throws away the only copy you had. JSONL accepts whatever arrives, keeps it verbatim, and lets you decide later what it meant. That is a feature at the edge and a liability in the warehouse.
Interchange Between Systems
Any language that can print a string can emit valid JSONL. No writer library to install, no version to negotiate, no codec both sides must agree on. Writing Parquet correctly means pulling in a real implementation such as Arrow or parquet-java - fine on a data platform, a genuine obstacle in a shell script, on an embedded device, in a serverless function with a tight package budget, or in a language whose Parquet bindings are immature.
Human Inspectability and Line-Level Diffs
You can open a JSONL file in any editor, pipe it through head, grep it for an identifier, and see what is wrong at three in the morning without a special tool. Because one record is exactly one line, ordinary line-oriented tooling works: diff shows which records changed, wc -l counts records, split shards a file, and version control produces a readable review. Parquet is binary, so inspecting it requires a reader and a diff tells you nothing without one. When something looks wrong in production, that gap matters more than it sounds like it should.
Schema-on-Read Flexibility
Every line can have a different shape. Heterogeneous event streams, sparse optional fields, and irregular nesting all fit without up-front modeling. You pay for that at query time instead of write time, which is the right trade when you do not yet know what the questions are.
When Parquet Wins
Column Pruning and Predicate Pushdown
A query selecting three columns out of eighty reads roughly three columns' worth of bytes from a Parquet file. The same query against JSONL must parse every byte of every line, because the field you want is interleaved with the seventy-seven you do not. This is the single largest reason analytical engines prefer Parquet, and it gets better the wider your records are.
Parquet also stores per-column statistics for each row group. DuckDB's write-up on querying Parquet puts both halves plainly: "The columnar representation means that individual columns can be (efficiently) read. No need to always read the entire file," and "The file contains per-column statistics in every row group (min/max value, and the number of NULL values)," so "any row groups that have a max value of pickup_at lower than 2019-04-15, or a min value higher than 2019-04-20, can be skipped." A filter on a well-clustered column can skip most of the file without decoding it.
Repeated Queries Over the Same Data
Converting to Parquet costs one full pass over the data. If the dataset is queried once, that pass is pure overhead and you should have read the JSONL directly. If a dashboard hits it every fifteen minutes for two years, the conversion amortizes to nothing. The honest question is not "which format is faster" but "how many times will this be read".
A Real, Typed Schema
Parquet embeds column names, types, nullability, and nesting in the footer. A reader knows amount is a decimal before decoding a single value, so there is no per-record type inference and no ambiguity about whether "1" was a string or a number. Engines can plan against that schema; with JSONL they sample the file and guess.
Large Scans and Vectorized Execution
Decoded Parquet columns arrive as typed arrays, exactly the layout modern engines want for vectorized execution. JSONL must be parsed into objects first, and text parsing dominates any JSONL scan. See the performance page for how much of that cost a faster JSON parser claws back when you must stay on JSONL.
Why Parquet Files Are Usually Much Smaller
You will see a lot of compression ratios quoted for this comparison. Treat all of them with suspicion: the ratio depends entirely on your data's cardinality, key-name length, and sortedness. The mechanism is what is worth understanding, because it tells you whether your data will compress well.
Keys Are Not Repeated
JSONL repeats every field name on every line. A million records with a field called transaction_timestamp carry that string a million times. Parquet writes each column name once, in the footer. For wide records with descriptive keys, this alone removes a large fraction of the file before any compression runs.
Columns Are Homogeneous
A general-purpose compressor works better on similar bytes than mixed ones. In JSONL a timestamp sits next to a username next to a float, so the compressor's window is full of unrelated patterns. In Parquet, thousands of timestamps sit adjacent and share nearly all their leading characters.
Encodings Exploit That
The Parquet specification defines encodings that only make sense on a homogeneous column: dictionary encoding, run-length plus bit-packing, delta encoding, and byte stream split. These run before the general compressor, which then gets an already-shrunken input.
Dictionary Encoding, Concretely
The Parquet docs describe dictionary encoding as building "a dictionary of values encountered in a given column," after which "values are stored as integers using the RLE/Bit-Packing Hybrid encoding." Suppose a country column holds ten million values drawn from about 200 distinct strings:
JSONL "country":"United States" x 10,000,000
-> the literal string, on every single line
Parquet dictionary: ["United States", "Canada", "Japan", ...] (200 entries, once)
column data: [0, 0, 1, 0, 2, 2, 0, ...] (small ints)
runs of the same value collapse further via RLEThat is the whole trick, and it is why the size gap is typically largest on low-cardinality string columns and smallest on high-entropy data such as random identifiers, hashes, or free text. Mostly unique blobs shrink the advantage considerably; wide, repetitive event rows make it dramatic.
Measure your own data. Convert a representative sample and compare byte counts. That takes about a minute and beats any number you read on the internet, this page included. Compare fairly, too: gzip-compressed JSONL against Parquet, since production JSONL is almost always stored compressed.
Schema Drift: The Difference That Bites
JSONL is schema-on-read: the file makes no promises and the reader decides what the data means. Parquet is schema-on-write: every column's type is fixed in the footer when the file is written. Neither is inherently better, but they fail in completely different ways, and the failure mode is what you should plan around.
When a Field Is Added
JSONL: nothing breaks. New lines carry the field, old lines do not, and every existing consumer keeps working because it never looked for it. The cost is invisible: a consumer that should have noticed the new field ignores it forever.
Parquet: files written after the change have an extra column. Most engines union schemas across a dataset and return NULL for the missing column in older files, but that is engine-specific behavior rather than a format guarantee, and strict readers refuse. You decide whether to backfill.
When a Type Changes
JSONL: the file happily holds {"id": 7} and {"id": "7"} on adjacent lines, and nothing complains at write time. The breakage surfaces months later in whatever tries to aggregate the column, by which point bad records are spread across the entire history.
Parquet: the writer fails immediately, where the bad data was produced, with a message naming the column. A conflicting type across two files in a dataset is an explicit read-time error, not a silent wrong answer.
JSONL's tolerance is a strength while you are receiving data and a weakness once you are querying it. That asymmetry is the practical argument for the hybrid pattern below. If JSONL stays your query layer, add an explicit validation step so drift is caught deliberately rather than discovered - the JSONL validator checks line-by-line parseability and flags structural inconsistencies before they reach a downstream job.
What Most Teams Actually Do
The honest answer to "JSONL or Parquet" is usually both, at different stages. That is not a hedge - it falls out of the tradeoffs above, and it is what the medallion, bronze-silver-gold, and raw-versus-curated layouts all describe under different names.
- 1 Land raw events as JSONL. Producers append; nothing is rejected, reshaped, or lost. Compress with gzip or zstd and partition by date. This layer is immutable, and it is your evidence if a downstream transform turns out to be wrong.
- 2 Compact to Parquet on a schedule. An hourly or daily job reads the new JSONL, applies types, and writes partitioned Parquet. Small files are the enemy of columnar formats, so compaction does two jobs at once: converting the format and consolidating fragments into row groups large enough to be worth skipping.
- 3 Point every query at the Parquet. Dashboards, notebooks, and the warehouse read the curated layer and get column pruning and row group skipping for free.
- 4 Keep the JSONL as long as it is affordable. When the transform has a bug you replay from raw, rather than reconstructing data you already discarded. Raw JSONL in cold object storage is cheap insurance.
The analytics pipelines guide covers this layering end to end, and the JSONL specification page covers what producers must emit for the landing layer to stay parseable.
Converting JSONL to Parquet
DuckDB
The shortest path: a single binary, no runtime to install. DuckDB documents read_ndjson as an alias for read_json with the format parameter set to newline-delimited.
COPY (SELECT * FROM read_ndjson('data.jsonl')) TO 'data.parquet' (FORMAT PARQUET);Glob a whole partition, pick a codec, and pin the types instead of letting them be inferred:
-- every file in a partition, zstd-compressed output
COPY (SELECT * FROM read_ndjson('events/2026-07-*.jsonl.gz'))
TO 'events-2026-07.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);
-- declare the schema instead of trusting inference
COPY (
SELECT * FROM read_ndjson('data.jsonl',
columns = {id: 'BIGINT', user: 'VARCHAR', amount: 'DECIMAL(12,2)', ts: 'TIMESTAMP'})
) TO 'data.parquet' (FORMAT PARQUET);Declaring columns is worth the extra lines on any recurring job: inference samples the file, so a rare string in an otherwise-numeric column can quietly change the output schema between runs.
Polars
Use the lazy API so the file is never fully materialized. Polars documents sink_parquet as evaluating the query in streaming mode, which "allows streaming results that are larger than RAM to be written to disk."
import polars as pl
# streaming: input larger than memory is fine
pl.scan_ndjson("data.jsonl").sink_parquet("data.parquet", compression="zstd")
# eager, for files that comfortably fit in RAM
pl.read_ndjson("data.jsonl").write_parquet("data.parquet")Prefer scan_ndjson plus sink_parquet in any pipeline: the lazy version keeps peak memory bounded regardless of input size, which is usually why you had JSONL in the first place.
pandas and PyArrow
pandas reads line-delimited JSON with lines=True, which is also the only mode in which chunksize is accepted - the docs are explicit that it "can only be passed if lines=True".
import pandas as pd
df = pd.read_json("data.jsonl", lines=True)
df.to_parquet("data.parquet", engine="pyarrow", compression="zstd", index=False)For a file too large to hold in memory, stream it through PyArrow's incremental writer. The first chunk fixes the schema, so an inferred type that shifts later will raise:
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
writer = None
for chunk in pd.read_json("data.jsonl", lines=True, chunksize=100_000):
table = pa.Table.from_pandas(chunk, preserve_index=False)
if writer is None:
writer = pq.ParquetWriter("data.parquet", table.schema, compression="zstd")
writer.write_table(table)
if writer:
writer.close()Pass an explicit pa.schema(...) to ParquetWriter when the data is at all irregular. Letting the first 100,000 rows decide the types of a ten-billion-row file is the kind of implicit contract that fails in production.
Going Back the Other Way
Parquet to JSONL is occasionally necessary - feeding an API, a fine-tuning job, or a system that speaks only JSON:
COPY (SELECT * FROM 'data.parquet') TO 'data.jsonl' (FORMAT JSON);Expect the file to grow substantially and expect some type fidelity to be lost, since JSON has no native decimal, date, or fixed-width integer types.
Side by Side
| Dimension | JSONL | Parquet |
|---|---|---|
| Layout | Row-oriented text | Columnar binary, row groups |
| Append a record | Write one line | Rewrite or add a new file |
| Partial file after a crash | Readable to the last full line | Invalid without the footer |
| Read 3 of 80 columns | Parses all 80 | Reads roughly 3 |
| Skip non-matching data | No index, full scan | Row group min/max statistics |
| File size | Larger; keys repeat every line | Typically far smaller |
| Schema | On read; drift is silent | Embedded and typed; drift errors |
| Writer requirements | Any language, no library | Arrow or an equivalent implementation |
| Human readable | Yes, any text editor | No, needs a reader |
| Line-level diff | Works with standard tools | Not meaningful |
| Irregular record shapes | Native | Possible, but awkward and sparse |
| Best role | Ingest, interchange, raw archive | Query layer, analytics, warehouse |
Decision Checklist
Stay on JSONL if
- Records arrive continuously and are appended as they come
- The data will be read once or twice, not repeatedly
- Producers are heterogeneous or outside your control
- Record shapes vary and you do not want to model them yet
- Humans need to read, grep, or diff the file directly
- The consuming system speaks JSON and nothing else
- You need a durable, verbatim archive of what was actually sent
Move to Parquet if
- The same dataset is queried many times
- Queries touch a few columns out of many
- Filters are selective and correlate with a sort key
- Storage or egress cost is a real line item
- You want type errors to surface at write time
- The consumer is Spark, Trino, DuckDB, BigQuery, or Snowflake
- The dataset is stable and no longer changing shape
If you ticked boxes in both columns, that is the expected result and it is telling you to run both: JSONL in, Parquet out. Only pick a single format when the data genuinely lives at one end of its life cycle.
Where Avro and ORC Fit
Avro
Row-oriented and binary, with a schema carried alongside the data and first-class evolution rules. Think of it as JSONL with types and a compact encoding: still good at writes and whole-record reads, still bad at column pruning. The common choice for Kafka topics and pipeline stages that need typed records without becoming a query target.
ORC
Columnar and binary, solving the same problem as Parquet with a different lineage in the Hive ecosystem. In practice your engine and existing tables make the decision, not any property of the format. Absent a constraint pushing you toward ORC, Parquet has the broader tool support.
For how JSONL stacks up against JSON, CSV, and XML, and for an honest list of its drawbacks, see the full format comparison page.
Sources
Every technical claim here traces to one of these. No benchmark numbers are quoted, because the ones that circulate for this comparison are almost never reproducible on your data.
- Apache Parquet - Encodings - dictionary, RLE/bit-packing hybrid, delta, and byte stream split.
- Apache Parquet - File Format - row groups, column chunks, and the trailing footer.
- DuckDB - Querying Parquet with Precision - projection pushdown, filter pushdown, and per-row-group min/max statistics.
- DuckDB - Loading JSON - read_ndjson and read_json_auto.
- Polars - LazyFrame.sink_parquet - streaming writes of results larger than RAM.
- pandas - read_json - the lines parameter, and chunksize requiring it.
Get the Landing Layer Right First
Parquet can only be as good as the JSONL it was built from. Check your files before you compact them.