Guides / null vs empty object

JSON to CSV: Distinguish null From Empty Object {} in Every Row

When the same JSON field is null in one record and an empty object {} in the next, a naive CSV exporter writes the same blank cell for both. An explicit empty-value column policy records the state, key count and original JSON spelling so a reader can tell them apart and reconstruct the exact source value.

Short answer

A field like meta that is null in R01 and {} in R02 has two different JSON values that both look empty in a bare CSV cell. Do not write both into one meta column. Instead, expand the field into three columns: meta_state ("null", "empty_object" or "object"), meta_key_count (0 for null and empty object, N for a populated object), and meta_json (the original JSON spelling). This lets any CSV reader distinguish null from empty object, verify that no keys were lost, and reconstruct the original value exactly.

This guide demonstrates a measured five-record fixture exported locally on 2026-11-01 with Python 3.13.14. The source has two null records, two empty-object records, and one populated object. The explicit exporter writes five CSV rows with four columns; every field was read back and matched exactly. A naive exporter that writes a single meta column produces blank cells for both null and empty object, making the two indistinguishable.

The five-record fixture

The source array contains five records. The meta field is null in R01 and R04, an empty object {} in R02 and R05, and a populated object in R03. This alternation is the core problem: null and empty object both produce a blank-looking cell in a naive export.

[
  {"id": "R01", "meta": null},
  {"id": "R02", "meta": {}},
  {"id": "R03", "meta": {"role": "admin", "level": 3}},
  {"id": "R04", "meta": null},
  {"id": "R05", "meta": {}}
]

Download the original JSON sample. Per RFC 8259 section 3, null is a JSON literal value and {} is an empty object (a structural value with zero members). They are distinct JSON values with different types. A CSV cell cannot encode that distinction on its own; the exporter must carry it in a companion column.

What a naive exporter gets wrong

A naive exporter writes a single meta column. For null it writes a blank cell. For {} it also writes a blank cell. For a populated object it may write str(dict) or nothing at all. This creates two measurable problems:

ProblemWhat happensMeasured result
null and {} collapseR01 null and R02 {} both produce an empty CSV cellReader cannot tell which was null and which was an empty container
No key countR03 has 2 keys; the naive exporter writes str(dict) with Python reprPython repr {'role': 'admin', 'level': 3} is not valid JSON; single quotes differ from double quotes
No reconstruction pathR04 and R05 are also both blank4 of 5 records lose their type identity; only R03 has a non-blank cell

The naive CSV output shows these problems concretely. R01, R02, R04 and R05 are all blank cells. R03 uses Python's str() representation, which uses single quotes instead of valid JSON double quotes:

id,meta
R01,
R02,
R03,"{'role': 'admin', 'level': 3}"
R04,
R05,

The explicit empty-value column policy

Instead of one bare column, expand the field into three columns that together carry the full type information:

SCHEMA = ["id", "meta_state", "meta_key_count", "meta_json"]

For a null value, meta_state is "null", meta_key_count is 0, and meta_json is "null". For an empty object {}, meta_state is "empty_object", meta_key_count is 0, and meta_json is "{}". For a populated object, meta_state is "object", meta_key_count is the number of keys, and meta_json is the original JSON serialization.

The meta_json column always stores the exact JSON serialization of the original value, so a downstream reader can parse it back to recover the precise type and structure. The meta_key_count column provides a quick integrity check: if it is 0 and the state is "null", the reader knows there was no object. If it is 0 and the state is "empty_object", the reader knows an object existed but had no members. Python's csv module documentation describes the DictWriter used to write the output; our code supplies the schema before iterating over the input.

Measured export results

The exporter was run locally on 2026-11-01 with Python 3.13.14. Source SHA-256: 2a78d195…be94c (179 bytes). Output SHA-256: f222a3e0…48284 (172 bytes). The CSV was read back with Python's csv.DictReader and every field compared against the parsed source.

RowIDSource valuemeta_statemeta_key_countmeta_jsonRead-back
0R01nullnull0null✓ state ✓ count ✓ json
1R02{}empty_object0{}✓ state ✓ count ✓ json
2R03{"role":"admin","level":3}object2{"role": "admin", "level": 3}✓ state ✓ count ✓ json
3R04nullnull0null✓ state ✓ count ✓ json
4R05{}empty_object0{}✓ state ✓ count ✓ json

All five rows passed every check. The CSV contains 5 data rows and 4 columns. The meta_json column for R03 contains valid JSON with double quotes, quoted by the CSV writer because the value contains commas and double quotes. The meta_json for R01 and R04 is the literal string null, and for R02 and R05 it is {}. A reader can distinguish null from empty object by checking meta_state or by parsing meta_json.

The exporter code

import json, csv, os
from pathlib import Path

SCHEMA = ["id", "meta_state", "meta_key_count", "meta_json"]

def export_explicit(source_path, csv_path):
    with open(source_path, "r", encoding="utf-8") as f:
        raw_text = f.read()
    records = json.loads(raw_text)

    rows = []
    for rec in records:
        row = {col: "" for col in SCHEMA}
        row["id"] = rec["id"]
        meta = rec["meta"]
        row["meta_json"] = json.dumps(meta, ensure_ascii=False)

        if meta is None:
            row["meta_state"] = "null"
            row["meta_key_count"] = 0
        elif isinstance(meta, dict):
            if len(meta) == 0:
                row["meta_state"] = "empty_object"
                row["meta_key_count"] = 0
            else:
                row["meta_state"] = "object"
                row["meta_key_count"] = len(meta)
        else:
            raise ValueError(f"Unsupported meta type: {type(meta).__name__}")
        rows.append(row)

    # Exclusive file creation: refuse to overwrite
    with open(csv_path, "x", encoding="utf-8", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=SCHEMA)
        writer.writeheader()
        writer.writerows(rows)
    return rows, raw_text, records

The exporter classifies each value before writing. Null gets state="null" and count=0. An empty object gets state="empty_object" and count=0. A populated object gets state="object" and count=len(keys). The meta_json column always stores the exact JSON serialization of the original value, so a downstream reader can parse it back to recover the precise type and structure without ambiguity. The exporter uses open(csv_path, "x") for exclusive file creation, refusing to overwrite an existing output.

Read-back verification

The verification step reads the CSV back with csv.DictReader and compares every column against the parsed source. For each row it checks that meta_state matches the expected state, meta_key_count matches the expected count, and meta_json matches the JSON serialization. All five rows passed all three checks, for a total of 15 individual field comparisons plus 5 ID checks, all successful.

def read_back_verify(csv_path, records):
    with open(csv_path, "r", encoding="utf-8", newline="") as f:
        reader = csv.DictReader(f)
        csv_rows = list(reader)

    for csv_row, src_rec in zip(csv_rows, records):
        meta = src_rec["meta"]
        if meta is None:
            exp_state, exp_count, exp_json = "null", 0, "null"
        elif isinstance(meta, dict) and len(meta) == 0:
            exp_state, exp_count, exp_json = "empty_object", 0, "{}"
        elif isinstance(meta, dict):
            exp_state = "object"
            exp_count = len(meta)
            exp_json = json.dumps(meta, ensure_ascii=False)
        assert csv_row["meta_state"] == exp_state
        assert int(csv_row["meta_key_count"]) == exp_count
        assert csv_row["meta_json"] == exp_json

Why null and {} are not the same

RFC 8259 section 3 defines a JSON value as an object, array, number, string, true, false, or null. An empty object {} is a structural value: it has a type (object) and zero members. null is a distinct literal that means the absence of a value. In a JSON to CSV converter pipeline, conflating them can cause downstream systems to treat an empty container as a missing value or vice versa. For example, a record with "meta": {} may mean "the metadata object exists but has no properties yet", while "meta": null may mean "metadata was not provided at all". The state column makes that semantic difference travel with the data.

Distinct from the null/missing/empty guide. The earlier null, missing and empty field states guide distinguishes a missing key from null and "" for scalar fields. This guide solves a different problem: null versus an empty object {} for container fields. The two guides cover non-overlapping value types.

Reproduce the local example

These are independent Python examples, not measurements of the browser converter. Download the input, exporter, saved CSV, naive CSV and execution evidence into one folder. Use Python 3.12 or later and a new output filename.

Original JSONPython exporterExplicit CSVNaive CSVExecution evidence
python null-empty-object-to-csv.py null-empty-object-fixture.json new-output.csv new-naive.csv new-evidence.json

The exporter reads at most 1,000,001 bytes and rejects inputs over the 1,000,000-byte demonstration policy. That limit is a code policy, not a measured browser limit. The output uses UTF-8 and CRLF record endings. Exclusive file creation prevents overwriting an existing file. Faithful text conversion does not neutralize spreadsheet formulas, so use a suitable text import workflow for untrusted data.

Question: Can I just write null as the text "null" in the CSV cell?

You can, but then you must check every real string in your data for a collision with the token null. If any field legitimately contains the text "null", a reader cannot tell it from a JSON null marker. The state column avoids that collision entirely: meta_state = "null" is a typed label, not a value token, and meta_json carries the original JSON spelling for verification. The same logic applies to "{}": writing it as a bare cell value does not distinguish it from a string that happens to contain those two characters.