skip to content

How do you diagnose a json.JSONDecodeError in a production ingest path?

level: seniorimportance: should knowfreq 44%

answer

  1. It is a ValueError underneath
  2. The exception knows where it stopped
  3. Log a window, not the document
  4. Offset zero versus offset at the end
  5. Unparseable is not the same as wrong shape

basics

~20 s

json.JSONDecodeError subclasses ValueError and carries msg, doc, pos, lineno and colno. Catch that class specifically, log the message plus a short slice of doc around pos rather than the whole payload, and keep malformed text separate from valid-but-wrong-shape data.

solid answer

~40 s

`json.JSONDecodeError` is raised by `json.loads` and `json.load` and subclasses `ValueError`, so a broad `except ValueError` catches it but also swallows unrelated conversion errors — catch the specific class. The instance carries `msg` (what the parser expected), `doc` (the full text it was given), `pos` (the character offset), and `lineno`/`colno` derived from it. The useful log line is `msg` plus `lineno`/`colno` plus a **bounded window** of `doc` around `pos` — never the whole payload, which may be megabytes and may carry secrets. Two failures look similar and are not: bytes that are not valid UTF-8 raise `UnicodeDecodeError` before any parsing, and a document that parses fine but lacks a field is a *shape* problem surfacing later as `KeyError` or `TypeError`. Route and count those separately.

code

python · 9 lines
python
import json

raw = '{"recipients": ["[email protected]",], "subject": "Weekly digest"}'
try:
    json.loads(raw)
except json.JSONDecodeError as exc:
    window = exc.doc[max(0, exc.pos - 20):exc.pos + 20]
    print(f"{exc.msg} at line {exc.lineno} column {exc.colno} (char {exc.pos})")
    print("near:", window)

go deeper

for a junior

Recall that a bad JSON string raises json.JSONDecodeError, that it must be caught rather than allowed to crash the handler, and that the exception itself reports the line and column where parsing stopped.

for a middle

Explain the attributes — msg, doc, pos, lineno, colno — and why catching ValueError is broader than you want. Be able to write the handler that logs a slice of doc around pos instead of the whole document.

for a senior

Demonstrate boundary judgment: separate undecodable bytes, malformed JSON and wrong-shaped documents into different metrics and owners, cap payload size before parsing, dead-letter rather than log raw bodies, and never retry a deterministic parse failure.

for a principal

Own the policy. Decide what an ingest boundary promises producers on malformed input, how long raw rejected payloads are retained and who may read them, and whether validation belongs at the edge or inside each consumer.

## What the exception actually gives you `json.JSONDecodeError` lives in `json.decoder`, is re-exported as `json.JSONDecodeError`, and subclasses `ValueError`. That inheritance is a compatibility decision from when the decoder raised a bare `ValueError`, and it has a practical consequence: `except ValueError` works, but it is a blunt instrument that also catches an `int()` conversion three lines away. Catch `json.JSONDecodeError` by name. The instance carries four attributes worth knowing cold: - `msg` — what the parser expected at the failure point, e.g. `Expecting property name enclosed in double quotes`. - `doc` — the entire document string it was parsing. - `pos` — the zero-based character offset where parsing stopped. - `lineno` and `colno` — one-based line and column, computed from `pos`. `str(exc)` is `msg` plus the position, which is why the default traceback is already reasonably informative. What it does *not* tell you is what the surrounding text looked like — and in an ingest path you no longer have the payload in your hand. ## Logging the failure without leaking the payload The reflex fix — log the whole document so someone can look at it — is wrong twice over. Payloads are unbounded (a truncated multi-megabyte upload floods the log), and they routinely carry tokens, addresses or personal data that your logs are not cleared to hold. Slice a window instead. ```python import json try: record = json.loads(raw) except json.JSONDecodeError as exc: window = exc.doc[max(0, exc.pos - 40):exc.pos + 40] logger.warning( "malformed payload at line %d col %d: %s | near: %r", exc.lineno, exc.colno, exc.msg, window, ) ``` Even the window may need redaction depending on what flows through the endpoint; the size and the redaction policy are decisions, not defaults. Pair the log with a counter keyed on `msg`, because a spike of one identical message means a broken producer while a scatter of different ones means corruption in transit. ## Three failures that are not the same failure An ingest path — say the inbound webhook of an email-digest sender, run by a four-person team, that receives one delivery-report document per send — sees three distinct failure classes, and conflating them is the usual reason an incident takes hours instead of minutes. 1. **Not decodable as text.** If you hand `json.loads` raw `bytes` and they are not valid UTF-8 (or UTF-16/32, which it autodetects), you get `UnicodeDecodeError`, not `JSONDecodeError`. That is a transport or content-encoding bug — a mislabelled charset, a truncated gzip stream — and it has different owners and a different fix. 2. **Not valid JSON.** `JSONDecodeError`. Common real causes are a truncated body from a dropped connection (`Expecting ',' delimiter` or `Unterminated string` right at the end, with `pos` near `len(doc)`), an HTML error page delivered with a 200 (`Expecting value` at `pos` 0), or a producer emitting a Python `repr` with single quotes. 3. **Valid JSON, wrong shape.** It parses, then blows up downstream as `KeyError` or `TypeError`, or worse, silently defaults. This is not a decode problem at all, and treating it as one sends you looking at the parser instead of at the producer's contract. The `pos` value discriminates the second class quickly: `pos == 0` means the body was never JSON, `pos` at the very end means it was cut off. ## Handling, not just diagnosing At the boundary, decode failures are expected traffic, not exceptional events. The shape that holds up: - Catch `json.JSONDecodeError` around the parse **and nothing else** — keep the `try` to the one call, so a genuine bug in the handler below is not misreported as bad input. - Reject with a specific error to the producer where the protocol allows it, and record the raw body to a dead-letter store with a retention limit rather than to the log. - Never retry a parse failure. Malformed text is deterministic; a retry loop on `JSONDecodeError` just multiplies the load. - Do not assert on `exc.msg` text in tests. CPython keeps refining the decoder's wording between releases, and a test that pins the string is a test that breaks on upgrade. Assert on the exception type and on `pos`/`lineno` if you must. - Guard size **before** parsing. `json.loads` builds the whole graph in memory, so a hostile or runaway payload is a memory problem the exception handler never gets to see. ## One more decode-side setting `json.loads` accepts the bare tokens `NaN`, `Infinity` and `-Infinity` by default — a CPython extension. If a producer's numbers must be strictly conformant, pass `parse_constant=` a callable that raises, so you find out at the boundary instead of when a `float('nan')` propagates into an average downstream and turns every aggregate into `nan`.

  • Is catching ValueError around json.loads good enough?
    It works, because `json.JSONDecodeError` subclasses `ValueError` for historical reasons, but it is too broad: an `int()` or `float()` conversion inside the same `try` block gets swallowed and misreported as a bad payload. Catch `json.JSONDecodeError` explicitly and keep the `try` wrapped around the parse call alone, so real bugs in the handler surface as bugs.
  • What error do you get when the incoming bytes are not valid UTF-8?
    `UnicodeDecodeError`, raised during decoding before any parsing happens — `json.loads` accepts `bytes` and autodetects UTF-8, UTF-16 or UTF-32, so invalid bytes fail there. It is also a `ValueError` subclass, which is one more reason a bare `except ValueError` hides useful distinctions. Treat it as a transport or content-encoding fault, with a different owner from malformed JSON.
  • How would you tell a truncated payload from one that was never JSON at all?
    Look at `exc.pos`. A `pos` of 0 with `Expecting value` means the first character already failed — typically an HTML error page or an empty body returned with a success status. A `pos` at or near `len(exc.doc)`, with a message about an unterminated string or a missing delimiter, means the document started out valid and was cut off, which points at a dropped connection or a write that never flushed.

saying these in an interview costs you the question

  • Catches bare Exception around the parse call
  • Logs the entire raw payload on every failure
  • Asserts on the decoder message text in tests
  • Confuses a missing field with a decode error
  • Retries a parse failure as if it were transient
  • Thinks JSONDecodeError is not a ValueError

context