Guides / Mixed types

JSON to CSV: Handle Mixed Object and Scalar Array Elements

When a JSON array contains both objects and bare scalars, a naive exporter can silently invent phantom columns or coerce values into the wrong shape. Use an explicit type contract that classifies each element before writing.

Short answer

A JSON array like [{"id":"S01"}, "free text", 42] mixes structured records with bare scalars. Do not treat every element as an object. Classify each element first: objects with an id field become structured data rows; everything else becomes a scalar row with explicit scalar_label, scalar_type and scalar_repr columns. This keeps every element traceable and prevents a bare string from being silently dropped into a phantom _scalar column that pollutes the header for every real record.

This guide demonstrates a measured five-element fixture exported locally on 2026-10-30 with Python 3.13.14. The source array contains three objects and two scalars (a string and an integer). The explicit-contract exporter writes five CSV rows with six columns; every field was read back and matched exactly. A naive exporter that treats all elements uniformly produces a mixed _scalar column and serializes object values with Python repr instead of JSON. This is a local recipe, not a measurement of this site's browser converter.

The five-element fixture

The source array has three objects (each with an id) and two bare scalars. The second object's value is itself a nested object, so a correct exporter must serialize it as JSON rather than a Python literal. The first record's value contains a comma, testing CSV quoting.

[
  {"id": "S01", "type": "string", "value": "hello, world"},
  "free-text annotation",
  {"id": "S02", "type": "object", "value": {"nested": true}},
  {"id": "S03", "type": "string", "value": "plain"},
  42
]

What a naive exporter gets wrong

A common shortcut collects every key from every element into one header set, adding a _scalar column for non-objects. This produces a single mixed schema where real fields share a row with scalar dumps. The table below records the measured output of that approach:

ProblemNaive behaviourMeasured result
Phantom columnNon-objects get a _scalar columnHeader includes _scalar alongside id, type, value
Wrong serializationDict value written with Python repr{"nested": True} instead of {"nested": true}
Lost type infoScalar type not recordedInteger 42 stored as a bare string with no type column
Schema pollutionEvery row has every columnObject rows have empty _scalar; scalar rows have empty id/type/value

The explicit type contract

Instead of one mixed schema, classify each element before writing. Objects become structured rows with their own fields; scalars get a labelled row with the original type preserved. The contract is a fixed six-column schema that both kinds share, with mutually exclusive fields left empty:

SCHEMA = ["id", "type", "value", "scalar_label", "scalar_type", "scalar_repr"]

For an object element, id, type and value are populated; the scalar columns are empty. For a scalar element, scalar_label, scalar_type and scalar_repr are populated; the object columns are empty. A reader can distinguish the two by checking whether scalar_label is non-empty. The RFC 8259 array grammar allows any mixture of types; the contract makes that explicit in the CSV.

Measured export results

The exporter was run locally on 2026-10-30 with Python 3.13.14. Source SHA-256: 353e70ac…147a (210 bytes). Output SHA-256: 6da3d3b7…bd46 (200 bytes). The CSV was read back with Python's csv.DictReader and every field compared against the parsed source.

IndexKindFields populatedRead-back match
0Objectid, type, valueid ✓ type ✓ value ✓
1Scalar (string)scalar_label, scalar_type, scalar_reprtype ✓ repr ✓
2Objectid, type, value (nested JSON)id ✓ type ✓ value ✓
3Objectid, type, valueid ✓ type ✓ value ✓
4Scalar (integer)scalar_label, scalar_type, scalar_reprtype ✓ repr ✓

All five rows, three object checks and two scalar checks passed. The CSV contains 5 data rows and 6 columns. The nested object value {"nested": true} was serialized as JSON (lowercase true) in the value column, not as a Python repr. The comma in "hello, world" was correctly quoted.

The exporter code

import argparse, json, sys, hashlib
from pathlib import Path

SCHEMA = ["id", "type", "value", "scalar_label", "scalar_type", "scalar_repr"]

def classify(element):
    if isinstance(element, dict):
        return "object", element
    if isinstance(element, str):
        return "scalar", {"scalar_type": "string", "scalar_repr": element}
    if isinstance(element, bool):
        return "scalar", {"scalar_type": "boolean",
                          "scalar_repr": "true" if element else "false"}
    if isinstance(element, int) and not isinstance(element, bool):
        return "scalar", {"scalar_type": "integer", "scalar_repr": str(element)}
    if isinstance(element, float):
        return "scalar", {"scalar_type": "float", "scalar_repr": repr(element)}
    if element is None:
        return "scalar", {"scalar_type": "null", "scalar_repr": ""}
    return "scalar", {"scalar_type": type(element).__name__, "scalar_repr": str(element)}

def escape_csv(value):
    s = "" if value is None else str(value)
    if any(c in s for c in [",", '"', "\r", "\n"]):
        s = '"' + s.replace('"', '""') + '"'
    return s

def export(records, output_path):
    rows = []
    for idx, element in enumerate(records):
        kind, fields = classify(element)
        row = {col: "" for col in SCHEMA}
        if kind == "object":
            if "id" not in fields:
                continue  # reject objects without id
            row["id"] = str(fields.get("id", ""))
            row["type"] = str(fields.get("type", ""))
            val = fields.get("value", "")
            if isinstance(val, (dict, list)):
                row["value"] = json.dumps(val, ensure_ascii=False)
            else:
                row["value"] = str(val) if val is not None else ""
        else:
            row["scalar_label"] = "scalar"
            row["scalar_type"] = fields["scalar_type"]
            row["scalar_repr"] = fields["scalar_repr"]
        rows.append(row)
    lines = [",".join(SCHEMA)]
    for row in rows:
        lines.append(",".join(escape_csv(row[col]) for col in SCHEMA))
    output_path.write_text("\r\n".join(lines) + "\r\n", encoding="utf-8")
    return {"csv_data_rows": len(rows), "csv_columns": len(SCHEMA)}

Download the full script and run it yourself. The exporter never modifies the source file, never accesses a network resource, and creates the output only if every element is classified successfully.

Download the fixture, exporter and CSV files

Fixture JSON Python exporter Safe CSV output Naive CSV output
python mixed-types-export.py mixed-types-source.json --output mixed-types-output.csv

Scope and related guides

This page covers one problem: exporting a JSON array whose elements mix objects and bare scalars (strings, numbers, booleans, null) to CSV without inventing phantom columns or losing type information. It does not claim that every converter has this problem, and it is not a general type-coercion guide. The bool check in classify comes before the int check because in Python True is an instance of int; this ordering is documented in the Python data model.

For related array and type problems, see primitive array to CSV (homogeneous scalar arrays), null, missing and empty fields (field-level state), flattened column-name collisions (dotted keys versus nested paths), and row and field verification after export.