{"slug": "json-dumps-writes-nan-by-default-31-llm-reports-json-parse-rejected", "title": "json.dumps Writes NaN by Default: 31 LLM Reports JSON.parse Rejected", "summary": "The developer behind Preterview, an interview-practice platform, traced 31 broken reports to Python's json.dumps writing bare NaN tokens by default, which browsers' JSON.parse rejects with a SyntaxError. The NaN originated from np.nanmean returning NaN (with only a RuntimeWarning) when every score in a rubric category was null, a case common to the platform's conversational interviewer style, and the failures went unnoticed because Python round-trip tests and once-per-location warning filters masked them. The fix combined Optional[float] with allow_inf_nan=False in Pydantic and json.dumps(..., allow_nan=False) as a tripwire at every write.", "body_md": "Nine people emailed me the same screenshot: a spinner and the line \"Your report is being prepared.\" One of them had been looking at it for 51 hours.\n\nThe report was done. It had been done since about 40 seconds after their session ended. It was sitting in object storage as a perfectly normal-looking file. The problem was three characters inside it: `NaN`. **Python json.dumps writes NaN by default**, the browser's `JSON.parse` refuses to read it, and my pipeline had no step anywhere that cared about the difference.\n\n`json.dumps` has `allow_nan=True` by default, so `float('nan')` becomes a bare `NaN` token. That is not valid JSON.`json.loads` reads `JSON.parse` throws a `SyntaxError`.` np.nanmean` over a rubric category where the LLM scorer returned `null` for every answer. An all-NaN slice returns NaN and only emits a `RuntimeWarning`.` Optional[float]` with `allow_inf_nan=False` in Pydantic, plus `json.dumps(..., allow_nan=False)` as a tripwire at every write.\nThe system is Preterview, an interview practice platform I run (full disclosure: I built it, [preterview.com/en](https://preterview.com/en)). A candidate does a voice interview with one of three interviewer styles, an LLM scores each answer against a rubric, and a worker assembles everything into a written report.\n\nThe report pipeline looks like this:\n\n`report.json` to object storage.\nStep 4 is where 31 reports died. The fetch succeeded, `JSON.parse` threw, my error handler caught it, and the error handler's fallback was \"show the preparing state and poll again.\" Forever. I had built a very polite infinite loop.\n\nThe NaN came from averaging a category that had no scores at all. The scorer prompt allows the model to say \"I can't assess this category from this answer\" by returning `null`. That's deliberate. I'd rather get a `null` than a confident 6/10 for system design on an answer about a team conflict.\n\nThe aggregation code turned those nulls into NaN on purpose so they'd be skipped:\n\n``` php\nimport numpy as np\n\ndef category_mean(scores: list[float | None]) -> float:\n    arr = np.array([np.nan if s is None else s for s in scores], dtype=float)\n    return float(np.nanmean(arr))\n```\n\n`np.nanmean` ignores NaN values. That's what I wanted. What I hadn't thought through is what it does when *every* value is NaN:\n\n```\n>>> np.nanmean([np.nan, np.nan, np.nan])\nRuntimeWarning: Mean of empty slice\nnan\n```\n\nNo exception. A warning and a NaN.\n\nWhen does every answer in a category get `null`? When the interviewer never asks a question that touches that category. One of my three interviewer styles is a conversational, culture-fit style. It often runs a whole session without a single technical question. So the \"technical depth\" category came back all-null, `nanmean` returned NaN, and the NaN flowed downstream. That's why 26 of the 31 broken reports came from that one style.\n\nBecause `allow_nan` defaults to `True`. The Python docs are upfront about it: with `allow_nan=True`, out-of-range floats are written as their JavaScript equivalents (`NaN`, `Infinity`, `-Infinity`), and the docs note this is not strictly JSON compliant. RFC 8259 has no representation for NaN at all.\n\n``` python\n>>> import json\n>>> json.dumps({\"technical_depth\": float(\"nan\")})\n'{\"technical_depth\": NaN}'\n```\n\nThe part that let this survive testing: Python reads it back just fine.\n\n```\n>>> json.loads('{\"technical_depth\": NaN}')\n{'technical_depth': nan}\n```\n\nMy test suite had a round-trip test: build a report, dump it, load it, compare. Python on both ends, so it passed. The only consumer that enforced the actual JSON spec was the browser:\n\n```\nJSON.parse('{\"technical_depth\": NaN}')\n// SyntaxError: Unexpected token 'N'\n```\n\nTwo reasons, and both are defaults.\n\nFirst, Python's default warnings filter shows a given warning once per code location, not once per occurrence. My workers are long-running processes. The first all-null category after a deploy printed `Mean of empty slice` to stderr. The next 30 printed nothing. Across 16 days I found 11 instances of that warning in the logs, one per worker start, sitting between thousands of normal lines. Nothing about it said \"a user's report is broken.\"\n\nSecond, the worker considered the job successful. `json.dumps` returned a string, the upload returned 200, the job was marked done. From the backend's point of view, every one of the 1,940 reports succeeded.\n\nThere was one more trap I tripped over while debugging. My first attempt to display the score did this:\n\n```\n>>> round(float(\"nan\"))\nValueError: cannot convert float NaN to integer\n```\n\nBut `round(float(\"nan\"), 1)` quietly returns `nan`. The report used one decimal place, so this path never raised either. Every layer had a way to pass NaN along without anyone noticing.\n\nThis is the table I wish I'd had. Same input, `float(\"nan\")`, very different behavior:\n\n| Where | What happens | \n|---|---|\n| `json.dumps` (stdlib) | Writes `NaN` . Invalid JSON, no error | \n| `json.dumps(..., allow_nan=False)` | Raises `ValueError` | \n| `json.loads` (stdlib) | Accepts `NaN` , returns`nan` | \n| Starlette / FastAPI `JSONResponse` | Raises `ValueError` (it uses`allow_nan=False` ) | \n| Pydantic v2 `model_dump_json()` | Writes `null` by default (`ser_json_inf_nan` ) | \n| orjson | Writes `null` | \n| Postgres `jsonb` | Rejects the input as invalid JSON | \n| Browser `JSON.parse` | Throws `SyntaxError` | \n\nThe irony: if this report had gone through my FastAPI endpoint, it would have been a loud 500 on day one. If I'd stored it in a `jsonb` column, the insert would have failed. I chose to write it straight to object storage with the stdlib serializer, which happens to be the most permissive path in the whole table.\n\nAlso notice that \"writes `null`\" is not automatically safe. Pydantic and orjson would have produced valid JSON, but the frontend would then have rendered \"Technical depth: null\" or crashed on `null.toFixed(1)`. Valid syntax, wrong meaning.\n\nMake \"not assessed\" a real value instead of a float that pretends to be a number. Then make every serializer refuse NaN so the next mistake fails at write time, not in a user's browser.\n\nThe model change:\n\n``` python\nfrom pydantic import BaseModel, Field\n\nclass CategoryScore(BaseModel):\n    category: str\n    mean: float | None = Field(default=None, allow_inf_nan=False)\n    assessed_answers: int\n```\n\nAnd the aggregation stops producing NaN in the first place:\n\n``` php\ndef category_mean(scores: list[float | None]) -> float | None:\n    vals = [s for s in scores if s is not None]\n    return sum(vals) / len(vals) if vals else None\n```\n\nNo numpy, no warning, and the empty case is a branch you can read instead of a side effect you have to remember.\n\nThen the tripwire, at the single function that writes report files:\n\n```\npayload = json.dumps(report.model_dump(), allow_nan=False)\n```\n\nIf anything upstream ever sneaks a NaN or an Infinity back in, this raises, the job fails, and my alerting sees a failed job instead of a successful one.\n\nTwo smaller changes:\n\n`filterwarnings = error::RuntimeWarning` in pytest config. The \"Mean of empty slice\" warning would have failed CI on the first test with a skipped category, and I had one. It just never asserted anything about the output.\nThe frontend change was really the bigger lesson. The NaN was a bug. The spinner turned that bug into 51 hours of a user waiting for something that was never going to arrive.\n\nI re-ran aggregation only. The raw per-answer LLM scores were stored separately from the assembled report, so I didn't need a single new model call. All 31 reports rebuilt and rendered in under two minutes, with \"Technical depth: not assessed in this session\" where the NaN used to be. I emailed the nine people who'd written in, plus the other 22 who'd quietly given up.\n\nIf I'd only stored the final report, I'd have had to re-score 31 sessions and explain why their scores changed. Keep your raw LLM outputs. You will need to re-derive something from them eventually.\n\nYes. Python's `json.dumps` uses `allow_nan=True` by default, so `float('nan')` is written as a bare `NaN` token, which is not valid JSON under RFC 8259. Python's `json.loads` accepts it back, so Python-only tests won't catch it, but JavaScript's `JSON.parse`, Postgres `jsonb`, and most strict parsers will reject the file. Pass `allow_nan=False` to make serialization raise a `ValueError` instead, and represent missing numeric values as `None`/` null` explicitly, especially for LLM-generated scores where \"couldn't assess\" is a legitimate answer that averaging code will happily turn into NaN.\n\n*Written by the developer behind [Preterview](https://preterview.com/en), an interview prep platform.*", "url": "https://wpnews.pro/news/json-dumps-writes-nan-by-default-31-llm-reports-json-parse-rejected", "canonical_source": "https://dev.to/ji_ai/jsondumps-writes-nan-by-default-31-llm-reports-jsonparse-rejected-2hnk", "published_at": "2026-11-03 12:22:33+00:00", "updated_at": "2026-11-03 12:26:45.696347+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "large-language-models"], "entities": ["Preterview", "Python", "NumPy", "Pydantic", "RFC 8259"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/json-dumps-writes-nan-by-default-31-llm-reports-json-parse-rejected", "markdown": "https://wpnews.pro/news/json-dumps-writes-nan-by-default-31-llm-reports-json-parse-rejected.md", "text": "https://wpnews.pro/news/json-dumps-writes-nan-by-default-31-llm-reports-json-parse-rejected.txt", "jsonld": "https://wpnews.pro/news/json-dumps-writes-nan-by-default-31-llm-reports-json-parse-rejected.jsonld"}}