Convert & export data · 4 min read

Nested JSON to CSV in Python: One Row per Array Item

Nested JSON can contain one order with several items. Converting that structure into rows requires a decision about what each row represents.

Define a row before flattening anything

The example contains two orders. Order 0007 has two items and order 0008 has none. We want a flat order-item export while retaining visibility of the empty order. The columns are order_id, customer_name, sku and quantity; the output therefore contains three rows.

That empty-order rule is a choice, not a universal JSON conversion rule. An item-only report might deliberately omit order 0008. A customer summary might instead require one row per order and an aggregated item count. Write the intended meaning down before running a converter.

Source structureRule hereOutput meaning
Customer objectSelect its name fieldOne customer_name column
Two item objectsExpand the items arrayTwo rows with repeated order fields
Empty items arrayKeep one blank-item rowOrder exists but has no item records
Unexpected JSON keyReject this input schemaReview what must be included
Nested orders: 0007: two items; 0008: zero items. Chosen flat result: 0007: item 00123; 0007: item 00456; 0008: blank item. Synthetic result under an explicit keep-empty-orders policy.
Illustrated example. Synthetic result under an explicit keep-empty-orders policy.

Save the input and the complete converter

Save the sample as orders.json and the script below as flatten_orders.py in a working folder. Use Python 3; the script needs no third-party package. Run python flatten_orders.py orders.json output.csv and choose a new output filename, because the script refuses to overwrite an existing file.

The strict field checks make the example intentionally specific. If your JSON has another customer field or a second array, decide how it belongs in the output and update the schema and mapping together. Deleting the checks without reviewing those fields can hide data loss.

flatten_orders.py — complete scriptDownload Python file ↓
import csv, json, sys

def unique_object(pairs):
    obj = {}
    for key, value in pairs:
        if key in obj:
            raise ValueError(f"Duplicate JSON key: {key}")
        obj[key] = value
    return obj

def bad_constant(value):
    raise ValueError(f"Not a JSON number: {value}")

def fields(obj, names):
    if not isinstance(obj, dict) or set(obj) != set(names):
        raise ValueError(f"Expected exactly these keys: {names}")

if len(sys.argv) != 3:
    raise SystemExit("Usage: python flatten_orders.py orders.json output.csv")
with open(sys.argv[1], encoding="utf-8-sig") as f:
    orders = json.load(f, object_pairs_hook=unique_object,
                       parse_constant=bad_constant)
if not isinstance(orders, list):
    raise ValueError("Top level must be an array")
output, seen = [], set()
for order in orders:
    fields(order, ["order_id", "customer", "items"])
    key = order["order_id"]
    if not isinstance(key, str) or not key.strip() or key in seen:
        raise ValueError("Order IDs must be unique nonempty strings")
    seen.add(key)
    fields(order["customer"], ["name"])
    name = order["customer"]["name"]
    items = order["items"]
    if not isinstance(name, str) or not isinstance(items, list):
        raise ValueError("Customer name must be text; items must be an array")
    if not items:
        output.append([key, name, "", ""])
    for item in items:
        fields(item, ["sku", "quantity"])
        sku, qty = item["sku"], item["quantity"]
        if not isinstance(sku, str) or not sku.strip():
            raise ValueError("SKU must be a nonempty string")
        if type(qty) is not int or qty < 0:
            raise ValueError("Quantity must be a nonnegative integer")
        output.append([key, name, sku, qty])
with open(sys.argv[2], "x", encoding="utf-8", newline="") as f:
    writer = csv.writer(f, lineterminator="\r\n")
    writer.writerow(["order_id", "customer_name", "sku", "quantity"])
    writer.writerows(output)
print(f"Wrote {len(output)} rows from {len(orders)} orders")

Read the result using its declared schema

The first two output rows repeat the same order ID and customer name because they belong to the same order. Counting CSV rows now counts exported order-item rows, not orders. Count distinct order IDs separately if you need the order total.

IDs remain strings throughout this converter. Quantity is a nonnegative JSON integer for real items; a boolean is rejected even though some programming environments treat booleans as a numeric subtype. The blank quantity on the preserved empty order is a different case from an item with quantity zero.

Expected CSV fields
order_id,customer_name,sku,quantity
0007,Mina,00123,2
0007,Mina,00456,1
0008,José,,

Do not expand unrelated arrays without a relationship rule

Imagine adding three shipment records to the order that already has two items. Expanding both arrays independently can create six item-shipment combinations. Unless every item belongs to every shipment, those six rows describe relationships the input did not establish.

Instead, export the arrays as separate tables linked by order ID, or use a documented item-to-shipment key. In Power Query, expanding a record into columns and expanding a list into rows are different operations. Inspect row counts after each expansion rather than only after the final load.

One order: Items: 2; Shipments: 3. Blind double expansion: 2 × 3 = 6 rows; Pairings may be invented. Illustrative warning: the source needs a relationship rule before combining independent arrays.
Illustrated example. Illustrative warning: the source needs a relationship rule before combining independent arrays.

Check the boundaries of this small converter

The script accepts a top-level array with exactly the documented keys. Duplicate JSON object keys and nonstandard numeric constants are rejected. Order IDs must be unique, nonblank strings. An empty top-level array produces only the CSV header, which is a valid empty result for this schema.

The input and output are held in memory before writing, so this example is intended for modest exports. Larger or changing schemas need a designed streaming or tabular process. The script does not evaluate spreadsheet formulas, and it does not sanitize cell contents for every downstream spreadsheet policy.

Verify rows and values before importing the CSV

Confirm three rows, two distinct orders and the preserved identifiers 0007 and 00123. Read the generated CSV with a CSV parser when checking commas or line breaks inside names. Counting physical text lines is not a reliable record count when fields contain newlines.

The published script was executed on the synthetic example and rejection cases. No Power Query UI execution is claimed. Column Harbor does not accept JSON or flatten nested structures; run this script locally, then inspect the resulting CSV using the tool appropriate to your destination.

Related help: Count CSV records correctly when fields contain newlines

Common questions

Why keep a row for an empty array?

This example preserves the existence of an order with no items. If your output is strictly an item table, omitting that row may be correct, but document and test the changed rule.

Can the script convert any JSON file?

No. It validates one order schema so missing or unfamiliar fields cannot disappear unnoticed. Adapt the schema and expected output together for another structure.

Will the CSV keep leading-zero IDs in Excel?

The output text keeps the zeros. Excel can still reinterpret a field when opening the file, so import identifier columns as Text and inspect the source values.

Sources & method

Examples and diagrams use synthetic data. Application instructions follow the linked documentation; available menus and options can vary. Our sample checks do not establish behavior in every Excel or Google Sheets version. Read our AI-assisted editorial method.

Browse all field guides →