On this page
Convert Parquet to JSONL for LLM Fine-Tuning
The mismatch
Public datasets — Hugging Face hosts its datasets as Parquet shards, and
most warehouses export the same way — arrive as .parquet, but every
mainstream fine-tuning pipeline wants JSONL: one JSON object per line,
usually in a chat messages shape. The gap is small enough that reaching
for pandas and a virtualenv is overkill.
Straight conversion, no code
If your Parquet columns already match the target shape, drop the file into
the Parquet to JSONL converter. It runs DuckDB
compiled to WebAssembly inside the tab — the dataset never leaves your
machine, which matters when the training data contains customer text.
Nested lists and structs come out as real JSON arrays and objects, so a
dataset that already has a messages column converts losslessly.
Before converting, it is worth a two-minute look at what is actually in the file: open it in the Parquet Viewer to check the schema, spot empty columns and confirm the row count matches what the dataset card claims.
Reshaping into the messages format
More often the Parquet file is flat — prompt and response columns, say —
and the API wants each line as
{"messages": [{"role": "user", ...}, {"role": "assistant", ...}]}.
That reshape is one query. In the SQL Workbench or the DuckDB CLI:
SELECT [
{'role': 'user', 'content': prompt},
{'role': 'assistant', 'content': response}
] AS messages
FROM 'train.parquet'
WHERE response IS NOT NULL
AND length(response) BETWEEN 20 AND 8000;
DuckDB's struct and list literals serialize to exactly the JSON you need.
The WHERE clause is where most of the quality work happens: dropping
empty completions, near-zero-length examples and pathological outliers is
the cheapest fine-tuning improvement available.
To write the result as JSONL locally, wrap the query in a COPY:
duckdb -c "COPY (
SELECT [{'role':'user','content':prompt},{'role':'assistant','content':response}] AS messages
FROM 'train.parquet' WHERE response IS NOT NULL
) TO 'train.jsonl' (FORMAT JSON);"
DuckDB's JSON export is newline-delimited by default — no extra flags.
Train/validation split that stays put
Avoid ORDER BY random() for splitting: it produces a different split on
every run. Hash a stable key instead:
-- ~95% of rows -> training set
... WHERE hash(id) % 20 != 0
-- the remaining ~5% -> validation set
... WHERE hash(id) % 20 = 0
The two predicates are exact complements, so no example can leak into both files, and re-running the export months later reproduces the same split.
Final checks before upload
wc -l train.jsonl— the line count is the example count; make sure it matches expectations.jq -c 'select(.messages == null)' train.jsonl— should print nothing.- Skim a sample:
shuf -n 5 train.jsonl | jq .catches swapped prompt/response columns faster than any validator. - Deduplicate if the source is scraped data — repeated examples overweight
themselves in training.
SELECT DISTINCTduring the export above handles exact duplicates for free.
If a provider rejects the file, the error usually names a line number — which is precisely why the format is JSONL in the first place.
Frequently asked questions
- Why do fine-tuning APIs use JSONL instead of JSON or Parquet?
- JSONL puts one self-contained example per line, so the training service can stream, split and validate the file without parsing it whole. Every malformed example is addressable by line number, which makes upload errors debuggable.
- Can I convert a Hugging Face Parquet dataset to JSONL without pandas?
- Yes. Download the .parquet shard and drop it into the browser Parquet to JSONL converter on this site, or run a one-line DuckDB COPY command. Neither requires Python, pandas or the datasets library.
- How do I reshape flat columns into the messages format for chat fine-tuning?
- Build the structure in SQL: select a list of role/content structs from your prompt and response columns, then export with FORMAT JSON. DuckDB serializes lists and structs as proper JSON arrays and objects on each line.
- How do I split training and validation sets during the export?
- Hash a stable ID column and filter: WHERE hash(id) % 20 != 0 for ~95% train, = 0 for the rest. Unlike random(), the split is reproducible, and no example can land in both files.