Guides / array index vs literal key

JSON to CSV: Distinguish Array Index [0] From Literal Key "[0]" in Column Headers

When a JSON object has a property whose name is the literal string "[0]" and a sibling array whose first element is at index 0, a naive dot-path flattener produces column headers that both contain [0] but refer to fundamentally different JSON structures. Per RFC 6901 (JSON Pointer), object keys and array indices have distinct path syntax: a key reference uses /key and an array index uses /0. An explicit path-marker column policy records the full JSON Pointer path, the path kind (object_key vs array_index), the segment type, the resolved value and the JSON serialization so a CSV reader can distinguish a literal key named "[0]" from array index 0 without re-parsing the original JSON.

Short answer

A JSON object like {"config": {"[0]": "first-literal-key", "items": ["alpha"]}} has two distinct paths that both involve [0]: the literal object key "[0]" (an object property whose name happens to be the string "[0]") and the array index items[0] (the first element of the items array). A naive dot-path flattener writes columns config.[0] and config.items[0] — both headers contain [0] but one refers to an object key and the other to an array index. Some flatteners further collapse config.items[0] to config.[0], creating an exact header collision. Instead of ambiguous dot-paths, declare a path-marker column policy with six columns: id, config_path (the JSON Pointer path, e.g. config/["0"] for the literal key and config/items/0 for the array index), config_path_kind (object_key or array_index), config_segment_type (bracket_string_key or integer_index), config_value (the resolved value), and config_json (the JSON serialization). This lets any CSV reader distinguish a literal object key from an array index by reading config_path_kind directly, without inspecting the path string or re-parsing JSON.

This guide demonstrates a measured six-record fixture exported locally on 2026-11-08 with Python 3.13.14. Each record has a config object containing a literal key "[0]" (a string property whose name is the four-character string [0]) and a sibling array items with one or two elements. The explicit exporter produces 12 CSV rows (6 records × 2 paths per record) with 6 columns; every field was read back and matched exactly (72/72 checks). A naive dot-path flattener writes 6 rows with 3 columns (id, config.[0], config.items[0]), producing headers where both config.[0] and config.items[0] contain [0] but refer to different JSON structures.

The six-record fixture

The source array contains six records. Each config object has a literal key "[0]" whose value is a descriptive string, and a sibling array items with one or two string elements:

[
  {"id": "R01", "config": {"[0]": "first-literal-key", "items": ["alpha"]}},
  {"id": "R02", "config": {"[0]": "second-literal-key", "items": ["beta", "gamma"]}},
  {"id": "R03", "config": {"[0]": "third-literal-key", "items": ["delta"]}},
  {"id": "R04", "config": {"[0]": "fourth-literal-key", "items": ["epsilon", "zeta"]}},
  {"id": "R05", "config": {"[0]": "fifth-literal-key", "items": ["eta"]}},
  {"id": "R06", "config": {"[0]": "sixth-literal-key", "items": ["theta", "iota"]}}
]

Download the original JSON sample. The key observation is that "[0]" as a JSON object key is a string — its name happens to look like an array index, but per RFC 8259 section 4, a JSON object is an unordered collection of name/value pairs where names are strings. The string "[0]" is a perfectly valid object key, distinct from the integer 0 used to index a JSON array. RFC 6901 (JSON Pointer) section 3 defines the syntax for referencing these: /["0"] or /0 for an array index (the reference token 0 is treated as an array index when the referenced value is an array), and /[0] would be a key reference if the referenced value is an object with that key.

What a naive dot-path flattener gets wrong

A naive dot-path flattener walks the JSON object and produces column headers by joining keys with dots and appending array indices in brackets. For R01 it produces two columns: config.[0] for the literal key "[0]" and config.items[0] for the first element of the items array. Both headers contain the substring [0], but one is an object property reference and the other is an array element reference:

HeaderWhat it refers toJSON structureValue in R01
config.[0]Object property whose name is the string "[0]"config["[0]"]first-literal-key
config.items[0]First element of the items arrayconfig.items[0]alpha

The naive CSV output shows the two columns side by side. A reader sees config.[0] and config.items[0] and must infer from the header naming convention which one is a literal key and which is an array index — but the convention is ambiguous:

id,config.[0],config.items[0]
R01,first-literal-key,alpha
R02,second-literal-key,beta
R03,third-literal-key,delta
R04,fourth-literal-key,epsilon
R05,fifth-literal-key,eta
R06,sixth-literal-key,theta

The problem becomes worse when a flattener strips intermediate keys. Some flatteners produce config.[0] for the literal key and config.[0] for the array index (collapsing config.items[0] to config.[0] by stripping items), creating an exact header collision. In that case, the two values first-literal-key and alpha would compete for the same column name, and one would overwrite the other silently.

The explicit path-marker column policy

Instead of ambiguous dot-path headers, expand each resolved path into a row with six columns that together carry the full path metadata:

SCHEMA = ["id", "config_path", "config_path_kind",
          "config_segment_type", "config_value", "config_json"]

The config_path column stores the JSON Pointer-style path using forward slashes as separators. For the literal key "[0]", the path is config/["0"] — the key is quoted to mark it as a string reference. For the array index, the path is config/items/0 — the index is a bare integer token. The config_path_kind column records whether the final segment is an object_key or an array_index. The config_segment_type column further classifies the segment: bracket_string_key for a key whose name contains bracket characters (like "[0]"), and integer_index for a numeric array position.

The config_value column stores the resolved value as a plain string. The config_json column stores json.dumps(value), which is the canonical JSON serialization. Python's csv module documentation describes the DictWriter used to write the output. The exporter uses open(path, "x") for exclusive file creation, refusing to overwrite an existing file.

Measured export results

The exporter was run locally on 2026-11-08 with Python 3.13.14. Source SHA-256: fc047f72…f05c (491 bytes). Output SHA-256: a4a637af…fbca (1,028 bytes). The CSV was read back with Python's csv.DictReader and every field compared against the parsed source. All 12 rows passed all 6 checks (72/72).

RowIDconfig_pathpath_kindsegment_typevalueRead-back
0R01config/["0"]object_keybracket_string_keyfirst-literal-key✓ all 6
1R01config/items/0array_indexinteger_indexalpha✓ all 6
2R02config/["0"]object_keybracket_string_keysecond-literal-key✓ all 6
3R02config/items/0array_indexinteger_indexbeta✓ all 6
4R03config/["0"]object_keybracket_string_keythird-literal-key✓ all 6
5R03config/items/0array_indexinteger_indexdelta✓ all 6
6R04config/["0"]object_keybracket_string_keyfourth-literal-key✓ all 6
7R04config/items/0array_indexinteger_indexepsilon✓ all 6
8R05config/["0"]object_keybracket_string_keyfifth-literal-key✓ all 6
9R05config/items/0array_indexinteger_indexeta✓ all 6
10R06config/["0"]object_keybracket_string_keysixth-literal-key✓ all 6
11R06config/items/0array_indexinteger_indextheta✓ all 6

All 12 rows passed all six checks (72/72). The CSV contains 12 data rows and 6 columns. The key distinction is between row 0 (config_path_kind=object_key, config_path=config/["0"]) and row 1 (config_path_kind=array_index, config_path=config/items/0): both are paths into the same config object, but one resolves a string-named property and the other resolves a numeric array position. The config_path_kind column lets a CSV reader filter object_key rows to get only literal key values, or filter array_index rows to get only array element values — without re-parsing the original JSON or guessing from header naming conventions.

The exporter code

import json, csv, hashlib
from pathlib import Path

SCHEMA = ["id", "config_path", "config_path_kind",
          "config_segment_type", "config_value", "config_json"]

def export_explicit(source_path, csv_path, naive_path, evidence_path):
    with open(source_path, "r", encoding="utf-8") as f:
        raw_text = f.read()
    source_sha = hashlib.sha256(raw_text.encode("utf-8")).hexdigest()
    records = json.loads(raw_text)

    rows = []
    for rec in records:
        rid = rec["id"]
        config = rec["config"]

        # Literal key "[0]" — an object property whose name is the string "[0]"
        literal_val = config["[0]"]
        row = {col: "" for col in SCHEMA}
        row["id"] = rid
        row["config_path"] = 'config/["0"]'
        row["config_path_kind"] = "object_key"
        row["config_segment_type"] = "bracket_string_key"
        row["config_value"] = literal_val
        row["config_json"] = json.dumps(literal_val)
        rows.append(row)

        # Array index [0] — the first element of the "items" array
        items = config["items"]
        if items:
            first_item = items[0]
            row2 = {col: "" for col in SCHEMA}
            row2["id"] = rid
            row2["config_path"] = "config/items/0"
            row2["config_path_kind"] = "array_index"
            row2["config_segment_type"] = "integer_index"
            row2["config_value"] = first_item
            row2["config_json"] = json.dumps(first_item)
            rows.append(row2)

    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 processes each record twice: once for the literal key "[0]" (recording object_key / bracket_string_key) and once for the first element of the items array (recording array_index / integer_index). The path string uses JSON Pointer-style forward-slash separators: config/["0"] for the object key (quoted to distinguish it from a numeric index) and config/items/0 for the array index (bare integer token). 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 id, config_path, config_path_kind, config_segment_type, config_value and config_json match the expected values. All 12 rows passed all six checks (72/72).

Why JSON Pointer matters for path disambiguation

Per RFC 6901 section 3, a JSON Pointer is a Unicode string containing a sequence of reference tokens, each prefixed by /. When the referenced value is an object, the reference token is a key name. When the referenced value is an array, the reference token is a decimal number representing the index. The key distinction is structural: the same token 0 can be either an object key (if the parent is an object with a key named "0") or an array index (if the parent is an array). The pointer itself does not encode which — the consumer must inspect the parent type to determine the meaning.

This is exactly the problem that the config_path_kind and config_segment_type columns solve. Instead of relying on the path string alone, the path-marker policy records the structural type explicitly. A reader does not need to inspect the parent JSON to know whether config/["0"] is an object key or an array index — the config_path_kind=object_key column tells them directly. The config_segment_type=bracket_string_key column further signals that the key name contains bracket characters, which is a pattern that commonly confuses dot-path flatteners.

Distinct from the flattened column-name collision guide. The flattened column-name collision guide addresses the case where two keys a.b (a literal dot in a key name) and a → b (a nested key) produce the same flattened header. This guide addresses a different collision: a literal key named "[0]" and an array index [0] that both produce headers containing [0]. The flattening rules and resolution strategies are distinct — the dot collision guide uses explicit path mapping with JSON Pointer; this guide adds path kind and segment type metadata to distinguish object keys from array indices.

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 path-marker-to-csv.py

The exporter reads at most 1,000,000 bytes and rejects inputs over that 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 distinguish a literal key "[0]" from an array index [0] by inspecting the CSV header alone?

You can try, but it is unreliable. A header like config.[0] could mean either an object property named "[0]" or an array index 0 on a child array — the dot-path convention does not encode the parent type. You can inspect the header for the presence of items as an intermediate key (e.g. config.items[0] is clearly an array index), but this fails when the flattener strips intermediate keys or when the object key is a simple number like "0" rather than "[0]". The path-marker column policy solves this by recording config_path_kind explicitly: object_key means the final segment is a property name (regardless of what it looks like), and array_index means it is a numeric position into an array. This metadata travels with the data, so downstream consumers do not need access to the original JSON or the flattener's conventions.