← All articles

Flatten Nested JSON to CSV Without Losing or Duplicating Rows

To flatten nested JSON to CSV safely, validate the file first, convert nested objects into dot-notation columns, and move each nested array into its own table linked by a parent ID. Flattening an array into the parent table repeats parent values once per child item and silently removes parents whose array is empty. Splitting the data keeps one row per real record, so counts and totals stay correct.

Why nesting breaks tables

A CSV file is a single two-dimensional grid. JSON is a tree, and a tree can hold a one-to-many relationship inside a single record. An order with three line items is one object in JSON, but a grid has no way to place three items inside one cell without either repeating the order or cramming the items into a string.

Take this export from an orders API:

{
  "orders": [
    { "order_id": 1001, "customer": { "name": "Ana", "country": "BD" }, "total": 59.97,
      "items": [ { "sku": "A1", "qty": 2, "price": 9.99 }, { "sku": "B2", "qty": 1, "price": 39.99 } ] },
    { "order_id": 1002, "customer": { "name": "Raj", "country": "IN" }, "total": 19.00,
      "items": [ { "sku": "C3", "qty": 1, "price": 19.00 } ] },
    { "order_id": 1003, "customer": { "name": "Mei", "country": "SG" }, "total": 0.00,
      "items": [] }
  ]
}

This is 3 orders and 3 line items. If a tool expands every array element into its own row and repeats the parent fields, the output has 3 rows (1001 twice, 1002 once). The row count looks plausible, but order 1003 is gone because its empty array produced zero rows, and SUM(total) returns 138.94 instead of the true 78.97, because the 59.97 on order 1001 is counted twice.

Two distinct failures cause this:

  • Row multiplication: every parent-level value is repeated once per child, so any aggregation of parent fields is inflated.
  • Row loss: an empty array or a missing key produces no child rows, so an inner-join style expansion drops the parent entirely.

Validating before converting

Converters parse the whole document before producing a single row, so one syntax error stops everything. JSON is defined by RFC 8259, which is stricter than JavaScript object syntax. Many API exports, hand-edited files and log dumps break it in the same few ways.

ProblemInvalid exampleValid form
Trailing comma{"a": 1,}{"a": 1}
Single quotes{'a': 'x'}{"a": "x"}
Unquoted key{a: 1}{"a": 1}
Comment{"a": 1 // note}Remove the comment
Raw line break in a string"line1⏎line2""line1\nline2"
Unescaped quote in a string"5" pipe""5\" pipe"

Control characters from U+0000 to U+001F must be escaped inside strings, which is why a pasted multi-line address field often breaks parsing.

Step 1. Open the JSON Formatter & Validator and paste the raw export.

Step 2. Read the error message. It reports a line and column. For {"order_id": 1003, "total": 0.00,} the validator flags the closing brace because the trailing comma expects another key.

Step 3. Remove the comma, validate again, and confirm the document is valid. Then format it with 2-space indentation so that the array boundaries are visible before you plan the split.

Conversion strategies

There are two valid ways to turn nested JSON into CSV. They apply to different shapes of nesting.

Objects: dot-notation columns. A nested object has a one-to-one relationship with its parent, so it fits in the same row. The path becomes the column name: customer.name, customer.country. Row count does not change.

Arrays: separate tables. An array is one-to-many. Give each array its own CSV and carry the parent key (order_id) into every child row. This keeps one row per real entity, which is what Excel pivots, Power BI relationships and database tables expect.

Here is the workflow for the sample data.

Step 1: Convert the parent table. In the JSON to CSV Converter, paste the orders array. Nested objects become dot-notation columns. Remove the items array from the parent input first, or exclude that column, so it does not appear as a serialized string.

Expected output (3 rows, one per order):

order_id,customer.name,customer.country,total
1001,Ana,BD,59.97
1002,Raj,IN,19.00
1003,Mei,SG,0.00

Step 2: Convert the child table. Build an items array that adds the parent key to each element, then convert it:

[
  { "order_id": 1001, "sku": "A1", "qty": 2, "price": 9.99 },
  { "order_id": 1001, "sku": "B2", "qty": 1, "price": 39.99 },
  { "order_id": 1002, "sku": "C3", "qty": 1, "price": 19.00 }
]

Expected output (3 rows, one per line item):

order_id,sku,qty,price
1001,A1,2,9.99
1001,B2,1,39.99
1002,C3,1,19.00

Step 3: Reconcile counts. The parent file must have 3 rows, matching the number of objects in orders. The child file must have 3 rows, matching the sum of all items lengths (2 + 1 + 0). If either number differs, a record was lost or duplicated.

Step 4: Check the totals in SQL. Import both CSVs into tables named orders and order_items. In the SQL Query Generator, describe the schema and the goal: “Tables orders(order_id, total) and order_items(order_id, sku, qty, price). Count orders with no items and sum order totals once per order.” A correct query looks like this:

SELECT COUNT(*) AS orders_without_items, (SELECT SUM(total) FROM orders) AS revenue
FROM orders o
LEFT JOIN order_items i ON i.order_id = o.order_id
WHERE i.order_id IS NULL;

The result is 1, 78.97. Order 1003 is retained as an order with no items, and revenue is not inflated.

1. NESTED JSON order 1001 customer {…} total 59.97 items [ A1, B2 ] 1 parent → many children

split

2. PARENT TABLE (objects → dot-notation) order_id customer.name customer.country total 1001AnaBD59.97 1002RajIN19.00 1003MeiSG0.00

linked by order_id

3. CHILD TABLE (array → own rows) order_id sku qty price 1001A129.99 1001B2139.99 1002C3119.00

Querying JSON directly

If the JSON already lives in a database column, skip the CSV step. All three major engines can expand arrays into rows in SQL. The behavior on empty arrays differs by syntax, and that difference decides whether you lose parents.

MySQL: JSON_TABLE. A NESTED PATH clause expands the array. When the array has no elements, MySQL returns the parent row with NULL child columns.

SELECT o.order_id, o.total, o.sku, o.qty, o.price
FROM raw_orders r,
JSON_TABLE(r.doc, '$.orders[*]' COLUMNS (
  order_id INT PATH '$.order_id',
  total DECIMAL(10,2) PATH '$.total',
  NESTED PATH '$.items[*]' COLUMNS (
    sku VARCHAR(20) PATH '$.sku',
    qty INT PATH '$.qty',
    price DECIMAL(10,2) PATH '$.price'
  )
)) AS o;

PostgreSQL: jsonb_array_elements. This is a set-returning function. A plain CROSS JOIN against an empty array returns zero rows and drops the parent. Use LEFT JOIN LATERAL ... ON true to keep it.

SELECT (o.order_json->>'order_id')::int AS order_id,
       (o.order_json->>'total')::numeric AS total,
       i.item->>'sku' AS sku,
       (i.item->>'qty')::int AS qty
FROM raw_orders r
CROSS JOIN LATERAL jsonb_array_elements(r.doc->'orders') AS o(order_json)
LEFT JOIN LATERAL jsonb_array_elements(o.order_json->'items') AS i(item) ON true;

SQL Server: OPENJSON. Declare the array column AS JSON, then expand it with OUTER APPLY. CROSS APPLY would drop empty-array parents, just like an inner join.

SELECT o.order_id, o.total, i.sku, i.qty, i.price
FROM OPENJSON(@json, '$.orders') WITH (
  order_id INT '$.order_id',
  total DECIMAL(10,2) '$.total',
  items NVARCHAR(MAX) '$.items' AS JSON
) AS o
OUTER APPLY OPENJSON(o.items) WITH (
  sku NVARCHAR(20) '$.sku',
  qty INT '$.qty',
  price DECIMAL(10,2) '$.price'
) AS i;

Each query returns 4 rows for the sample data: 1001 twice, 1002 once, and 1003 with NULL item columns. That is the joined view, so aggregate parent fields from a separate parent query or with COUNT(DISTINCT order_id). To draft these statements for your own schema, describe the JSON paths and target columns in the SQL Query Generator and check the output against your engine’s syntax.

Edge cases to check before you trust the output

  • Missing keys: if some objects omit a field, a converter that builds headers from the first object only will drop the column. Confirm the header row contains every key.
  • Nested arrays inside arrays: each level is its own one-to-many relationship. Give each level its own table and carry both parent keys.
  • Arrays of scalars: a field like "tags": ["a","b"] is still one-to-many. Either create a order_id,tag child table or join the values with a delimiter, if you never filter on individual tags.
  • Commas, quotes and line breaks in values: RFC 4180 requires fields containing these characters to be wrapped in double quotes, with internal quotes doubled ("5"" pipe"). A converter that skips this shifts columns to the right.
  • Leading zeros and long IDs: Excel converts 00123 to 123 and shows numbers over 15 digits in scientific notation. Import the column as text through Data > From Text/CSV.
  • Excel limits: a worksheet holds 1,048,576 rows and 16,384 columns. Flattened arrays that multiply rows can pass this limit, which is another reason to keep child tables separate.
  • Delimiter and locale: Excel in some regional settings expects semicolons. If every value lands in column A, the delimiter does not match the locale.

Final tip: before sharing any flattened file, record two numbers from the source JSON, the count of parent objects and the sum of all array lengths, and confirm that your parent and child CSVs match them exactly.

Frequently asked questions

How do you flatten nested JSON arrays into CSV without duplicating rows?

Split the data into a parent table and a child table linked by a shared ID. Convert objects to dot-notation columns, export each array as its own CSV, and join them only when needed. Joining early repeats parent values in every row.

Why does my JSON to CSV conversion fail with an unexpected token error?

The JSON is invalid. Common causes are trailing commas, single-quoted strings, unquoted keys, comments, or raw line breaks inside strings. Run the file through a validator, fix the reported line and column, then convert again. Parsers reject the whole file.

Get Custom Help