JSON.stringify Explained: undefined, Dates and BigInt
Most surprises from JSON.stringify are silent: a field disappears, a Map becomes {}, a date turns into a string. Here is exactly what happens to each kind of value, and how to control it.
JSON.stringify looks like the simplest function in JavaScript, but a JavaScript value can hold far more kinds of data than JSON can. For each kind that JSON lacks, JSON.stringify makes a choice. It drops the value, converts it, or throws, and only the last one tells you anything happened.
This guide lists those choices, each checked in Node, then covers the two hooks that let you change them: toJSON and the replacer. The JSON Stringify tool covers a different job, turning a JSON document into an escaped string.
Values that disappear
undefined, functions and symbols have no JSON form. Inside an object the whole property is left out. Inside an array the slot stays, as null, so positions do not shift:
JSON.stringify({ a: 1, b: undefined, c: () => 1, d: Symbol("x") })
// '{"a":1}'
JSON.stringify([1, undefined, () => 1, Symbol("x")])
// '[1,null,null,null]'
JSON.stringify(undefined)
// undefined, not a stringThe last case catches people out. Code that writes JSON.stringify(value) to a file or a response body can end up writing the text undefined, or nothing at all, when value was never set.
Values that change silently
Input Output
{ a: NaN, b: Infinity, c: -Infinity } {"a":null,"b":null,"c":null}
{ at: new Date("2026-10-18T09:15Z") } {"at":"2026-10-18T09:15:00.000Z"}
{ at: new Date("not a date") } {"at":null}
{ m: new Map([["a", 1]]) } {"m":{}}
{ s: new Set([1, 2]) } {"s":{}}
{ z: -0 } {"z":0}
new Money(5, "EUR") {"amount":5,"currency":"EUR"}
new Error("timeout") {}- A
Datebecomes an ISO string through its built-intoJSONmethod, andJSON.parsedoes not turn it back into a date. An invalid date becomesnull. - A
MaporSetbecomes{}, because only an object's own enumerable properties are written, and a Map keeps its entries elsewhere. The data is lost without an error. - A class instance keeps its fields but loses its class. Parsing the result gives a plain object, without the methods.
- An
Errorbecomes{}, because itsmessageandstackare not enumerable. Log{ message: error.message, stack: error.stack }instead, or use a logger that handles errors itself.
Values that throw
Two kinds of value stop serialisation with a TypeError:
JSON.stringify({ id: 12345678901234567890n })
// TypeError: Do not know how to serialize a BigInt
const order = { id: "ord_1" };
order.self = order;
JSON.stringify(order)
// TypeError: Converting circular structure to JSONCycles appear more often than you might expect: ORM entities with links in both directions, DOM nodes, and Node's HTTP request and socket objects. Logging one of these with JSON.stringify is a common way to turn a handled error into an unhandled one.
JSON.stringify replacer and toJSON
An object with a toJSON method is replaced by whatever that method returns, which is how Date works. Add one to your own classes to choose their JSON form:
const price = { amount: 5, currency: "EUR", toJSON() { return "5.00 EUR"; } };
JSON.stringify({ price })
// '{"price":"5.00 EUR"}'A replacer function is called for every key and value, starting with the root under the empty key "". It sees each value after toJSON has run, so for a date it receives the string, not the Date. Return undefined to drop a property. Three replacers that solve the problems above:
// BigInt: write it as a string
JSON.stringify(data, (key, value) => (typeof value === "bigint" ? value.toString() : value));
// Map and Set: convert them instead of losing them
JSON.stringify(data, (key, value) => {
if (value instanceof Map) return Object.fromEntries(value);
if (value instanceof Set) return [...value];
return value;
});
// Secrets: drop a key at every depth
JSON.stringify(data, (key, value) => (key === "password" ? undefined : value));The well-known fix for cycles, a replacer that remembers every object in a WeakSet and replaces repeats with "[Circular]", has a flaw. It also replaces objects that are shared but not circular: with { billing: address, shipping: address }, shipping comes out as "[Circular]". For logging that may be acceptable. For data, remove the back-references before serialising.
The replacer can also be an array of key names. Only those keys are written, at every depth, so nested keys must be listed too: with ["id", "address"], the address object comes out empty unless city is also in the list.
Pretty printing with the space argument
The third argument sets indentation. A number means that many spaces, but values above 10 are treated as 10. A string is used as the indent itself, cut to its first 10 characters:
JSON.stringify({ a: [1] }, null, "--")
// {
// --"a": [
// ----1
// --]
// }Use 2 or "\t" for files people read, and leave the argument out for anything sent over a network or written to a log.
Related tools
Escape any string so it can be safely embedded inside JSON.
Turn a JSON-escaped string back into readable raw text.
Strip whitespace to shrink JSON to the smallest valid payload.
Pretty-print JSON with configurable indentation and instant validation.
Related guides
JSON escaping makes text safe inside JSON. Putting the JSON somewhere else needs that place's own escaping too. Here are the five places it goes wrong most often.
When a log line holds an object as an escaped string, you cannot search it by field. Here is how to read it today with jq, and the one-line change that stops it.
Whitespace in JSON is nearly free once gzip runs. Here is where JSON travels without compression, with the extra bytes measured on sample payloads.
Two formatters can disagree about the same JSON file. Here is how a team picks one, applies it in every editor and before every commit, and makes CI enforce it.