Guides / Input validity

NaN in JSON to CSV: Reject Bare Constants, Keep Quoted Text

Short answer: Reject unquoted NaN, Infinity and -Infinity at the JSON parser boundary. Keep quoted strings such as "NaN" unchanged. Do not replace those words across the raw file: the same spelling can be legitimate text inside a string.

This guide solves one input-policy problem before a CSV export. It is a local Python recipe for an array of exact id/text string objects, not a claim about this site's browser converter or pandas missing-value handling.

The same letters can mean different input

[{"id":"N1","text":NaN}]
[{"id":"S1","text":"NaN"}]

The first document contains a bare extension token. The second contains an ordinary string. RFC 8259's number grammar excludes NaN and Infinity. That restriction does not prohibit their letters inside JSON strings.

What our original fixture actually produced

We ran the downloadable exporter locally on 2026-10-23. These are the exact inputs and observed results, not measured behavior of an online converter. No output file existed before each run.

bare-nan

[{"id":"N1","text":NaN}]

Rejected before opening the CSV destination. Error:

Non-JSON constant: NaN

bare-infinity

[{"id":"N2","text":Infinity}]

Rejected before opening the CSV destination. Error:

Non-JSON constant: Infinity

bare-negative-infinity

[{"id":"N3","text":-Infinity}]

Rejected before opening the CSV destination. Error:

Non-JSON constant: -Infinity

quoted-tokens

[{"id":"S1","text":"NaN"},{"id":"S2","text":"Infinity"}]

Accepted. CSV output:

id,text
S1,NaN
S2,Infinity

embedded-words

[{"id":"S3","text":"NaN report: Infinity is a label"}]

Accepted. CSV output:

id,text
S3,NaN report: Infinity is a label

The quoted-token output was read back and compared exactly to the expected CSV text. A CSV field containing NaN does not itself declare a numeric missing value; the importing application's type rules are a separate concern. Import this column as text when that distinction matters.

Put rejection in the parser, not a text substitution

Python's default decoder accepts these nonstandard constants. Its parse_constant callback lets this recipe reject them while leaving strings alone; see the official decoder compliance notes. Changing the encoder's allow_nan option is not a decoder setting.

def reject_constant(token):
    raise ValueError("Non-JSON constant: " + token)

rows = json.loads(raw, parse_constant=reject_constant)

A replacement such as raw.replace("NaN", "null") would also change "NaN report: Infinity is a label". Even if the modified document parses, that does not establish that its text still matches the source. Do not describe that operation as a harmless cleanup.

Download and reproduce the narrow export

Download the complete Python exporter. It validates the full demonstrated schema before opening a new destination with exclusive creation. It refuses an existing destination, duplicate keys, non-string id/text values and a source/destination path match. Its 1,000,000-byte cap is a chosen demo policy, not a measured browser limit. Run it in a trusted local directory with a new output filename.

python reject-nonfinite-text-export.py nonfinite-quoted-tokens.json new-result.csv

Download quoted-token input, exact successful CSV, bare NaN input, bare Infinity input, negative Infinity input and embedded-words input.

The complete runnable code follows:

import csv, json, sys
from pathlib import Path

def reject_constant(token):
    raise ValueError("Non-JSON constant: " + token)

def unique_keys(pairs):
    out = {}
    for key, value in pairs:
        if key in out:
            raise ValueError("Duplicate key: " + key)
        out[key] = value
    return out

def export(source, target):
    source, target = Path(source), Path(target)
    if source.resolve() == target.resolve():
        raise ValueError("Source and destination must differ")
    if source.stat().st_size > 1_000_000:
        raise ValueError("Demo input cap exceeded")
    data = json.loads(source.read_text(encoding="utf-8"),
        parse_constant=reject_constant, object_pairs_hook=unique_keys)
    if not isinstance(data, list):
        raise ValueError("Expected an array")
    for row in data:
        if not isinstance(row, dict) or set(row) != {"id", "text"}:
            raise ValueError("Expected exact id/text objects")
        if not all(isinstance(row[k], str) for k in ("id", "text")):
            raise ValueError("id and text must be strings")
    with target.open("x", encoding="utf-8", newline="") as f:
        w = csv.writer(f, lineterminator="\n")
        w.writerow(["id", "text"])
        w.writerows((r["id"], r["text"]) for r in data)

if __name__ == "__main__":
    try:
        export(sys.argv[1], sys.argv[2])
    except (ValueError, OSError) as e:
        print(str(e), file=sys.stderr)
        sys.exit(1)

Choose a producer policy explicitly

If the upstream measurement really is non-finite, ask its producer to select a documented representation before serialization: a string label, a separate status field, or null with an agreed meaning. This exporter chooses rejection and does not make that business decision for you. A quoted word is preserved because it was supplied as text, not because we guessed that it represented a missing number.

This hook is not a universal JSON or numeric validator. A valid exponent such as 1e400 is a different problem: it is not one of the three extension tokens, and a decoder's numeric representation can overflow. This recipe's string-field schema rejects any numeric text value; numeric export needs a separate explicit range/representation policy. See the decimal token guide. For null versus missing fields, use the field-state guide rather than silently substituting either one here.

Before handing off the result, compare parsed fields against the accepted records using row and field verification. Exclusive creation avoids overwriting an existing file, but does not provide crash durability or automatic cleanup after a disk-write failure.