<?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>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>
