Every JavaScript developer uses these two functions daily, and most have been bitten by at least one of their quirks. This guide covers the full signatures, then the behaviours that catch people out.
JSON.stringify
JSON.stringify(value, replacer, space)
Turns a JavaScript value into a JSON string.
JSON.stringify({ a: 1, b: [true, null] });
// '{"a":1,"b":[true,null]}'
JSON.stringify({ a: 1, b: 2 }, null, 2);
// '{\n "a": 1,\n "b": 2\n}' pretty printed with 2 spaces
JSON.stringify({ a: 1, b: 2 }, ["a"]);
// '{"a":1}' only the listed keys
JSON.stringify(obj, (key, val) => typeof val === "bigint" ? val.toString() : val);
// replacer function transforms each value
The third argument can also be a string such as "\t" to indent with tabs. Anything longer than ten characters is cut to ten.
JSON.parse
JSON.parse(text, reviver)
Turns a JSON string into a value. It throws a SyntaxError on invalid input, so wrap it in try and catch when the text comes from outside.
JSON.parse('{"when":"2026-09-22T10:00:00Z"}', (key, val) =>
key === "when" ? new Date(val) : val
);
// { when: Date object } reviver rebuilds types
The surprises
undefined, functions and symbols vanish
JSON.stringify({ a: undefined, b: () => {}, c: Symbol() });
// '{}'
JSON.stringify([undefined, () => {}]);
// '[null,null]' in arrays they become null instead
This is why an object can lose keys on a round trip through JSON.
Dates become strings and stay strings
JSON.stringify({ d: new Date(0) });
// '{"d":"1970-01-01T00:00:00.000Z"}'
JSON.parse('{"d":"1970-01-01T00:00:00.000Z"}').d
// the string, not a Date
Use a reviver or convert after parsing. This trips up nearly everyone once.
NaN and Infinity become null
JSON.stringify({ n: NaN, i: Infinity });
// '{"n":null,"i":null}'
BigInt throws
JSON.stringify({ big: 10n });
// TypeError: Do not know how to serialize a BigInt
Convert to a string in a replacer, or add a toJSON method to BigInt.prototype.
Large numbers lose precision
JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992
JavaScript numbers are 64 bit floats and cannot represent integers above 2 to the 53 exactly. If an API sends 64 bit IDs as numbers, they will be silently rounded. Ask for them as strings, or use a parser that supports BigInt.
Circular references throw
const a = {}; a.self = a;
JSON.stringify(a);
// TypeError: Converting circular structure to JSON
toJSON is called if present
const user = { name: "Ada", password: "x", toJSON() { return { name: this.name }; } };
JSON.stringify(user);
// '{"name":"Ada"}'
Date uses this mechanism internally. It is the cleanest way to control how a class serialises.
Key order is preserved, mostly
JavaScript keeps insertion order for string keys, so stringify outputs them in that order. Integer like keys such as "1" are always output first in numeric order regardless of when they were added. To get a stable output for comparison, sort the keys first. Our JSON formatter has a Sort Keys button that does this recursively.
The deep clone trick and its replacement
const copy = JSON.parse(JSON.stringify(obj));
This has been the go to deep copy for years. It works for plain data but drops everything listed above: dates become strings, undefined vanishes, maps and sets become empty objects. The modern replacement is structuredClone(obj), available in all current browsers and Node 17 and later. It handles dates, maps, sets, typed arrays and circular references correctly.
Quick reference
| Value | stringify result |
|---|---|
| undefined in object | key omitted |
| undefined in array | null |
| function | omitted or null |
| Date | ISO string |
| NaN, Infinity | null |
| BigInt | throws |
| Map, Set | {} |
| circular object | throws |