Skip to main content

Converting Data To and From JSONL

A conversion matrix for JSON Lines - JSON arrays, CSV, Parquet, Excel, SQL databases and YAML - with working commands in jq, Python, Node.js, DuckDB, Polars, Miller and qsv.

Need a Conversion Right Now?

The JSONL Validator converts a JSON array to JSONL, JSONL back to a JSON array, and JSONL to CSV - in your browser, with nothing uploaded. For bigger files or a repeatable pipeline, use the recipes below.

Open the Converter

JSON Array and JSONL

The most searched pair. A JSON array is one unit a parser must read to the closing bracket before it hands you anything; JSONL is a sequence of independent documents it hands you one at a time.

JSON Array to JSONL

You are unwrapping the outer array and emitting each element on its own line. The critical detail in every language is compact output: a pretty-printing serializer spreads each record over many lines and the result is not JSONL.

jq

jq -c '.[]' data.json > data.jsonl

The -c flag is not optional. Without it jq pretty-prints and you get invalid JSONL.

jq, Streaming for Files Too Big for Memory

jq -cn --stream 'fromstream(1|truncate_stream(inputs))' data.json > data.jsonl

Reconstructs one element at a time. Slower per record, but the file need not fit in RAM.

Python

import json

with open('data.json', 'r', encoding='utf-8') as fin, \
     open('data.jsonl', 'w', encoding='utf-8') as fout:
    for row in json.load(fin):
        fout.write(json.dumps(row, ensure_ascii=False) + '\n')

Set ensure_ascii=False to keep UTF-8 readable instead of escaped.

Python, Streaming with ijson

import ijson
import json

with open('data.json', 'rb') as fin, open('data.jsonl', 'w') as fout:
    for row in ijson.items(fin, 'item'):
        fout.write(json.dumps(row) + '\n')

The prefix item selects the elements of the top level array.

Node.js

const fs = require('fs');

const rows = JSON.parse(fs.readFileSync('data.json', 'utf8'));
const out = fs.createWriteStream('data.jsonl');

for (const row of rows) {
    out.write(JSON.stringify(row) + '\n');
}
out.end();

For very large inputs, swap the parse for a streaming parser such as the stream-json package.

JSONL to JSON Array

Wrapping every line in one array is what you reach for when a library refuses anything but a JSON document. It is also the direction that quietly exhausts memory, because the whole dataset must exist at once.

jq

jq -s '.' data.jsonl > data.json

The slurp flag reads every line into one array, holding the whole file in memory.

Shell, Constant Memory

{ echo '['; sed '$!s/$/,/' data.jsonl; echo ']'; } > data.json

Appends a comma to every line but the last. It parses nothing, so only run it on a file you have already validated.

Python

import json

with open('data.jsonl', 'r', encoding='utf-8') as f:
    rows = [json.loads(line) for line in f if line.strip()]

with open('data.json', 'w', encoding='utf-8') as f:
    json.dump(rows, f, ensure_ascii=False, indent=2)

The if line.strip() guard skips blank trailing lines, which are common and would raise a decode error.

Node.js

import fs from 'node:fs';
import readline from 'node:readline';

const rl = readline.createInterface({
    input: fs.createReadStream('data.jsonl'),
    crlfDelay: Infinity
});

const rows = [];
for await (const line of rl) {
    if (line.trim()) rows.push(JSON.parse(line));
}

fs.writeFileSync('data.json', JSON.stringify(rows, null, 2));

Uses top level await, so save it as an ES module. crlfDelay keeps Windows line endings from producing empty lines.

CSV and JSONL

The pair with the most sharp edges, because the formats describe different shapes. JSONL records are typed trees with per record keys; CSV is an untyped rectangle with one fixed header row.

CSV to JSONL

Every value in a CSV file is a string. Your tool decides whether it stays one or gets guessed into a number, boolean or date, and that guess is the biggest source of surprise here. jq cannot read CSV at all.

Python csv Module

import csv
import json

with open('data.csv', newline='', encoding='utf-8') as fin, \
     open('data.jsonl', 'w', encoding='utf-8') as fout:
    for row in csv.DictReader(fin):
        fout.write(json.dumps(row, ensure_ascii=False) + '\n')

Zero type inference, so every field lands as a JSON string. Usually what you want when the data goes straight back out.

pandas

import pandas as pd

df = pd.read_csv('data.csv', dtype=str, keep_default_na=False)
df.to_json('data.jsonl', orient='records', lines=True, force_ascii=False)

Drop dtype=str to let pandas infer. Keep it when the CSV holds identifiers, postal codes or phone numbers that must not become integers.

DuckDB

COPY (SELECT * FROM read_csv_auto('data.csv'))
TO 'data.jsonl' (FORMAT JSON, ARRAY false);

DuckDB writes newline delimited JSON when ARRAY is false and a single array when true. Pass explicit types to read_csv if you distrust the sniffer.

Miller

mlr --icsv --ojsonl cat data.csv > data.jsonl

# Keep every value as a string instead of inferring numbers
mlr --icsv --ojsonl --infer-none cat data.csv > data.jsonl

Miller streams by default and understands quoted fields containing commas and newlines.

qsv

qsv tojsonl data.csv > data.jsonl

qsv infers types per column from the data it sees. Check the output on a sample first.

JSONL to CSV

Answer one question first: what are the columns? JSONL does not promise every line has the same keys, so either you name the columns or the tool scans the file to collect them.

jq with @csv

jq -rn '["id","name","email"],
        (inputs | [.id, .name, .email])
        | @csv' data.jsonl > data.csv

The @csv filter handles quoting and comma escaping. You supply the header, which also pins column order.

jq, Deriving the Header from the Data

jq -rs '(map(keys) | add | unique) as $cols
        | $cols, (.[] | [.[$cols[]]])
        | @csv' data.jsonl > data.csv

Collects the union of all keys so nothing is silently dropped. Requires slurping, so it is memory bound.

pandas

import pandas as pd

df = pd.read_json('data.jsonl', lines=True)
df = pd.json_normalize(df.to_dict('records'), sep='.')
df.to_csv('data.csv', index=False)

json_normalize flattens nested objects into dotted column names. Arrays are not flattened - they land as their Python representation.

DuckDB

COPY (
  SELECT * FROM read_json_auto('data.jsonl', format='newline_delimited')
) TO 'data.csv' (HEADER, DELIMITER ',');

DuckDB samples the file to build a schema. Any field typed as a struct or list is rendered as text in the CSV.

Miller

mlr --ijsonl --ocsv cat data.jsonl > data.csv

# Records with different keys: fill gaps instead of erroring
mlr --ijsonl --ocsv unsparsify data.jsonl > data.csv

Miller auto flattens nested maps into dotted keys on non JSON output. unsparsify is the fix for heterogeneous keys.

qsv

qsv jsonl data.jsonl > data.csv

The CSV Gotchas That Actually Bite

  • Type inference is a guess. Order number 00042 becomes 42, version 1.10 becomes 1.1, ZIP codes lose leading zeros. If a field is an identifier rather than a quantity, force it to a string.
  • Embedded commas and newlines are legal CSV. RFC 4180 allows a quoted field to contain commas, double quotes and line breaks. Never split on commas yourself, and never assume one physical line is one record.
  • Headers are positional, keys are not. Column order means something to whoever reads the CSV next; JSONL key order does not. Pin the column list if downstream code indexes by position.
  • Nested JSON does not fit a flat table. A record with an address object and a tags array has no honest CSV form. Flatten with dotted names, serialize the subtree into one cell, or explode arrays into extra rows - all three change what a row means.
  • Null and empty string collapse. CSV has one way to say nothing, JSON has two, and they usually mean different things. Decide the mapping before you convert.

Parquet and JSONL

Parquet is a columnar, compressed, strongly typed binary format with its schema in the file footer - the usual destination once JSONL stops being a transport format and becomes a dataset you query repeatedly. Unlike CSV it represents nested structures natively, so this conversion is far less lossy.

JSONL to Parquet

Every tool here infers a schema first, usually by sampling. That is where heterogeneous records go wrong: a field that is an integer in the first thousand rows and a string later will fail the write or be silently coerced.

DuckDB

COPY (
  SELECT * FROM read_json_auto('data.jsonl', format='newline_delimited')
) TO 'data.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);

Raise the sample size, or declare columns explicitly with read_json, when the schema is not uniform.

Polars

import polars as pl

# Eager: loads the file
pl.read_ndjson('data.jsonl').write_parquet('data.parquet')

# Lazy: streams, for files larger than memory
pl.scan_ndjson('data.jsonl').sink_parquet('data.parquet')

Check your Polars version for the exact sink API, which has changed across releases.

pandas with PyArrow

import pandas as pd

df = pd.read_json('data.jsonl', lines=True)
df.to_parquet('data.parquet', engine='pyarrow', compression='zstd')

PyArrow Directly

import pyarrow.json as pj
import pyarrow.parquet as pq

table = pj.read_json('data.jsonl')
pq.write_table(table, 'data.parquet', compression='zstd')

PyArrow reads line delimited JSON natively and preserves structs and lists as real Parquet nested types.

Parquet to JSONL

DuckDB

COPY (SELECT * FROM 'data.parquet')
TO 'data.jsonl' (FORMAT JSON, ARRAY false);

DuckDB queries Parquet directly by path, so you can filter or project instead of dumping everything.

Polars

import polars as pl

pl.read_parquet('data.parquet').write_ndjson('data.jsonl')

pandas

import pandas as pd

df = pd.read_parquet('data.parquet')
df.to_json('data.jsonl', orient='records', lines=True, date_format='iso')

Set date_format='iso' or timestamps come out as epoch milliseconds, which is almost never what a consumer expects.

Excel and XLSX

No standard tool converts JSONL to XLSX directly - Excel is a spreadsheet, not an interchange format. You go through the flat rectangle first, so every CSV caveat applies, plus Excel specific ones that are worse.

JSONL to Excel

Via CSV

mlr --ijsonl --ocsv unsparsify data.jsonl > data.csv

Then use Data, Get Data, From Text/CSV and set every identifier column to Text in the import preview. Never double click the CSV - that path applies Excel's conversions with no chance to intervene.

pandas Direct to XLSX

import pandas as pd

df = pd.read_json('data.jsonl', lines=True, dtype=False)
df = pd.json_normalize(df.to_dict('records'), sep='.')
df.to_excel('data.xlsx', index=False, engine='openpyxl')

Safer than importing a CSV, because pandas sets each cell type deliberately instead of letting Excel guess. Requires pip install openpyxl.

Excel to JSONL

import pandas as pd

df = pd.read_excel('data.xlsx', sheet_name=0, dtype=str)
df = df.where(df.notna(), None)
df.to_json('data.jsonl', orient='records', lines=True, force_ascii=False)

dtype=str stops pandas re-guessing types Excel already guessed once. The where call turns missing cells into JSON null rather than the string "nan".

What Excel Does To Your Data

Excel applies its own conversions on import and they are not reversible. Assume a round trip through a spreadsheet is destructive.

  • Large numbers lose precision. Microsoft documents that Excel stores 15 significant digits and zeroes the rest. A 19 digit snowflake ID comes back changed, and it looks plausible.
  • Leading zeros disappear. Anything Excel decides is numeric gets normalized, so 00123 becomes 123. Postal codes and part numbers are the usual casualties.
  • Anything that looks like a date becomes one. Values such as 3-1 or 1/2 convert on import. The HUGO Gene Nomenclature Committee renamed a set of gene symbols in 2020 specifically because Excel kept mangling them.
  • There is a hard row ceiling. Microsoft documents a worksheet limit of 1,048,576 rows and 16,384 columns. JSONL files routinely exceed it.
  • Encoding still surprises people. A CSV saved from Excel may not be UTF-8. Verify before feeding it back into a JSONL pipeline.

SQL Databases

JSONL is the standard bulk load format for most analytical databases, because a loader can split the file on newlines and parse chunks in parallel. The pattern is the same everywhere: land each line in a JSON column, then project it into typed columns with SQL.

PostgreSQL

Postgres has no native JSONL loader. Point the CSV reader at delimiter and quote characters that cannot occur in your data, and every line arrives intact as one text value.

Load

CREATE TABLE events_raw (doc jsonb);

\copy events_raw (doc) FROM 'data.jsonl' WITH (FORMAT csv, QUOTE e'\x01', DELIMITER e'\x02')

INSERT INTO events (id, name, created_at)
SELECT (doc->>'id')::bigint,
       doc->>'name',
       (doc->>'created_at')::timestamptz
FROM events_raw;

A text mode COPY would interpret backslash sequences inside your JSON strings. Control characters avoid that.

Export

\copy (SELECT row_to_json(t)::text FROM (SELECT id, name, created_at FROM events) t) TO 'events.jsonl' WITH (FORMAT csv, QUOTE e'\x01', DELIMITER e'\x02')

Use jsonb_build_object instead of row_to_json to control key names and nesting.

SQLite

Load

CREATE TABLE raw(doc TEXT);
.mode ascii
.separator "\x1f" "\n"
.import data.jsonl raw

CREATE TABLE users AS
SELECT json_extract(doc, '$.id')    AS id,
       json_extract(doc, '$.name')  AS name,
       json_extract(doc, '$.email') AS email
FROM raw;

A field separator that never appears in the data makes the importer treat each line as a single column value.

Export

sqlite3 app.db ".mode list" ".headers off" \
  "SELECT json_object('id', id, 'name', name, 'email', email) FROM users;" \
  > users.jsonl

SQLite is dynamically typed, so a column can hold an integer in one row and a string in the next. The JSONL will reflect that.

BigQuery

BigQuery treats newline delimited JSON as a first class load format, and it is what its own export produces.

# Load
bq load --source_format=NEWLINE_DELIMITED_JSON --autodetect \
  mydataset.events data.jsonl

# Export (destination must be Cloud Storage)
bq extract --destination_format=NEWLINE_DELIMITED_JSON \
  mydataset.events gs://my-bucket/events-*.jsonl

Add --ignore_unknown_values when your JSONL carries fields the table does not declare, or the load job fails on the first surprise.

ClickHouse

ClickHouse names the format explicitly: JSONEachRow is JSONL, and it works symmetrically for input and output.

# Load
clickhouse-client \
  --query="INSERT INTO events FORMAT JSONEachRow" \
  < data.jsonl

# Load, tolerating extra fields and missing columns
clickhouse-client \
  --input_format_skip_unknown_fields=1 \
  --input_format_null_as_default=1 \
  --query="INSERT INTO events FORMAT JSONEachRow" \
  < data.jsonl

# Export
clickhouse-client \
  --query="SELECT * FROM events FORMAT JSONEachRow" \
  > events.jsonl

The file table function reads a JSONL file in place, useful for inspecting a dump before committing to a schema.

YAML

YAML is a superset of JSON, so JSONL to YAML never loses structure. The reverse can, because YAML has anchors, aliases and comments, none of which survive.

Both Directions with yq

# YAML list to JSONL (indent 0 forces one line per record)
yq -o=json -I=0 '.[]' data.yaml > data.jsonl

# JSONL to a YAML list
jq -s '.' data.jsonl | yq -p=json -o=yaml > data.yaml

Watch YAML's implicit typing on the way in. Unquoted values such as yes, no, on and off can be read as booleans, and a bare 08 may be rejected or read as a string. Quote anything ambiguous.

Which Tool Should You Use

Most conversion pain comes from using a tool outside the job it was designed for. Pick by the shape of your work, not by what is already installed.

jq for Shell Pipelines

Use jq when the conversion is one step in a pipe and the input is already JSON or JSONL. Single binary, no runtime, streams naturally, and the most expressive filter language for reshaping nested records. Wrong choice for reading CSV, which it cannot do, and for aggregations that force the slurp flag on a file bigger than memory.

DuckDB for Large or SQL Shaped Work

DuckDB reads JSONL, CSV and Parquet, joins across all three, and writes any of them back with a single COPY. When the conversion involves filtering, joining or grouping, SQL beats a chain of shell filters. Its readers accept glob patterns, so a whole directory converts in one statement. The caveat is schema inference from a sample, which you should override on messy data.

pandas or Polars for Python Workflows

If the conversion is one line inside a script that also cleans or models the data, stay in the data frame library you already use. pandas has the widest format coverage, including the only convenient XLSX writer here. Polars streams files larger than memory and has stricter typing that surfaces schema problems instead of coercing them away.

Miller or qsv for CSV Heavy Work

When the awkward side is the CSV, use a tool built for CSV. Miller speaks CSV, TSV, JSON, JSONL and fixed width, converts between any pair with two flags, and has verbs like unsparsify and flatten for the ragged record problem. qsv is a Rust CSV toolkit with dedicated JSONL subcommands.

What Every Conversion Costs You

No conversion here is free, and the failures are quiet. A file that converts without an error message can still be wrong. If a round trip matters, keep the original and compare against it.

Nesting Flattened to CSV

Objects become dotted column names; arrays become a serialized string or extra rows. Neither survives a return trip cleanly, and an empty array and a missing key both flatten to an empty cell. For deeply nested records, Parquet or a database beats CSV.

Type Coercion on the Way In

Any tool reading CSV or Excel guesses a type per column, and any tool writing Parquet must commit to one. A column holding both integers and strings is unified into whatever the sampler saw, or rejected. Declare your schema explicitly on non-uniform data.

Precision on Large Integers

JSON does not specify a numeric range, so parsers choose. JavaScript numbers are IEEE 754 doubles, exact only up to MAX_SAFE_INTEGER, and Excel documents a 15 significant digit limit. Long identifiers belong in your JSONL as strings, not numbers.

Null Versus Empty Versus Missing

JSONL distinguishes three states: key absent, key present and null, key holding an empty string. CSV has one empty cell for all three, and pandas adds a not a number sentinel in the middle. Pick a mapping and apply it consistently.

Key Ordering and Duplicate Keys

JSON objects are unordered by specification, and most tools reorder or alphabetize keys silently, so normalize before diffing two JSONL files. Duplicate keys in one object are also handled inconsistently, with most parsers keeping the last.

Dates, Times and Time Zones

JSON has no date type, so a timestamp is a string or a number and every tool here has an opinion about which. Parquet and SQL databases have real timestamp types with time zones; CSV and Excel do not. Standardize on one unambiguous string format.

A Habit That Prevents Most of This

Validate before and after. Check the source so you are not converting something already broken, convert a small sample and inspect it by eye, then compare record counts on both sides. Counting lines is the cheapest regression test there is, and it catches truncated writes and silently skipped records. The validator reports line counts, per line errors and schema consistency in one pass.

Keep Going

Convert in the browser, dig into the full toolchain, read the format rules, or see how JSONL compares to the alternatives.