JavaScript has two built-in JSON functions, and both are less obvious than they look. Values disappear, Dates turn into strings, BigInt throws, and numbers above 2^53 change. Here is what happens and how to control it.
JSON.parse basics
JSON.parse(text) accepts strict JSON only: double-quoted strings and keys, no trailing commas, no comments, no undefined, NaN or single quotes. Any JSON value can be at the top level, so JSON.parse('42') and JSON.parse('"hi"') are fine.
A few behaviours worth knowing:
- Key order changes for integer-like keys. Objects list integer-like keys first, in ascending order:
JSON.parse('{"b":1,"2":2,"a":3,"1":4}')has keys1, 2, b, a. If order matters, use an array. - Duplicate keys are allowed and the last one wins, without a warning.
__proto__is safe. A"__proto__"key becomes an ordinary own property; it does not change the object’s prototype. Copying the result into another object with a spread orObject.assignis where prototype pollution can creep back in.- Numbers are doubles.
JSON.parse('9007199254740993')returns9007199254740992, and1e400becomesInfinity. See big integers in JSON for lossless options.
fetch’s response.json() uses the same parser, so all of this applies to API responses too.
Revivers: transforming values while parsing
The optional second argument is called for every value, from the deepest values outwards, and finally for the root with the key "". Whatever it returns replaces the value; returning undefined deletes the property.
const order = JSON.parse(text, (key, value) =>
key === 'createdAt' && typeof value === 'string' ? new Date(value) : value,
);
Because children are visited before parents, a reviver sees already-revived children when it reaches an object. Inside a regular function, this is the object holding the current key, which lets you inspect siblings.
Newer engines pass a third context argument whose source holds the original text of a primitive value, which makes lossless big integers possible:
JSON.parse('{"id":9007199254740993}', (k, v, ctx) =>
k === 'id' ? BigInt(ctx.source) : v,
); // { id: 9007199254740993n }
This comes from the “JSON.parse source text access” proposal; it is in current V8-based runtimes such as Chrome and Node.js, but feature-detect it before depending on it in a browser app.
JSON.stringify: what gets dropped
JSON.stringify converts what it can and silently skips or replaces the rest:
JSON.stringify({ a: undefined, b() {}, c: Symbol('s') }); // '{}'
JSON.stringify([undefined, () => 1, Symbol()]); // '[null,null,null]'
JSON.stringify({ n: NaN, i: Infinity, z: -0 }); // '{"n":null,"i":null,"z":0}'
JSON.stringify({ m: new Map([[1, 2]]), s: new Set([1]) }); // '{"m":{},"s":{}}'
JSON.stringify(undefined); // undefined, not a string
Properties whose value is undefined, a function or a symbol vanish from objects, and become null in arrays so indexes stay aligned. NaN and infinities become null, which silently changes meaning; validate numbers before sending them. Maps and Sets have no enumerable own properties, so they serialise as empty objects; convert them first with Object.fromEntries(map) or [...set]. Only own enumerable string keys are included, so class getters on the prototype and symbol keys are skipped too.
Two cases throw instead: circular references (TypeError: Converting circular structure to JSON, explained in this error guide) and BigInt values.
Replacers, toJSON and indentation
The second argument of JSON.stringify is either a function or an array. An array is an allow-list of property names, applied at every level:
JSON.stringify({ a: 1, b: { a: 2, c: 3 }, c: 4 }, ['a', 'b']); // '{"a":1,"b":{"a":2}}'
A replacer function works like a reviver in reverse: it receives each key and value, starting with the root under key "", and returns the value to write. Returning undefined omits the property. Use it to redact secrets before logging:
JSON.stringify(user, (k, v) => (k === 'password' ? undefined : v));
Before the replacer runs, any toJSON() method on the value is called, with the property name as its argument. That is how Date serialises: Date.prototype.toJSON returns toISOString(), always in UTC, and null for an invalid date. Give your own classes a toJSON to control their representation. The date format guide covers what to do with those strings on the way back.
The third argument indents: a number of spaces (capped at 10) or a string (only its first 10 characters are used). JSON.stringify(value, null, 2) is the familiar pretty-print.
BigInt
JSON.stringify({ n: 1n }) throws TypeError: Do not know how to serialize a BigInt. You have three options:
- Strings, the most portable choice: a replacer such as
(k, v) => (typeof v === 'bigint' ? v.toString() : v). - Raw numbers with
JSON.rawJSON, from the same proposal as the reviver context:JSON.stringify({ n: JSON.rawJSON('9007199254740993') })writes the digits unquoted. Check support first. - A global
BigInt.prototype.toJSON. It works, but it changes behaviour for every library in the page, so avoid it in shared code.
Deep copies: structuredClone vs JSON round trip
JSON.parse(JSON.stringify(obj)) was the classic deep-copy trick, and it inherits every limitation above: Dates become strings, Maps become {}, undefined disappears and cycles throw. structuredClone(obj), available in all modern browsers and Node.js 17 and later, copies Dates, Maps, Sets, typed arrays, undefined and circular references correctly. It throws a DataCloneError for functions and DOM nodes, and it does not preserve class prototypes, so instances come back as plain objects. Use the JSON round trip only when you specifically want the JSON-compatible subset, for example to check what an API will actually receive, or to strip class instances down to plain data before storing them in localStorage.
Reading JSON.parse errors
Error messages differ between engines. Chrome and Node.js say, for example:
SyntaxError: Unexpected end of JSON input
SyntaxError: "undefined" is not valid JSON
SyntaxError: Unexpected token '<', "<html>" is not valid JSON
SyntaxError: Expected double-quoted property name in JSON at position 7 (line 1 column 8)
Firefox reports JSON.parse: unexpected character at line 1 column 1 of the JSON data. The usual causes: an empty response body, a variable that was undefined and got stringified to the text undefined, an HTML error page returned instead of JSON, or a trailing comma. Check response.ok and the Content-Type header before calling response.json(), and wrap parsing of anything you did not produce yourself in try/catch. To find the exact spot in a large payload, paste it into the JSON validator; the unexpected token explainer covers each variant.
Frequently asked questions
Why does JSON.stringify drop properties that are undefined?
JSON has no undefined value, so the specification omits such properties from objects and writes null in arrays. Use null explicitly if the receiver must see the key.
How do I keep Dates as Date objects after JSON.parse?
JSON.parse returns ISO strings, so convert known fields after parsing or in a reviver function that turns them into Date objects.
How do I serialise a BigInt to JSON?
Convert it to a string with a replacer, or use JSON.rawJSON to emit the exact digits as a number where the runtime supports it. JSON.stringify throws on BigInt by default.
Is JSON.parse(JSON.stringify(obj)) a good deep copy?
Only for plain JSON-like data. structuredClone copies Dates, Maps, Sets, undefined and circular references correctly and is available in all modern runtimes.
Is JSON.parse safe to use on untrusted input?
Yes, it never executes code and does not modify prototypes. Validate the shape of the result before using it, and be careful when merging it into other objects.