Guides / Large files

Convert Large JSON to CSV Without Losing Late Columns

Keep an unchanged local JSON file and stream it twice: first collect every column name, then write one record at a time using that fixed header. This avoids holding every record in a list and keeps fields that appear near the end. The downloadable example targets flat objects; it refuses nested values instead of silently flattening them.

1 · Freeze input2 · Discover columns3 · Stream CSV4 · Finalize

A browser JSON to CSV converter is convenient for inspectable inputs. If the file is too large for your workflow, use a local export. There is no universal safe file-size cutoff: the largest record, schema width, parser and available memory all matter.

Two passes retain a region column that appears only in the last record, followed by source-hash and count checks.
Original fixture: region first appears on record L03. The measured export retains it for all three records.

Use the right record prefix

The example input has an object containing data.records, an array of objects. For ijson, select it with data.records.item; a top-level array uses item. This syntax is ijson's prefix syntax, not the browser tool's array-path syntax. Open the file in binary mode and iterate the selected items. The parser constructs each selected object, so one huge object can still require substantial memory.

{"data":{"records":[
  {"id":"L01","name":"Cedar"},
  {"id":"L02","name":"Maple"},
  {"id":"L03","name":"Birch","region":"West"}
]}}
Download the reproducible workflowExample JSONPython exporterMeasured CSVCompleted manifest

Run on a frozen local copy

python -m pip install ijson
python stream-json-csv.py streaming-sample.json data.records.item run-001

Use a new output directory. The example scans the entire input for keys before opening the CSV. It writes a partial file on pass two, checks the record count and source SHA-256 before finalizing, and writes a completed manifest. Keep the source unchanged throughout; hashes around the run detect ordinary changes but are not a substitute for a locked snapshot. Only consume an export with its completed manifest.

Read the complete original exporter
"""Two-pass flat-record exporter. Requires ijson; input must remain unchanged.
python stream-json-csv.py input.json data.records.item new-output-directory
"""
import csv, hashlib, json, os, sys
from pathlib import Path
from decimal import Decimal
import ijson

def digest(path):
    h=hashlib.sha256()
    with path.open('rb') as f:
        for block in iter(lambda:f.read(1024*1024), b''): h.update(block)
    return h.hexdigest()

def records(path,prefix):
    with path.open('rb') as f:
        for row in ijson.items(f,prefix,use_float=False):
            if not isinstance(row,dict): raise ValueError('selected items must be objects')
            if any(isinstance(v,(dict,list)) for v in row.values()):
                raise ValueError('flat records only; choose a nested-data policy first')
            yield row

def cell(value):
    if value is None: return ''
    if isinstance(value,bool): return 'true' if value else 'false'
    return str(value)

def export(source,prefix,out):
    source=Path(source); out=Path(out)
    before=digest(source)
    columns={}; count=0
    for row in records(source,prefix):
        count+=1
        for key in row: columns.setdefault(key,None)
    if not count or not columns: raise ValueError('no exportable records; check prefix and input')
    out.mkdir(exist_ok=False)
    partial=out/'records.csv.partial'; written=0
    try:
        with partial.open('w',encoding='utf-8',newline='') as f:
            writer=csv.writer(f); writer.writerow(columns)
            for row in records(source,prefix):
                if not row.keys()<=columns.keys(): raise ValueError('schema changed between passes')
                writer.writerow([cell(row.get(key)) for key in columns]); written+=1
        after=digest(source)
        if before!=after or written!=count: raise ValueError('input changed; export not finalized')
        report={'status':'complete','records':written,'columns':list(columns),
                'source_sha256':after,'prefix':prefix,'ijson_version':ijson.__version__,
                'backend':ijson.backend,'policy':'flat values; missing/null -> empty; no spreadsheet sanitization'}
        os.replace(partial,out/'records.csv')
        (out/'manifest.json').write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
        return report
    except Exception:
        # Keep partial output for diagnosis. Never treat it as a completed export.
        raise

if __name__=='__main__':
    print(json.dumps(export(*sys.argv[1:4])))

What this example actually writes

id,name,region
L01,Cedar,
L02,Maple,
L03,Birch,West

Local execution with ijson 3.5.1 produced 3 data records and 3 columns; reading the CSV with Python's CSV reader confirmed both. An intentionally wrong prefix stopped with ValueError: no exportable records; check prefix and input. These counts describe this small fixture, not a large-file capacity benchmark.

Question: Can streaming JSON keep fields that appear only in the last record?

Yes, when you scan all records for the column union before writing the header. Our last record introduces region, while the first two leave that cell blank. A header taken only from L01 would omit it. If you cannot reread the input, use a declared schema or spool records to disk before writing a final CSV. Do not keep changing a CSV header after records have already been written.

Know the memory and data boundaries

Python CSV documentation supports the writer and newline="" handling used here. ijson's maintained documentation describes item iteration, prefixes and number options.

Open the JSON to CSV converter · Collect API pages first · Audit JSONL lines

See the workflow