Skip to content
Allin

JSON Is Simpler Than You Think, and That Is the Problem

Published 8/8/2025 · 19 min read · Developer tools

Daniel Okonkwo

Daniel OkonkwoFront-end developer and tech writer at Allin

Web performance · File formats

Checked against 7 sources

View profile
In short

JSON's grammar fits on one page, and that is exactly why it leaks. ECMA-404 and RFC 8259 define six kinds of value — object, array, string, number, true or false, null — and nothing else. There is no integer type: JSON has one number type, and a JavaScript parser lands it on an IEEE 754 double, so 1234567890123456789 comes back as the double 1234567890123456768, an error of 21, while Python's parser returns it exactly. Same bytes, two different values. There is no date type: a timestamp is a string whose format is a convention. NaN and Infinity cannot be written at all — JSON.stringify turns them into null and JSON.parse rejects the literals, though Python's json module emits them by default and produces documents that are not JSON. Negative zero survives asymmetrically: parsing -0 gives -0, but serialising -0 gives 0. Duplicate keys are legal in the grammar, RFC 8259 only says the result is unpredictable, and every mainstream parser silently keeps the last one. Comments are not in the grammar at all. A generated JSON Schema pins down shape, never meaning, and one inferred from a single sample over-fits badly. And key order is a byte-level fact: reorder two keys and the payload hashes differently, which breaks signatures.

JSON has no integer type, no date type, no comments and no schema. Every one of those absences produces a specific bug: a 19-digit ID comes back off by 21, a timestamp becomes a string nobody agreed on, NaN cannot be written down, and duplicate keys are legal. All of it run, in two languages.

Six kinds of value, and none of them is the one you wanted

JSON is specified twice, by ECMA-404 and by RFC 8259, and both documents are short because there is very little to say. A value is an object, an array, a string, a number, the literal true, the literal false, or the literal null. That is the whole type system. Everything else you think JSON has is something your language added on the way in or on the way out.

Count the absences. No integer type, only a single number production that a parser must map onto whatever numeric type it has. No date, no time, no duration. No binary — you get base64 in a string, which costs you a third more bytes. No comments. No enumerations, no ranges, no required fields, no schema of any kind. No guarantee about key order. No way to express a reference to another part of the same document, so a graph has to be flattened by hand.

None of that is a design flaw. JSON was extracted from a JavaScript literal syntax to move data between two programs that already agreed on what the data meant, and it does that job at a size and speed nothing has beaten. The flaw appears when a format that carries no meaning is used as though it carried some. Everything that follows is one instance of that mistake.

The number that comes back wrong

JSON's number grammar allows any decimal you can write. RFC 8259 warns that implementations vary and recommends staying inside IEEE 754 double precision, which in practice means integers up to 2^53 − 1 = 9,007,199,254,740,991. Above that, doubles stop being able to represent consecutive integers, and a JavaScript parser has nowhere else to put the value.

Run it. JSON.parse('9007199254740993') returns 9007199254740992 — the input was odd, the output is even, and no error was raised. Take a realistic 19-digit identifier of the kind social platforms and chat services hand out: JSON.parse('{"id": 1234567890123456789}') stores the double 1234567890123456768, which JavaScript then prints as 1234567890123456800 because that is the shortest decimal that round-trips to the same double. The identifier you received differs from the identifier you stored by 21, and it differs from the identifier you print by 32. A shorter 18-digit id, 175928847299117063, comes back as 175928847299117060.

Now feed the identical bytes to Python 3.13: json.loads returns 1234567890123456789 exactly, as a Python int, because Python's parser maps the JSON number production onto an arbitrary-precision integer when it has no fractional part. One document, two languages, two different values, and both parsers are conformant. That is the whole problem in one line — JSON does not tell a parser which numeric type to use, so the parser decides, and the decision is not the same everywhere.

The same hole swallows the integer-versus-decimal distinction in the other direction. JSON.stringify(1.0) produces the two characters "1", so a field your database declared as a decimal arrives as something a schema generator will label an integer. There is no way to write 1.0 in JSON and have it stay 1.0 through a JavaScript round trip. The fix for identifiers is blunt and universal: send them as strings. The platforms that got bitten early ship both fields — a numeric id and a string id — precisely because they could not fix their clients.

Dates, NaN, Infinity, and a minus sign that vanishes

JSON.stringify(new Date(...)) produces "2026-08-28T14:30:00.000Z", which looks like JSON understands dates. It does not. ECMAScript defines Date.prototype.toJSON, and JSON.stringify calls it; the result is an ordinary string. Parse it back and typeof gives "string". The format happens to be the one RFC 3339 profiles out of ISO 8601, but nothing in JSON requires it, and the moment a second service serialises with a different library you get a date string in another shape — an epoch integer, a local time with no offset, a "/Date(1234567890)/" wrapper from an older stack. Every one of those is equally valid JSON and equally unparseable without a prior agreement.

NaN and the two infinities are not in the grammar, so they cannot be written down at all. JSON.stringify({a: NaN, b: Infinity, c: -Infinity}) returns {"a":null,"b":null,"c":null} — three distinct floating-point values collapsed into one null, silently. JSON.parse('{"a":NaN}') throws a SyntaxError. Inside an array, undefined, functions and symbols also become null; as object values they are dropped entirely, so {a: undefined, b: 1} serialises to {"b":1} and a key simply disappears.

Python does something worse and more interesting: json.dumps({'a': nan, 'b': inf}) emits {"a": NaN, "b": Infinity} by default, and json.loads reads it back happily. That output is not JSON. It will pass through your own Python services untouched and fail the moment it reaches a conformant parser in any other language, which is usually the browser, which is usually production. The flag exists — allow_nan=False raises instead — and almost nobody sets it.

Negative zero is the smallest and strangest of the set. IEEE 754 has two zeros, and they matter in the places where a sign carries information — a rate of change, a rounding direction, a balance that moved to exactly nothing from below. JSON.parse('-0') returns -0: Object.is(JSON.parse('-0'), -0) is true, and 1 divided by it is −Infinity. But JSON.stringify(-0) returns the single character 0. So the value survives one direction and not the other, and a full round trip flips 1 ÷ x from −Infinity to +Infinity without any warning at all.

Duplicate keys are legal, and every parser quietly picks one

The JSON grammar defines an object as a comma-separated sequence of name-value pairs. It does not say the names have to differ. RFC 8259 addresses this in prose rather than in the grammar: names SHOULD be unique, and it warns that implementations given a duplicate behave differently — some take the last, some take the first, some report an error. That SHOULD is the weakest word the RFC could have used, and it means every parser you will ever meet accepts the document.

In practice the mainstream converged. JSON.parse('{"role":"admin","role":"user"}') returns {"role":"user"} in Node, and Python's json.loads returns the same. Last one wins, no warning, no way to detect after the fact that the document ever had two. The information that a duplicate existed is destroyed by the parse itself, which is what makes it hard to debug: by the time your code sees an object, the evidence is gone.

The consequence worth taking seriously is that a document can mean one thing to the component that checks it and another to the component that acts on it, if those two components use parsers that disagree — or if one inspects the raw text and the other the parsed object. The general shape of that hazard, and the rule that follows from it, are covered in this site's article on verifying signed tokens: validate and act on the same parsed representation, never on two. The specific mitigation here is simpler. Reject documents with duplicate names at your ingress, before anything else looks at them; a streaming parser or a tokenising pre-pass can see the duplicate that JSON.parse throws away.

What a generated schema buys, and where it over-fits

JSON Schema fills the biggest hole: it is a vocabulary for saying which keys must exist, what type each value has, which values are allowed, and how deep the nesting goes. A validator turns a shapeless document into a yes or a no at the edge of your system, which is worth a great deal. Generating a first draft from a sample you already have is the fastest way to get there, and it is what this site's schema generator does.

The trap is that a schema inferred from one document describes that document, not the family it belongs to. Take a sample that looks harmless: an object with an integer id of 42, a name, a one-element array of string tags, an integer score of 10, a manager that happens to be null, and a boolean. A naive generator produces type integer for id and score, type null for manager, an array of strings for tags, every key in required, and additionalProperties false.

Now validate five perfectly legitimate later documents against it. A score that arrives as 10.5 is rejected, because the sample happened to be a whole number. A manager that is finally filled in with an object is rejected, because the sample happened to be null. A document that omits an optional field is rejected, because the generator put every key in required. A document with a new email field is rejected, because additionalProperties was false. A tags array containing a number is rejected. Five out of five, and every one of them is a real record your system should have accepted.

The other half of the lesson is what the same schema happily accepts: a record with an empty name and a score of −999 passes every check, because JSON Schema validates shape and never meaning. Nothing in the vocabulary knows that a name should be non-empty or that a score has a floor. So use generation as a first draft and then edit it by hand: widen integer to number wherever a decimal is possible, replace a null type with a nullable union, cut required down to the fields that are genuinely mandatory, leave additionalProperties open unless you are deliberately locking the contract, and add the minLength, minimum and enum constraints that carry your actual business rules.

Key order, and the signature that stops matching

JSON objects are unordered as a data model, but a JSON document is a sequence of bytes and the bytes have an order. JSON.stringify emits string keys in insertion order — with one exception that catches people out. ECMAScript orders integer-index keys first, ascending, ahead of every string key. Build an object by assigning z, then user_2, then "2", then user_1, then "1", and stringify returns {"1":5,"2":3,"z":1,"user_2":2,"user_1":4}. The two numeric-looking keys jumped to the front and sorted themselves numerically; the rest stayed in the order you wrote them. Parsing does the same thing, so a document you received in one order comes out of JSON.parse in another.

That becomes a production incident the moment you hash a payload. Two services describe the same $100 transfer: one writes {"amount":100,"currency":"USD","to":"acct_9"} and the other writes the same three fields starting from "to". The objects are deep-equal. The SHA-256 digests are 1648f3b9016a5b95… and bc654befe505d093…, and an HMAC computed over each differs from the first byte. The receiver rejects a request that is, semantically, exactly the one it was expecting.

Sorting the keys before serialising fixes this particular case — both objects canonicalise to the amount-first form and the digests match. But sorting alone is not a canonical form, because the same value can still be written in more than one way: "é" and "\u00e9" are the same string and different bytes, 1e21 and 1000000000000000000000 are the same number, and a serialiser may or may not escape the forward slash. RFC 8785, JSON Canonicalization Scheme, is the standardised answer: it fixes key order by UTF-16 code unit, pins number formatting to the ECMAScript rules and defines exactly which characters get escaped. If you can avoid the problem entirely, do: sign and verify the exact bytes you received, and never re-serialise a document you are about to check.

The practical checklist

Send every identifier as a string, whatever its type in your database. Agree one timestamp format in writing — RFC 3339 with an explicit offset is the least contentious — and reject anything else at the boundary rather than guessing. Decide in advance what a missing value means, and pick either null or absence, not both. Never let a NaN or an infinity reach a serialiser; convert it to null, to a string, or to an error, deliberately, where the computation happens.

Reject duplicate names at ingress. Generate a schema to save typing, then edit it before you trust it. Canonicalise or sign raw bytes, never a re-serialised object. And keep configuration files, where humans need comments and trailing commas, in a format that has them — which is the subject of the next article.

The same JSON document read by two conformant parsers — Node v26.3.0 and Python 3.13.2
In the documentNode returnsPython returnsConsequence
900719925474099390071992547409929007199254740993 (exact)Odd number becomes even, no error
12345678901234567891234567890123456768, printed as 12345678901234568001234567890123456789 (exact)Identifier off by 21; the two services disagree
1.01, and JSON.stringify writes it back as "1"1.0 as a float, written back as 1.0Decimal/integer distinction lost in one language only
A serialised Date, "2026-08-28T14:30:00.000Z"A string (typeof is "string")A stringThere is no date type; the format is a convention
NaN written as a literalSyntaxError — rejectednan — accepted, and emitted by defaultPython writes documents that are not JSON
-0 serialised from a programWritten as 0; the sign is goneWritten as -0.0; the sign survives1 ÷ x flips from −Infinity to +Infinity
{"role":"admin","role":"user"}role = user (last wins)role = user (last wins)Legal grammar, unpredictable per RFC 8259
JSON Schema generatorTurn a sample of JSON into a JSON Schema. Paste an object, an array or newline-delimited records and it infers the types, properties and array shapes, merges the fields seen across objects into a required list, and recognises common string formats — email, URL, UUID, date and date-time — across Draft 2020-12, 2019-09 or Draft-07.Try the tool

Frequently asked questions

How do I move a 64-bit identifier through JSON without losing digits?
Send it as a string. That is the only fix that works everywhere, and it is why the platforms that were bitten first publish two fields — a numeric id and a string version of the same id — rather than breaking their clients. A JSON.parse reviver will not help you: the reviver runs after the tokeniser has already produced the double, so by the time your callback sees the value the digits are gone. Bigint-aware parsers exist and do work, because they read the token text and decide the type themselves, but they change what your code receives and every downstream comparison, JSON.stringify and arithmetic has to be audited. If you cannot change the producer, at least detect the damage: an integer whose absolute value exceeds Number.MAX_SAFE_INTEGER, 9007199254740991, is no longer trustworthy, and a round trip through String(BigInt(x)) compared against the raw token will tell you whether it survived. And when you do move to strings, remember that string identifiers sort lexicographically, so "10" comes before "9" — any ordering you relied on has to move to a separate numeric field or to the database.
Are duplicate keys actually valid JSON?
Yes, grammatically. Neither ECMA-404 nor the grammar in RFC 8259 forbids a repeated name, so a document containing one parses. RFC 8259 adds a prose requirement at the SHOULD level — names ought to be unique — and warns that implementations differ in what they do when they are not, listing three plausible behaviours: keep the last, keep the first, or report an error. Measured here, Node's JSON.parse and Python's json.loads both keep the last, so {"role":"admin","role":"user"} yields the user role in both. Because the parse itself discards the evidence, you cannot detect the duplicate from the resulting object, and no amount of validation afterwards will find it. The practical rule is to reject at ingress: either use a streaming or event-based parser that reports each name as it appears, or run a cheap tokenising pre-pass that counts names per object, and refuse the request. A schema will not do this for you — JSON Schema operates on the parsed instance, by which point the duplicate has already been resolved.
JSON has no comments. What do I use for configuration files?
Comments were left out deliberately, on the reasoning that people would start putting parsing directives in them. Trailing commas, single-quoted strings and unquoted keys are absent for the same reason: the grammar is small so that every implementation agrees. That is a good property for data on a wire and an awful one for a file a human maintains. The honest split is to use strict JSON for anything a machine produces or transmits, and something friendlier for anything a person edits. JSONC — JSON with comments — is what several editors and toolchains accept and is the smallest step away. JSON5 adds trailing commas, unquoted keys, single quotes, hexadecimal numbers and the NaN and Infinity literals JSON lacks. TOML is designed for configuration specifically and has real dates. YAML is the most widely deployed and is covered in the next article in this series, including the ways its type inference will surprise you. The one thing not to do is a "_comment" key: it is legal, it survives round trips, and it also survives into whatever you serialise next, where nobody expects it.
Does a generated schema replace hand-written validation?
No, for two separate reasons. First, generation over-fits: measured above, a schema inferred from a single record rejected five out of five legitimate later records — a decimal where the sample had an integer, a populated field where the sample had null, an absent optional, an added field, and a mixed-type array. Every one of those is a normal evolution of a real payload. Second, JSON Schema validates shape and not meaning by design, so the same schema accepted a record with an empty name and a score of −999 without complaint. What generation is genuinely good for is the tedious part: enumerating fifty keys and their types without a typo, and giving you a starting file that already parses. Treat the output as a draft and make four edits before you trust it — widen integer to number wherever a decimal can occur, make nullable fields a union rather than the null type, prune required to what is genuinely mandatory, and decide consciously whether additionalProperties should be false. Then add the constraints that carry the business rules, because those are exactly the ones no generator can infer from data.
Why do two services compute different hashes of the same payload?
Because a hash is over bytes and the two services produced different bytes for the same value. Three things vary independently. Key order is the usual culprit: two objects that are deep-equal serialise differently if their keys were inserted in a different order, and ECMAScript additionally hoists integer-index-like keys to the front in ascending numeric order, so "2" and "10" move ahead of every other key regardless of where you wrote them. String escaping is the second: "é" written directly and written as \u00e9 are the same string and different bytes, and serialisers disagree about escaping the forward slash and the two line separators U+2028 and U+2029. Number formatting is the third: 1e21 and its long decimal form denote the same double, and 1.0 serialises as 1. The right fix depends on where you are. If you are verifying something you received, hash the exact bytes that arrived and never re-serialise them, which sidesteps all three problems at once. If you have to hash a value you constructed yourself, use a defined canonical form: RFC 8785 specifies one, fixing key order, number formatting and escaping together, and libraries implement it in most languages.
Is JSON.parse safe on untrusted input?
Structurally, yes, and far safer than the eval it replaced: the grammar contains no executable construct, so a parsed document cannot run code. Two specific worries come up and both are less alarming than their reputation. Prototype pollution is not caused by JSON.parse — the specification requires it to create data properties, so JSON.parse('{"__proto__": {"admin": true}}') yields an object with an ordinary own property named __proto__ and leaves Object.prototype untouched; verified here, ({}).admin is still undefined. Pollution happens afterwards, in a naive recursive merge or an unguarded assignment loop that walks those keys, so that is where the guard belongs. Stack overflow from deep nesting is also largely historical: V8's parser is iterative, and one million levels of nested arrays parsed without error in Node 26. What remains genuinely worth limiting is size and time. A parser must read the whole document before producing anything, so an unbounded body means unbounded memory, and duplicate keys, oversized numbers and unexpected fields all still need rejecting at the boundary. Cap the request body, then validate.

Articles you may find interesting

All guides
GuideSQL Formatting and the IN Clause That Breaks ProductionBuilding an IN list by string concatenation is both the classic injection vector and a performance cliff. Parameterisation fixes the first structurally, because the plan is compiled before any value arrives. The second needs arithmetic: the vendors' documented parameter ceilings, and what a query whose text changes with every list length does to a plan cache.ExplainerYAML Looks Friendly and BitesYAML is JSON plus a type-inference layer, and the inference is the dangerous part. The same file run through a YAML 1.2 parser and a YAML 1.1 parser: no is a string in one and false in the other, 01234 is 1234 in one and 668 in the other, and 12:30:00 is a number in one of them.ExplainerJSON to CSV When the Structure Is Nested: Why There Is No Right AnswerThe same two orders come out as five columns from one converter and ten from another, and neither is wrong. Dotted paths, arrays of scalars, arrays of objects and records with different keys — four decisions, made for you, usually silently.ExplainerXML to JSON: Attributes, Repetition, and the Single-Element Array TrapTwo documents that differ only in how many children exist produce two different JSON shapes, and no converter can tell them apart without a schema. Plus what this one really does with attributes, mixed content and whitespace — and the one thing it still cannot record.ExplainerCSV to JSON: The Five Cases That Break Every ConverterQuoted delimiters, embedded newlines, ambiguous types, duplicate headers and encoding. Each one was run through the converter and the exact output is printed here — including the two cases it does not rescue.ComparisonJSON vs XML: What's the Difference?JSON and XML both store structured data as text, but they trade off differently. Here's how each looks, where each wins, and how to choose.

Related tools

Sources

Spotted a mistake in this article?