On this page
How to Flatten Nested Parquet Columns to CSV or JSON
Why nesting breaks CSV
Parquet stores nested data natively: a STRUCT column holds named fields, a
LIST column holds a variable number of values per row. CSV has neither
concept. When you export directly, those columns arrive as a single opaque
cell — usually something like {'city': 'Tokyo', 'zip': '150-0001'} — which
looks like JSON but is not: DuckDB renders struct values with single quotes,
so any downstream json.loads fails immediately.
Deciding how to flatten is the actual work. Start by looking at what you have.
Step 1: see the nested types
Drop the file into the Parquet Viewer, or ask DuckDB:
DESCRIBE SELECT * FROM 'events.parquet';
Nested columns show up as STRUCT(city VARCHAR, zip VARCHAR) or
VARCHAR[]. Everything else is already CSV-safe.
Step 2a: promote struct fields to columns
Dot notation reads a single field. Combine it with EXCLUDE to drop the
original struct in the same select:
SELECT * EXCLUDE (address),
address.city AS address_city,
address.zip AS address_zip
FROM 'events.parquet';
For a wide or deeply nested struct, expand everything at once:
SELECT id, unnest(address, recursive := true)
FROM 'events.parquet';
recursive := true also flattens structs inside structs. It is faster to
write, but the generated column names come from the field names alone, so
two nested fields called id collide — explicit aliases are safer for
anything you will re-import later.
Step 2b: keep the struct, but as real JSON
When the fields vary or you just need the data to survive the round trip, stringify instead of expanding:
SELECT * REPLACE (to_json(address) AS address)
FROM 'events.parquet';
REPLACE swaps one column in place and leaves the rest of the select
untouched. The CSV now contains {"city":"Tokyo","zip":"150-0001"} — valid
JSON, parseable by anything.
Step 2c: lists become rows, or a delimited string
To explode a list into rows:
SELECT id, unnest(tags) AS tag
FROM 'events.parquet';
Each input row produces one row per element, so a 10,000-row file with three tags each returns 30,000 rows. When the output must stay one row per record, join the elements instead:
SELECT id, array_to_string(tags, '|') AS tags
FROM 'events.parquet';
Step 3: export
In the SQL Workbench, run the flattening query and download the
result as CSV. Note that the workbench registers each dropped file under its
exact name, so reference 'events.parquet' literally — glob patterns have
nothing to expand against in the browser.
From a terminal:
duckdb -c "COPY (SELECT * EXCLUDE (address), address.city AS address_city
FROM 'events.parquet') TO 'flat.csv' (HEADER)"
Or do not flatten at all
Flattening is only necessary because CSV is flat. If whatever consumes the data reads JSON, export nesting intact and skip this entire problem:
duckdb -c "COPY (SELECT * FROM 'events.parquet') TO 'events.jsonl' (FORMAT json)"
DuckDB writes newline-delimited JSON by default; add ARRAY true for a
single JSON array. The browser equivalents are the
Parquet to JSON and
Parquet to JSONL converters, both of which
preserve structs and lists as-is.
Frequently asked questions
- Why does my struct column look like {'city': 'Tokyo'} in the CSV?
- That is DuckDB's text rendering of a STRUCT value, not JSON — it uses single quotes, so json.loads rejects it downstream. Wrap the column in to_json() before exporting if you want a parseable string.
- How do I expand a struct into one column per field?
- Select the fields by name with dot notation, or use unnest(col, recursive := true) to expand every field at once, including fields of nested structs.
- What happens to row counts when I unnest a list column?
- One input row becomes one row per list element, so the result grows. Use array_to_string(col, '|') instead when you need to keep exactly one row per record.
- Is there a way to avoid flattening altogether?
- Yes, if the consumer accepts JSON. Exporting to JSON or JSONL preserves the nested structure exactly, so no information is lost and no column naming scheme is needed.