What the spec says
RFC 8259 §6 defines the JSON number grammar:
number = [ minus ] int [ frac ] [ exp ] NaN, Infinity, and -Infinity
do not match this grammar. Strict parsers reject them. The spec
also notes that JSON cannot represent the full range of IEEE 754,
which is why "valid JSON number" and "valid IEEE 754 double" are
not the same set.
What each language actually emits
| Language | Encodes NaN as | Encodes Infinity as | Strict mode? |
|---|---|---|---|
JavaScript (JSON.stringify) | null | null | n/a — silent |
Python (json.dumps) | NaN (invalid) | Infinity (invalid) | allow_nan=False raises |
Go (encoding/json) | Returns UnsupportedValueError | Same | Default; cannot disable |
Rust (serde_json) | Returns error | Same | Default |
| Java (Jackson) | NaN (invalid by default) | Same | QUOTE_NON_NUMERIC_NUMBERS emits "NaN" |
pandas (to_json) | null | null | n/a — silent |
NumPy (via json) | Inherits Python's behavior | Same | Same |
Two distinct failure modes:
- Silent (JavaScript, pandas): values become
null. Data loss, no error. - Invalid (Python default, Jackson default):
emits the bare token
NaN, which the same library will parse back, but every standards-compliant consumer rejects.
The pandas → browser pipeline (most common bug)
A backend computes a DataFrame, calls df.to_json(),
and ships it to a frontend. NaN values become
null silently. The frontend renders null
as "—" or 0 or skips the row, and the discrepancy with the source
Excel file is reported as a frontend bug. It isn't.
Fix: explicitly decide on the backend what missing means. Replace
NaN with a documented sentinel string ("missing") or
carry a separate "is_present" boolean alongside the number. Don't
let to_json's default decide.
Workarounds
1. Replace before serializing
// JS
const safe = JSON.stringify(value, (k, v) =>
typeof v === "number" && !Number.isFinite(v) ? null : v
);
# Python
import math
def clean(o):
if isinstance(o, float) and not math.isfinite(o): return None
if isinstance(o, dict): return {k: clean(v) for k, v in o.items()}
if isinstance(o, list): return [clean(x) for x in o]
return o
json.dumps(clean(value), allow_nan=False) 2. Encode as strings
If the value matters, send it as a string consumers know how to decode:
{"latency_ms": "Infinity"} Lossless, but every consumer needs to opt in.
3. Use JSON5 or NDJSON with extensions
JSON5 allows NaN and Infinity directly.
Some streaming dialects (e.g., extended JSON for MongoDB) define
{"$numberDouble": "NaN"} as the canonical
encoding. Either is fine if you control both ends.
Detection: is your JSON about to break someone?
Run the input through any strict parser other than the one that
produced it. If your producer is Python json with
defaults and your consumer is a browser, paste the output into
our linter — it uses the browser's strict parser
and will flag the bare NaN token.
Related
FAQ
Why does JSON forbid NaN and Infinity?
RFC 8259 defines a JSON number as a decimal literal: optional minus, integer part, optional fraction, optional exponent. NaN and Infinity aren't representable in that grammar. The original spec authors prioritized portability across languages that don't all have IEEE 754 specials.
What does JSON.stringify do with NaN?
It silently outputs 'null'. Same for +Infinity, -Infinity, and undefined. No error, no warning. This is the most common cause of mysterious nulls landing in databases.
What does Python's json.dumps do with NaN?
By default, json.dumps emits the literal string 'NaN' (without quotes), which is valid Python but not valid JSON — and json.loads accepts it back, which is what makes the bug feel local. Pass allow_nan=False to force a ValueError. Standards-compliant consumers (browsers, Go, Rust) will reject the output.
What about JSON5?
JSON5 explicitly allows NaN, Infinity, and -Infinity as numeric literals. If your producer and consumer both use JSON5, you can keep them. If either side is strict JSON, you can't.
Should I use null, a string, or a sentinel number?
Depends on what 'missing' means. If the field is genuinely unknown, use null and document it. If it's the result of 0/0, store the operation outcome separately ('status': 'undefined') so consumers don't confuse it with a real number. Sentinel numbers (-1, 9999) are a trap: they look like data and silently corrupt aggregations.