<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: sharpenlee</title>
    <description>The latest articles on DEV Community by sharpenlee (@sharpenlee).</description>
    <link>https://dev.to/sharpenlee</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4145260%2F01ebd70e-40e6-4fe9-891c-ea3932f92a75.jpg</url>
      <title>DEV Community: sharpenlee</title>
      <link>https://dev.to/sharpenlee</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sharpenlee"/>
    <language>en</language>
    <item>
      <title>Why LLMs return invalid JSON — a language model has no parser</title>
      <dc:creator>sharpenlee</dc:creator>
      <pubDate>Sun, 11 Oct 2026 06:38:56 +0000</pubDate>
      <link>https://dev.to/sharpenlee/why-llms-return-invalid-json-a-language-model-has-no-parser-1n0d</link>
      <guid>https://dev.to/sharpenlee/why-llms-return-invalid-json-a-language-model-has-no-parser-1n0d</guid>
      <description>&lt;p&gt;Ask a model for JSON and you often get something that is &lt;em&gt;almost&lt;/em&gt; JSON: a value wrapped in a markdown fence, a trailing comma, a Python &lt;code&gt;None&lt;/code&gt; where &lt;code&gt;null&lt;/code&gt; belongs. The reflex is to blame the prompt, or to switch on "JSON mode". Both miss the mechanism. A language model predicts the next token; it never builds a document, and it cannot tell you that a document is broken, because nothing in it represents a document. The useful question is therefore not "how do I make it stop" but "where does the parser go, given there is not one upstream".&lt;/p&gt;

&lt;h2&gt;
  
  
  What comes back
&lt;/h2&gt;

&lt;p&gt;The twelve source strings below are the shapes that actually break things — eight of them are repairs that the &lt;code&gt;jsonrepair&lt;/code&gt; library documents by name. I ran all twelve through four parsers and recorded accept-or-reject. They were typed as literals rather than captured from a live call: there is no API key on the machine this was written on, so the shapes are the documented ones and what is &lt;em&gt;measured&lt;/em&gt; here is how the parsers respond to them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Node&lt;/th&gt;
&lt;th&gt;Python&lt;/th&gt;
&lt;th&gt;Ruby&lt;/th&gt;
&lt;th&gt;PHP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;markdown fence&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;trailing comma &lt;code&gt;{"a":1,}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;single quotes &lt;code&gt;{'a':1}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python literals &lt;code&gt;None / True&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;block comment &lt;code&gt;/* … */&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;raw newline inside a string&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;truncated &lt;code&gt;{"a":1,"b":[1,2&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NaN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Infinity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unquoted key &lt;code&gt;{a:1}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;trailing prose after the value&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTF-8 BOM before the object&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;td&gt;ERR&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four parsers, twelve documents, and they agree on almost all of it. Nine of the twelve are rejected by every parser in the table, which is the reassuring part: for a fence, a trailing comma, a stray quote or a truncation, none of the mainstream parsers you are likely to be running will accept the text as JSON. Three rows break the unanimity, in two ways that matter more than they look.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ruby accepts block comments
&lt;/h3&gt;

&lt;p&gt;Ruby 2.6.10 — the build measured here, with its bundled &lt;code&gt;json&lt;/code&gt; 2.1.0 — parses &lt;code&gt;{"a":1 /* note */}&lt;/code&gt; and hands back &lt;code&gt;{"a"=&amp;gt;1}&lt;/code&gt;. Every other parser in the table refuses it. So the exact bytes that are a hard 400 from a Node or PHP service are a clean 200 from a Ruby one. This is obscure, and that is the point: a strict parse is not a shared contract between two services in the same pipeline unless they happen to run the same parser.&lt;/p&gt;

&lt;h3&gt;
  
  
  Python accepts NaN and Infinity
&lt;/h3&gt;

&lt;p&gt;JSON's number grammar has no &lt;code&gt;NaN&lt;/code&gt; and no &lt;code&gt;Infinity&lt;/code&gt; — RFC 8259 builds every number from an optional minus, digits, an optional fraction and an optional exponent, and those tokens are not in it. Node, Ruby and PHP reject &lt;code&gt;{"x": NaN}&lt;/code&gt;. Python's &lt;code&gt;json.loads&lt;/code&gt; returns &lt;code&gt;{'x': nan}&lt;/code&gt;. Hand that value straight back to &lt;code&gt;json.dumps&lt;/code&gt; and, with the default settings, you get &lt;code&gt;{"x": NaN}&lt;/code&gt; — a document that is not valid JSON and that the other three parsers reject on sight; only &lt;code&gt;allow_nan=False&lt;/code&gt; turns the silent round trip into an exception. This is the same divergence the companion article on number precision found from the write side; here it arrives from the read side, in data a model just produced.&lt;/p&gt;

&lt;h2&gt;
  
  
  There is no guarantee upstream, by construction
&lt;/h2&gt;

&lt;p&gt;The reason models get this wrong is not that they are bad at JSON. It is that the mechanism cannot make the promise. Sampling emits one token at a time from a probability distribution; nothing in the loop represents "the text so far is a complete, well-formed document", so nothing in the loop can refuse to finish it. A constrained decoder does better — it can track whether the text so far is a valid &lt;em&gt;prefix&lt;/em&gt; — but a valid prefix is not a valid document, and that gap is the whole story.&lt;/p&gt;

&lt;p&gt;"JSON mode" is the vendor term for pushing that guarantee as far upstream as it will go, and the documentation is unusually direct about what it actually buys you. Microsoft's Azure OpenAI page states that JSON mode "guarantees valid JSON output, but it doesn't guarantee the output matches a specific schema". The braces will close; the fields inside them can still be wrong. The same page shows how thin even that guarantee is at the edges — it also requires the word "JSON" to appear somewhere in the messages, and when it does not, the API rejects the request outright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error code: 400 - {'error': {'message': "'messages' must contain the word
'json' in some form, to use 'response_format' of type 'json_object'."}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error is the honest shape of the whole feature. When the instruction is missing, the documentation warns (quoting OpenAI) that the model can "generate an unending stream of whitespace and the request could run continually until it reaches the token limit" — a failure that looks, from your side, exactly like a hang.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hole the guarantee does not cover
&lt;/h3&gt;

&lt;p&gt;Validity per token is not validity per document, and the place they diverge is truncation. If the reply is longer than &lt;code&gt;max_tokens&lt;/code&gt;, the stream simply stops mid-structure and &lt;code&gt;finish_reason&lt;/code&gt; comes back as &lt;code&gt;length&lt;/code&gt;. Azure's guidance is explicit on both counts: "You should check &lt;code&gt;finish_reason&lt;/code&gt; for the value &lt;code&gt;length&lt;/code&gt; before parsing the response. The model might generate partial JSON", and, in troubleshooting, "Don't parse partial JSON."&lt;/p&gt;

&lt;p&gt;This is the case the guarantee cannot reach, and the case a repair layer is most likely to paper over. Take the truncated object from the table and let a repair pass close its brackets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;model&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(finish_reason:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"length"&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"a"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"b"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;after&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;repair&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;pass&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;closes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;brackets&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"a"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"b"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result parses cleanly, and it is a lie. Nothing in the text says whether the list was going to end at two items or fifty; the repair chose two. &lt;strong&gt;Repair restores syntax, never data.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The repair layer, measured
&lt;/h2&gt;

&lt;p&gt;Since the parser upstream does not exist, one has to exist downstream, and in practice it is a repair pass that runs before &lt;code&gt;JSON.parse&lt;/code&gt;. I wrote the smallest honest version of that pass and ran it over the same twelve documents. A first stage of plain regex substitutions — strip the fence and the byte-order mark, drop comments and trailing commas, swap the single quotes, map the Python and JavaScript non-values, close unbalanced brackets — recovered nine of the twelve. The three it missed were simply rules that first pass did not contain, and two of them are cases a regex cannot handle safely on its own: a newline must be escaped only when it is &lt;em&gt;inside&lt;/em&gt; a string, and trailing prose must be cut only once the value has ended. Adding a boundary-tracking pass, and the key-quoting rule, took it to twelve out of twelve:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source pattern&lt;/th&gt;
&lt;th&gt;After repair&lt;/th&gt;
&lt;th&gt;What the repair did&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;markdown fence&lt;/td&gt;
&lt;td&gt;parses&lt;/td&gt;
&lt;td&gt;stripped the fence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;trailing comma&lt;/td&gt;
&lt;td&gt;parses&lt;/td&gt;
&lt;td&gt;removed it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;single quotes&lt;/td&gt;
&lt;td&gt;parses&lt;/td&gt;
&lt;td&gt;rewrote as double quotes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;None&lt;/code&gt; / &lt;code&gt;True&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;parses&lt;/td&gt;
&lt;td&gt;mapped to &lt;code&gt;null&lt;/code&gt; / &lt;code&gt;true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;block comment&lt;/td&gt;
&lt;td&gt;parses&lt;/td&gt;
&lt;td&gt;removed the comment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;raw newline in a string&lt;/td&gt;
&lt;td&gt;parses&lt;/td&gt;
&lt;td&gt;escaped the control character&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unquoted key&lt;/td&gt;
&lt;td&gt;parses&lt;/td&gt;
&lt;td&gt;added the quotes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;trailing prose&lt;/td&gt;
&lt;td&gt;parses&lt;/td&gt;
&lt;td&gt;discarded everything after the value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTF-8 BOM&lt;/td&gt;
&lt;td&gt;parses&lt;/td&gt;
&lt;td&gt;stripped the mark&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;NaN&lt;/code&gt; / &lt;code&gt;Infinity&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;parses&lt;/td&gt;
&lt;td&gt;substituted &lt;code&gt;null&lt;/code&gt; — a value the model never wrote&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;truncated&lt;/td&gt;
&lt;td&gt;parses&lt;/td&gt;
&lt;td&gt;closed the brackets — and invented the data above&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All twelve parse afterwards. The truncated one parses by being falsified. And some of those rows are decisions rather than restorations: the comment row silently dropped content the model took the trouble to write, and the &lt;code&gt;NaN&lt;/code&gt; row is worse — the substituted &lt;code&gt;null&lt;/code&gt; is indistinguishable from a real one, so a repair pass can convert "the model produced an undefined number" into "the model said there is nothing here" without leaving a trace.&lt;/p&gt;

&lt;p&gt;None of this is improvisation on my part. The &lt;code&gt;jsonrepair&lt;/code&gt; library publishes a list of what it fixes, and eight of the twelve shapes above are on it by name: add missing quotes around keys, add missing escape characters, repair truncated JSON, replace single quotes with double quotes, replace Python constants, strip trailing commas, strip comments and strip fenced code blocks. Three of the shapes it does not cover are the ones that matter most — &lt;code&gt;NaN&lt;/code&gt;, &lt;code&gt;Infinity&lt;/code&gt; and prose trailing after the value. Those are exactly the cases that survive a repair pass silently: &lt;code&gt;NaN&lt;/code&gt; parses in Python and nowhere else, so a pipeline can carry it through the repair stage and lose it at whichever service happens to disagree. That a dedicated repair library and a model's failure modes overlap this heavily is the clearest evidence that the problem is structural rather than incidental.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do instead
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Move the guarantee upstream when you can.&lt;/strong&gt; Prefer a schema-constrained mode over asking politely in the prompt: the documented difference is that JSON mode promises syntax while structured outputs constrain the model to a schema. Neither was exercised here (no API key), so treat the exact semantics as documented rather than measured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the reply as untrusted text.&lt;/strong&gt; The pipeline is repair → parse → &lt;em&gt;validate against a schema&lt;/em&gt;, in that order, and the last step is not optional, because a syntactically perfect document with the wrong fields passes the first two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check &lt;code&gt;finish_reason&lt;/code&gt; before you parse, and never repair a truncated reply into acceptance.&lt;/strong&gt; Raise and retry with a larger budget instead. Repair makes a cut-off document look finished; only you can decide that it is not finished.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not assume "valid JSON" is a shared contract.&lt;/strong&gt; Two services in one pipeline can disagree about the same bytes — Ruby takes the block comment, Python takes the &lt;code&gt;NaN&lt;/code&gt;, and the third service rejects both. Validate at the boundary rather than trusting that the upstream parser agreed with yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at the output before it reaches code.&lt;/strong&gt; A repair layer hides exactly the cases you most need to see.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Check your own payload
&lt;/h2&gt;

&lt;p&gt;Paste a model reply into the &lt;a href="https://json-tool.com/json-validator.html?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=why-llms-return-invalid-json" rel="noopener noreferrer"&gt;validator&lt;/a&gt; to get the line and column of the first real error rather than a repair that is invisibly wrong, and into the &lt;a href="https://json-tool.com/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=why-llms-return-invalid-json" rel="noopener noreferrer"&gt;formatter&lt;/a&gt; to see the structure once it parses. For output that is valid JSON but the wrong shape, the &lt;a href="https://json-tool.com/json-schema.html?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=why-llms-return-invalid-json" rel="noopener noreferrer"&gt;JSON Schema validator&lt;/a&gt; is the missing test: "parses" and "correct" are different questions, and JSON mode only answers the first. The &lt;a href="https://json-tool.com/json-syntax.html?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=why-llms-return-invalid-json" rel="noopener noreferrer"&gt;syntax cheat sheet&lt;/a&gt; covers what the grammar actually allows, including why &lt;code&gt;NaN&lt;/code&gt; and &lt;code&gt;Infinity&lt;/code&gt; are not in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provenance
&lt;/h2&gt;

&lt;p&gt;The parser table and the repair table were produced by running the documents shown through Node 24.18.0 on V8 15.0.245.15, CPython 3.10.11, Ruby 2.6.10 (bundled &lt;code&gt;json&lt;/code&gt; 2.1.0) and PHP 8.3.35 on a 64-bit build. The Node runtime here is the one embedded in an Electron host rather than a stock Node distribution — its V8 build string carries an &lt;code&gt;-electron&lt;/code&gt; suffix — and the parser exercised in either case is V8's own &lt;code&gt;JSON.parse&lt;/code&gt;. The repair pass was written and executed for this article rather than taken from a library, and the Python &lt;code&gt;NaN&lt;/code&gt; round trip was measured with the default &lt;code&gt;json.dumps&lt;/code&gt; and again with &lt;code&gt;allow_nan=False&lt;/code&gt;. The twelve source strings are the documented failure shapes rather than captures from a live call: there is no API key on the machine this was written on, so nothing here was produced by a model. The claims about JSON mode, structured outputs and &lt;code&gt;finish_reason&lt;/code&gt; are quoted from Microsoft's Azure OpenAI documentation, which mirrors OpenAI's, and are labelled documented rather than measured for exactly that reason; the &lt;code&gt;jsonrepair&lt;/code&gt; list is quoted from that library's repository README, which enumerates its repairs without naming models as their source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://json-tool.com/blog/json-number-precision.html?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=why-llms-return-invalid-json" rel="noopener noreferrer"&gt;JSON number precision — why 19-digit IDs come back wrong&lt;/a&gt; — the same "the parsers disagree" problem, measured from the write side.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://json-tool.com/json-schema.html?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=why-llms-return-invalid-json" rel="noopener noreferrer"&gt;JSON Schema validator&lt;/a&gt; — the check that catches a reply which parses but has the wrong shape.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://json-tool.com/blog/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=why-llms-return-invalid-json" rel="noopener noreferrer"&gt;JSON guides&lt;/a&gt; — everything published so far.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>json</category>
      <category>ai</category>
      <category>llm</category>
      <category>programming</category>
    </item>
    <item>
      <title>JSON Number Precision — Why 19-Digit IDs Come Back Wrong</title>
      <dc:creator>sharpenlee</dc:creator>
      <pubDate>Fri, 02 Oct 2026 14:09:44 +0000</pubDate>
      <link>https://dev.to/sharpenlee/json-number-precision-why-19-digit-ids-come-back-wrong-2ahb</link>
      <guid>https://dev.to/sharpenlee/json-number-precision-why-19-digit-ids-come-back-wrong-2ahb</guid>
      <description>&lt;p&gt;Here is one line of JSON, parsed and re-serialised, changing a digit on the way through. No error, no warning, nothing to catch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;in:  {"id": 9007199254740993}
out: {"id": 9007199254740992}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is tempting to conclude that JSON is lossy. It is not. Run the same document through Python or Ruby and the ID survives intact — which means the damage was done by a choice the JavaScript engine made, and that the standard explicitly permitted.&lt;/p&gt;

&lt;h2&gt;
  
  
  One document, three answers
&lt;/h2&gt;

&lt;p&gt;Every result below was produced by running the code, not by reading documentation. Three commands, one payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node    &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'JSON.parse("{\"id\":9007199254740993}").id'&lt;/span&gt;
python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import json;print(json.loads("{\"id\":9007199254740993}")["id"])'&lt;/span&gt;
ruby    &lt;span class="nt"&gt;-rjson&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'p JSON.parse("{\"id\":9007199254740993}")["id"]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th&gt;What you get back&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Node v24.18.0 (V8 15.0)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;9007199254740992&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;number&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPython 3.10.11&lt;/td&gt;
&lt;td&gt;&lt;code&gt;9007199254740993&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;int&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ruby 2.6.10&lt;/td&gt;
&lt;td&gt;&lt;code&gt;9007199254740993&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Integer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of the three are exact. The odd one out is the language most JSON is written in, and it is the only one of the three whose integers are not arbitrary-precision: JavaScript has one numeric type, a 64-bit float, so anything above &lt;code&gt;2**53 - 1&lt;/code&gt; is rounded on the way in. A 19-digit snowflake ID, a nanosecond timestamp, an Ethereum amount — all of them sit well past that line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The damage can happen before JSON is involved
&lt;/h2&gt;

&lt;p&gt;In JavaScript the rounding is not a property of the parser. It is a property of the number, and it applies to literals in your source too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="mi"&gt;9007199254740993&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;9007199254740992&lt;/span&gt;   &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="mf"&gt;1.0000000000000000001&lt;/span&gt;                   &lt;span class="c1"&gt;// 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So by the time &lt;code&gt;JSON.parse&lt;/code&gt; is called, a numeric ID may already have been corrupted by the code that built the value. This is why "just use a bigger type" does not work in JavaScript: the bigger type does not exist, and the literal syntax will not warn you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the standard actually promises
&lt;/h2&gt;

&lt;p&gt;RFC 8259 does not require a lossy parser. It permits one. It allows implementations to set their own limits on range and precision, and the only numbers it promises will interoperate are those inside the window &lt;code&gt;[-(2**53)+1, (2**53)-1]&lt;/code&gt;. It names the smell itself: "A JSON number such as 1E400 or 3.141592653589793238462643383279 may indicate potential interoperability problems".&lt;/p&gt;

&lt;p&gt;That, in one sentence, is the whole problem. &lt;strong&gt;JSON defines a grammar for numbers, not a numeric type.&lt;/strong&gt; The grammar says a number is an optional minus sign, digits, an optional fraction and an optional exponent. It says nothing about how many digits matter or what they mean once decoded. Every implementation then binds that grammar to whatever number type it already had, and the binding is where your ID dies.&lt;/p&gt;

&lt;h3&gt;
  
  
  It applies to keys as well
&lt;/h3&gt;

&lt;p&gt;The same laissez-faire applies to object keys. RFC 8259 says "The names within an object SHOULD be unique", and then concedes what happens when they are not: "the behavior of software that receives such an object is unpredictable". All three runtimes I tested take the last value, but nothing in the standard requires that, which is why a duplicated key is a live security question rather than a style question — a proxy and the service behind it can disagree about which value the document means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading is only half of it
&lt;/h2&gt;

&lt;p&gt;The write side is worse, because it destroys information that was never in doubt. These are measured, not theoretical:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;JavaScript&lt;/th&gt;
&lt;th&gt;Python&lt;/th&gt;
&lt;th&gt;Ruby&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1.0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1.10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1.1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NaN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NaN&lt;/code&gt; — invalid JSON&lt;/td&gt;
&lt;td&gt;raises &lt;code&gt;GeneratorError&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Infinity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Infinity&lt;/code&gt; — invalid JSON&lt;/td&gt;
&lt;td&gt;raises &lt;code&gt;GeneratorError&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-0.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-0.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-0.0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2**53 + 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;9007199254740992&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;9007199254740993&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;9007199254740993&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things are worth pulling out of that table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JavaScript turns &lt;code&gt;NaN&lt;/code&gt; and &lt;code&gt;Infinity&lt;/code&gt; into &lt;code&gt;null&lt;/code&gt;.&lt;/strong&gt; Not an error, not a string — &lt;code&gt;null&lt;/code&gt;, which is indistinguishable from a genuine absent value. Python takes the opposite risk and writes &lt;code&gt;NaN&lt;/code&gt; into the output, producing a document that is not valid JSON and that a strict reader will reject. Ruby refuses to write it at all, which is the correct behaviour and the least popular one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;1.10&lt;/code&gt; loses its trailing zero everywhere.&lt;/strong&gt; Not because a parser is lazy, but because &lt;code&gt;1.10&lt;/code&gt; and &lt;code&gt;1.1&lt;/code&gt; are the same IEEE 754 double — the trailing zero was never a value, only spelling. Only a reader that declines to convert to a double keeps it. That is the real dividing line in this whole subject: &lt;em&gt;did the parser commit to a floating-point type, or did it keep the text?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The round trip is not a function.&lt;/strong&gt; If you cannot recover your input from &lt;code&gt;parse(stringify(x))&lt;/code&gt;, then comparing serialised JSON as text for equality is a bug waiting to happen. &lt;code&gt;1.0&lt;/code&gt; becomes &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;-0.0&lt;/code&gt; becomes &lt;code&gt;0&lt;/code&gt;, and &lt;code&gt;2**53 + 1&lt;/code&gt; becomes &lt;code&gt;2**53&lt;/code&gt; — all before you look at it.&lt;/p&gt;

&lt;p&gt;The sign of zero shows how per-language this gets. Given the source text &lt;code&gt;-0&lt;/code&gt;, JavaScript keeps the negative zero when parsing and then writes it back as &lt;code&gt;0&lt;/code&gt;; Python reads the very same token as the integer &lt;code&gt;0&lt;/code&gt;, losing the sign on the way in rather than on the way out. Identical bytes, two languages, two different places to drop the same bit.&lt;/p&gt;

&lt;h3&gt;
  
  
  And it silently discards non-numbers too
&lt;/h3&gt;

&lt;p&gt;While we are on the write side, &lt;code&gt;JSON.stringify&lt;/code&gt; in JavaScript does the following without complaint:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{ a: undefined, b: 1 }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{"b":1}&lt;/code&gt; — the key disappears&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[undefined]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;[null]&lt;/code&gt; — in an array the slot survives as null&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;new Map([[1, 2]])&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{}&lt;/code&gt; — every entry gone, no error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;new Set([1])&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;1n&lt;/code&gt; (BigInt)&lt;/td&gt;
&lt;td&gt;throws &lt;code&gt;TypeError&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;new Date(0)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"1970-01-01T00:00:00.000Z"&lt;/code&gt;, and parsing it back gives a string, not a Date&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note the inconsistency between the first two rows: the same &lt;code&gt;undefined&lt;/code&gt; is dropped from an object and replaced with &lt;code&gt;null&lt;/code&gt; in an array. Neither is wrong per the standard — an array cannot have a hole, so something has to go there — but a reader who assumes symmetry will be surprised.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 1e21 cliff
&lt;/h2&gt;

&lt;p&gt;One more JavaScript-only oddity, because it breaks text diffs rather than values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// "100000000000000000000"&lt;/span&gt;
&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e21&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// "1e+21"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Below &lt;code&gt;1e21&lt;/code&gt; a number prints in full; at and above it, exponential notation. The value is identical, the bytes are not. Any tool that compares two payloads by their text — a cache key, a deduplication hash, a diff — will declare these different while any parser will say they are the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  The escape hatch almost nobody knows
&lt;/h2&gt;

&lt;p&gt;There is now a way to make &lt;code&gt;JSON.parse&lt;/code&gt; and &lt;code&gt;JSON.stringify&lt;/code&gt; round-trip byte-for-byte in JavaScript, and it is barely two years old. It comes from the TC39 &lt;em&gt;JSON.parse source text access&lt;/em&gt; proposal, which is at stage 4, and it does two things: it gives the reviver a third argument carrying the &lt;strong&gt;original source text&lt;/strong&gt; of each value, and it adds &lt;code&gt;JSON.rawJSON&lt;/code&gt; for writing that text back out untouched.&lt;/p&gt;

&lt;p&gt;That is enough to preserve a number exactly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;source&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rawJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"id":9007199254740993,"amount":1.10,"tag":"x"}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;back&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// back === src  -&amp;gt;  true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the reviver the same transform silently rewrites the document — a changed digit &lt;em&gt;and&lt;/em&gt; a reformatted decimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plain:  {"id":9007199254740992,"amount":1.1,"tag":"x"}
kept:   {"id":9007199254740993,"amount":1.10,"tag":"x"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works inside arrays too, and &lt;code&gt;JSON.isRawJSON&lt;/code&gt; tells you when you are holding one of these wrappers. Two things to know before reaching for it. It is not a number — you cannot do arithmetic on it, so it belongs in pass-through code (a proxy, a logger, a formatter) and not in business logic. And the argument must be valid JSON on its own: &lt;code&gt;JSON.rawJSON('01')&lt;/code&gt; throws immediately rather than corrupting anything downstream, which is a good failure to have.&lt;/p&gt;

&lt;p&gt;Availability is the real limit. It is present in the runtime I tested, Node v24.18.0 on V8 15.0, and absent from older ones — check with &lt;code&gt;typeof JSON.rawJSON&lt;/code&gt; before relying on it. On an engine without it, no reviver will help you, because the digits were gone before your callback ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do instead
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Send identifiers as strings.&lt;/strong&gt; This is what every large API eventually does, and it is the only fix that holds across languages, proxies and databases simultaneously. &lt;code&gt;"9007199254740993"&lt;/code&gt; is exact everywhere; &lt;code&gt;9007199254740993&lt;/code&gt; is exact in two of the three runtimes above and wrong in the third. A string is also self-documenting: nobody will accidentally sum it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;If you must keep numbers, parse with a reader that does not commit to a double.&lt;/strong&gt; Python's default already keeps integers exact, and &lt;code&gt;parse_float=decimal.Decimal&lt;/code&gt; keeps decimals exact too. Go's &lt;code&gt;json.Decoder.UseNumber()&lt;/code&gt; and Jackson's &lt;code&gt;USE_BIG_DECIMAL_FOR_FLOATS&lt;/code&gt; are the equivalent switches elsewhere; neither was executed here, so treat their exact semantics as documented rather than measured. In JavaScript, the reviver above is the option that exists today.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Refuse to write &lt;code&gt;NaN&lt;/code&gt; and &lt;code&gt;Infinity&lt;/code&gt;.&lt;/strong&gt; Python needs &lt;code&gt;json.dumps(..., allow_nan=False)&lt;/code&gt; to stop producing invalid JSON; Ruby does it already. In JavaScript there is nothing to enable — they are silently &lt;code&gt;null&lt;/code&gt;, so the check has to be yours.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Never compare serialised JSON as text.&lt;/strong&gt; &lt;code&gt;1.0&lt;/code&gt; versus &lt;code&gt;1&lt;/code&gt; and &lt;code&gt;1e20&lt;/code&gt; versus &lt;code&gt;100000000000000000000&lt;/code&gt; are the same values in different spelling. Compare parsed structures, and if you need a stable hash, canonicalise first — which means deciding what "equal" means for numbers before you hash, not after.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where this bites in practice
&lt;/h2&gt;

&lt;p&gt;The failure is concentrated in identifiers, and identifiers are exactly the values people are most reluctant to make strings because of how much legacy code compares them. Snowflake IDs from Twitter, Discord and the like; nanosecond timestamps at 19 digits; Ethereum amounts in wei, up to 78 digits; 64-bit hashes used as keys. Every one of them is past &lt;code&gt;2**53&lt;/code&gt;, and every one of them fails quietly — you get a valid-looking number that is off by a little, and the error surfaces three services later as "record not found".&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own payload
&lt;/h2&gt;

&lt;p&gt;Paste the document into the &lt;a href="https://json-tool.com/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=json-number-precision" rel="noopener noreferrer"&gt;JSON formatter&lt;/a&gt; to see the structure, and be aware of what you are looking at: this tool parses with &lt;strong&gt;your browser's&lt;/strong&gt; engine, which is a V8-family parser, so a 19-digit ID will already be rounded when it appears on screen. That is not a limitation to hide — it is the same thing &lt;code&gt;JSON.parse&lt;/code&gt; will do to that data in your application, demonstrated before you ship it. If the number looks wrong in the formatter, it is wrong in your code.&lt;/p&gt;

&lt;p&gt;For documents that will not parse at all, the &lt;a href="https://json-tool.com/json-validator.html?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=json-number-precision" rel="noopener noreferrer"&gt;validator&lt;/a&gt; reports the line and column, and the &lt;a href="https://json-tool.com/json-syntax.html?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=json-number-precision" rel="noopener noreferrer"&gt;syntax cheat sheet&lt;/a&gt; covers what the grammar allows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provenance
&lt;/h2&gt;

&lt;p&gt;Every result in this article was produced by executing the code shown, on Node v24.18.0 (V8 15.0.245.15), CPython 3.10.11 and Ruby 2.6.10. The quoted sentences are from RFC 8259, &lt;em&gt;The JavaScript Object Notation (JSON) Data Interchange Format&lt;/em&gt;, sections 4 and 6; the proposal status is from the TC39 &lt;em&gt;JSON.parse source text access&lt;/em&gt; repository. Where a claim was not executed here — the Go and Jackson settings — it is labelled as documented behaviour rather than measured, because the difference between those two things is the entire subject of this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://json-tool.com/blog/unexpected-token-doctype-is-not-valid-json.html?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=json-number-precision" rel="noopener noreferrer"&gt;Why "Unexpected token &amp;lt;" means your API returned HTML&lt;/a&gt; — the other way a payload arrives in a shape you did not expect.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://json-tool.com/json-syntax.html?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=json-number-precision" rel="noopener noreferrer"&gt;JSON syntax cheat sheet&lt;/a&gt; — the grammar this article keeps referring to, with valid and invalid examples.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://json-tool.com/blog/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=json-number-precision" rel="noopener noreferrer"&gt;JSON guides&lt;/a&gt; — everything published so far.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>json</category>
      <category>javascript</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why "Unexpected token &lt;" means your API returned HTML</title>
      <dc:creator>sharpenlee</dc:creator>
      <pubDate>Thu, 01 Oct 2026 14:15:17 +0000</pubDate>
      <link>https://dev.to/sharpenlee/why-unexpected-token-means-your-api-returned-html-431o</link>
      <guid>https://dev.to/sharpenlee/why-unexpected-token-means-your-api-returned-html-431o</guid>
      <description>&lt;p&gt;You called an endpoint, expected data, and the parser stopped on the very first character. The message is not about your payload — it is about the shape of the response, and the response was a web page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Uncaught SyntaxError: Unexpected token '&amp;lt;', "&amp;lt;!DOCTYPE "... is not valid JSON
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Older engines report the same failure with different wording, and a search for any of these strings means the same thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SyntaxError: Unexpected token &amp;lt; in JSON at position 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the parser is telling you
&lt;/h2&gt;

&lt;p&gt;A JSON document may begin with &lt;code&gt;{&lt;/code&gt;, &lt;code&gt;[&lt;/code&gt;, &lt;code&gt;"&lt;/code&gt;, a digit, a minus sign, or one of the literals &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt; and &lt;code&gt;null&lt;/code&gt;. Leading whitespace is allowed. Nothing else is. A &lt;code&gt;&amp;lt;&lt;/code&gt; is not in that set, so a body that starts with &lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/code&gt; fails at offset zero.&lt;/p&gt;

&lt;p&gt;Newer V8 builds — Chrome, Edge and Node 20 and later — make the message far more useful by quoting the beginning of what they actually received. Those five characters, &lt;code&gt;"&amp;lt;!DOCTYPE "&lt;/code&gt;, are the whole diagnosis: something gave you a hypertext document where you expected a data envelope.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five things that actually happened
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The endpoint returned an error page.&lt;/strong&gt; A 404, a 500, or a framework's HTML error renderer answered instead of the route you meant. A path segment is missing, the route only exists for a different HTTP method, or the resource genuinely is not there.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A single-page app fallback answered instead of the API.&lt;/strong&gt; Dev servers and static hosts commonly serve &lt;code&gt;index.html&lt;/code&gt; for any unmatched path, so a request to a typo such as &lt;code&gt;/api/uers&lt;/code&gt; returns the application shell — status 200, content type &lt;code&gt;text/html&lt;/code&gt;. This is the most frequent cause in local development, and it looks exactly like a successful request.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;You were redirected to sign-in.&lt;/strong&gt; An expired session or a missing token turns the API call into a redirect to a login page, and &lt;code&gt;fetch&lt;/code&gt; follows redirects silently. The parser then receives the sign-in form.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Something in front of the API blocked the request.&lt;/strong&gt; A WAF, a corporate proxy, a CDN security rule or a captive portal serves an HTML interstitial, frequently with a status that does not read like a failure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The base URL points at the marketing site.&lt;/strong&gt; A missing &lt;code&gt;/api&lt;/code&gt; prefix, an environment variable still set to the root domain, or a trailing-slash difference lands the request on a page that is perfectly valid HTML — just not the one you wanted.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Confirm it in one request
&lt;/h2&gt;

&lt;p&gt;Print the status, the content type and the first bytes &lt;em&gt;before&lt;/em&gt; parsing. This is the step that turns the error from a puzzle into a fact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; from &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Expected JSON, got &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading with &lt;code&gt;text()&lt;/code&gt; and parsing it yourself is what makes this work. Calling &lt;code&gt;res.json()&lt;/code&gt; directly throws the same unhelpful syntax error and discards the body, which is why the cause stays hidden.&lt;/p&gt;

&lt;p&gt;From a shell, the headers and the first line are enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Accept: application/json'&lt;/span&gt; https://api.example.com/v1/users | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line gives the status and &lt;code&gt;content-type&lt;/code&gt; gives the type. If the body begins with &lt;code&gt;&amp;lt;!DOCTYPE&lt;/code&gt;, you have your answer. Run it again without &lt;code&gt;-L&lt;/code&gt;: curl will then print the redirect and its &lt;code&gt;location&lt;/code&gt; header instead of following it, which is how you catch the login bounce that &lt;code&gt;fetch&lt;/code&gt; hid from you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why sending &lt;code&gt;Accept: application/json&lt;/code&gt; does not prevent this
&lt;/h3&gt;

&lt;p&gt;It is a request header, not a contract. Servers are free to ignore it, and error paths in particular tend to bypass content negotiation altogether. It is still worth sending — some gateways route on it — but the reliable check is the status and the content type above, not the header you hoped would be honoured.&lt;/p&gt;

&lt;h3&gt;
  
  
  It works in Postman but fails in the browser
&lt;/h3&gt;

&lt;p&gt;Two explanations cover nearly every case. Either Postman sends an API key or bearer token while the browser sends session cookies, so only the browser gets redirected to sign-in; or the server chooses its response format based on &lt;code&gt;X-Requested-With&lt;/code&gt;, which &lt;code&gt;fetch&lt;/code&gt; does not set by default. Compare the raw response rather than the Pretty view — Postman renders HTML without complaint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not fix this by stripping the HTML
&lt;/h2&gt;

&lt;p&gt;It is tempting to add a repair step that slices everything before the first &lt;code&gt;{&lt;/code&gt; and parses the remainder. That converts a loud failure into a quiet one. The request is still wrong, and once the error page changes shape you will be debugging a half-parsed object instead of a 404. Fix the path, the authentication or the routing, and keep the guard from the snippet above so the next occurrence fails where you can see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wording across engines
&lt;/h2&gt;

&lt;p&gt;The same defect is reported differently depending on what parsed it. If your message is not the one at the top of this post, it is probably in this table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Message&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome, Edge, Node 20+&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Unexpected token '&amp;lt;', "&amp;lt;!DOCTYPE "... is not valid JSON&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chrome, Node before v20&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Unexpected token &amp;lt; in JSON at position 0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox&lt;/td&gt;
&lt;td&gt;&lt;code&gt;JSON.parse: unexpected character at line 1 column 1 of the JSON data&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safari, JavaScriptCore&lt;/td&gt;
&lt;td&gt;&lt;code&gt;JSON Parse error: Unexpected identifier "&amp;lt;!DOCTYPE"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python &lt;code&gt;json.loads&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Check whether a body is JSON at all
&lt;/h2&gt;

&lt;p&gt;Once the endpoint returns data, paste it into the &lt;a href="https://json-tool.com/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=json-parser-errors" rel="noopener noreferrer"&gt;JSON formatter&lt;/a&gt; to read the structure. If you are not yet sure whether a body is JSON at all, paste it in as it arrived: a &lt;code&gt;&amp;lt;&lt;/code&gt; reported at position 0 is a definitive answer that the content type was lying, and the &lt;a href="https://json-tool.com/json-validator.html?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=json-parser-errors" rel="noopener noreferrer"&gt;validator&lt;/a&gt; gives you the line and column rather than a bare offset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://json-tool.com/json-syntax.html?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=json-parser-errors" rel="noopener noreferrer"&gt;JSON syntax cheat sheet&lt;/a&gt; — what the grammar permits, with valid and invalid examples side by side.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://json-tool.com/blog/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=json-parser-errors" rel="noopener noreferrer"&gt;JSON guides&lt;/a&gt; — the growing set of parser-error explainers.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>json</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
