JSON.parse vs JSON.stringify: Inverse Operations, Asymmetric Behavior

Quick answer

💡JSON.parse converts a JSON-formatted string into a JavaScript value. JSON.stringify converts a JavaScript value into a JSON string. They are inverse operations but not perfectly symmetric: stringify silently drops undefined values, functions, and Symbol properties, while parse can use a reviver function to transform values during deserialization.

Error symptoms

  • JSON.parse returns null instead of expected object
  • JSON.stringify returns undefined for certain property values
  • Date objects become strings after a stringify-parse round trip
  • Map and Set instances serialize as empty objects {}
  • Class instance methods disappear after JSON serialization
  • Circular reference error from JSON.stringify on certain objects

Common causes

  • Passing a JavaScript object directly to JSON.parse instead of a string
  • Not accounting for stringify silently omitting undefined properties
  • Relying on JSON round-trip to clone objects that contain Date or Map
  • Missing a replacer function when serializing non-standard data types
  • Forgetting that parse returns the exact JSON type including null and primitives
  • Assuming reviver and replacer are symmetric when they have different signatures

When it happens

  • When storing application state in localStorage and reading it back on page load
  • When sending configuration objects over a REST API and reconstructing them on the server
  • When implementing a deep clone of a plain object using the JSON round-trip pattern
  • When deserializing ISO date strings from an API response back into Date objects
  • When serializing a Map to send as a JSON body and needing to reconstruct it on receipt

Examples and fixes

JSON.parse converts ISO date strings into plain strings. A reviver function intercepts each key-value pair and can convert matching strings back to Date instances.

Using reviver to restore Date objects after parsing

❌ Wrong

const apiResponse = '{"userId": 42, "createdAt": "2026-05-01T08:00:00.000Z", "updatedAt": "2026-05-05T12:30:00.000Z"}';

const userData = JSON.parse(apiResponse);

console.log(typeof userData.createdAt);
// Output: string — not a Date object

console.log(userData.createdAt.getFullYear());
// TypeError: userData.createdAt.getFullYear is not a function

const daysSince = (new Date() - userData.createdAt) / 86400000;
console.log(daysSince);
// NaN — arithmetic on a string returns NaN

✅ Fixed

const apiResponse = '{"userId": 42, "createdAt": "2026-05-01T08:00:00.000Z", "updatedAt": "2026-05-05T12:30:00.000Z"}';

const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;

const userData = JSON.parse(apiResponse, (key, value) => {
  if (typeof value === 'string' && ISO_DATE_PATTERN.test(value)) {
    return new Date(value);
  }
  return value;
});

console.log(userData.createdAt instanceof Date);
// true

const daysSince = (new Date() - userData.createdAt) / 86400000;
console.log(Math.floor(daysSince));
// 4

JSON has no native date type, so JSON.stringify serializes Date objects as ISO 8601 strings and JSON.parse reads them back as plain strings. The reviver function receives each key-value pair after the parser has processed it, working bottom-up from leaf nodes to the root. By testing each string value against an ISO date pattern inside the reviver, you convert matching strings into Date instances during the parse step itself. This approach is preferable to post-processing the parsed object because it handles arbitrarily nested date fields without requiring you to know their paths in advance.

JSON.stringify produces an empty object when given a Map because Maps are not plain enumerable objects. A replacer function intercepts specific types and converts them to a serializable form.

Using replacer to serialize a Map as an array of pairs

❌ Wrong

const permissionsMap = new Map([
  ['admin', ['read', 'write', 'delete']],
  ['editor', ['read', 'write']],
  ['viewer', ['read']]
]);

const serialized = JSON.stringify({ permissions: permissionsMap });
console.log(serialized);
// {"permissions":{}} — Map serialized as empty object

const restored = JSON.parse(serialized);
console.log(restored.permissions instanceof Map);
// false
console.log(restored.permissions.get('admin'));
// TypeError: restored.permissions.get is not a function

✅ Fixed

const permissionsMap = new Map([
  ['admin', ['read', 'write', 'delete']],
  ['editor', ['read', 'write']],
  ['viewer', ['read']]
]);

const replacer = (key, value) => {
  if (value instanceof Map) {
    return { __type: 'Map', entries: [...value.entries()] };
  }
  return value;
};

const reviver = (key, value) => {
  if (value && value.__type === 'Map') {
    return new Map(value.entries);
  }
  return value;
};

const serialized = JSON.stringify({ permissions: permissionsMap }, replacer);
const restored = JSON.parse(serialized, reviver);

console.log(restored.permissions instanceof Map); // true
console.log(restored.permissions.get('admin')); // ['read', 'write', 'delete']

JSON.stringify only serializes own enumerable properties of plain objects. Map, Set, and other built-in collection types do not expose their entries as enumerable properties, so they serialize as empty objects. The replacer function receives every value that stringify processes, letting you intercept non-serializable types and convert them to tagged plain-object representations. By encoding a type marker such as __type into the serialized form, the corresponding reviver function can recognize and reconstruct the original type during parsing. This round-trip pattern works for any custom type as long as you implement matching replacer and reviver logic.

Parse and stringify as inverse operations

JSON.parse and JSON.stringify are often described as inverses, and in the common case they behave that way: stringify converts a JavaScript value to a JSON string, and parse converts that string back to an equivalent JavaScript value. However, the word equivalent deserves close attention, because the two operations are not lossless in either direction.

On the stringify side, the JSON specification defines only six value types: strings, numbers, booleans, null, arrays, and objects. JavaScript has many more types, and stringify handles the gap by silently discarding them. A property whose value is undefined, a function, or a Symbol is simply omitted from the output string without any error or warning. This means that two objects which look different in JavaScript — one with an undefined property and one without that property entirely — can produce identical JSON output. If your code depends on the presence of a key whose value might be undefined, you can be silently surprised after a stringify-parse round trip.

On the parse side, the situation is the reverse: JSON.parse is faithful to the JSON string it receives, but it can only produce values in the six JSON types. A JSON string is always a JavaScript string, a JSON number is always a JavaScript number (with the precision limitations that entails), and there is no JSON representation for Date, Map, Set, RegExp, or any other JavaScript built-in. This means that if you stringify a Date, you get an ISO 8601 string, and when you parse it back, you get a plain string — not a Date object.

Understanding this asymmetry is the foundation of working correctly with both functions. The reviver and replacer parameters exist precisely to bridge the gap: replacer lets you control what stringify produces for non-standard types, and reviver lets you reconstruct those types after parsing. Without them, any data structure more complex than plain objects and arrays will degrade silently across a stringify-parse cycle.

Catching reviver and replacer edge cases

Debugging problems with reviver and replacer functions requires understanding the order in which they are called. The replacer function receives every value that JSON.stringify processes, starting at the root and working inward. The root object itself is passed first with an empty string key, then each property in the order they appear on the object. If a replacer returns undefined for a value, that key-value pair is omitted from the output entirely — the same behavior as if the property did not exist.

The reviver function, by contrast, works bottom-up. JSON.parse builds the full parsed structure first, then calls the reviver on every key-value pair starting from the deepest leaf nodes and moving toward the root. The final call is always for the root value itself with an empty string key. If the reviver returns undefined for any value, that property is deleted from the parent object. This bottom-up traversal means that by the time the reviver processes a container object, its nested properties have already been through the reviver and may have been transformed.

A common edge case arises when using reviver to convert date strings: if you check typeof value === 'string' without validating the string format, you risk converting unrelated strings that happen to match your pattern. Regular expressions with anchors — testing the full string rather than a substring — protect against accidental conversions. Another edge case is the root value: JSON.parse('null') returns null, JSON.parse('42') returns the number 42, and JSON.parse('"hello"') returns the string hello. When your code always expects an object from JSON.parse, these primitive-root JSON strings can cause downstream errors if not handled.

For replacer, a common mistake is returning null instead of the original value when you want to pass a value through unchanged. Returning null from a replacer does not mean skip this property — it serializes as the JSON literal null. To pass a value through the replacer unchanged, always return the value argument itself.

The JSON clone pattern and its limits

JSON.parse(JSON.stringify(obj)) is a widely-used pattern for creating a deep clone of an object. It works reliably for plain data objects — objects whose properties are strings, numbers, booleans, null, nested plain objects, and arrays. For these structures, the round trip produces a new object that shares no references with the original, which is exactly what a deep clone should do. It is simple to write, requires no dependencies, and is faster than many recursive clone implementations for moderately-sized objects.

The limits of this pattern follow directly from what stringify drops and what parse cannot reconstruct. Date objects become ISO strings, not Dates. Map and Set instances become empty objects. Functions and Symbols disappear entirely. Properties explicitly set to undefined vanish from the clone, which can cause problems if your code distinguishes between a missing property and a property set to undefined. Class instances lose their prototype chain: after the round trip, you get a plain object with the same data but none of the methods from the class.

For objects that contain only JSON-safe types, the pattern is reliable and appropriate. For everything else, you need a different strategy. The structured clone algorithm, available in modern browsers and Node.js as structuredClone(), handles Date, Map, Set, ArrayBuffer, and circular references, making it the better general-purpose deep clone for most application code. For even more control over what gets cloned and how, libraries such as lodash.cloneDeep provide customizable clone logic.

When you do use the JSON clone pattern, it is worth being explicit about its contract in code comments or documentation: this function accepts only plain JSON-serializable data. This prevents future contributors from passing Date or class instances and being confused by silent data loss.

Reviver versus replacer function signatures

Reviver and replacer share a similar signature — both receive a key and a value — but their contexts differ in ways that matter when writing correct implementations. In a replacer, the this keyword refers to the object that contains the property being serialized. This lets you inspect sibling properties of the current value, which can be useful for context-aware serialization decisions. In a reviver, the this keyword similarly refers to the containing object, letting you inspect neighboring already-revived values.

Replacer has an alternative form: instead of a function, you can pass an array of property name strings. When stringify receives an array replacer, it only includes properties whose names appear in that array, omitting all others. This is a convenient way to produce a filtered JSON output without writing a function, and it works recursively — the filter applies at every nesting level. The array form of replacer does not allow value transformations, only property selection.

A subtlety in reviver is that it is called for every value including array elements, using the numeric index as a string for the key. This means a reviver that checks for specific key names will not match array elements, since the keys are '0', '1', '2', and so on. If your data has date strings inside arrays, your reviver must check the value regardless of whether the key matches an expected name.

The replacer and reviver are also independent: you can use a replacer without a reviver and vice versa. But when you use both to implement a round-trip for non-standard types, they must be designed as a matched pair. A common pitfall is updating the replacer to handle a new type and forgetting to update the corresponding reviver, or vice versa, leading to asymmetric serialization where data goes in but cannot come out correctly.

When parse throws but stringify succeeds

JSON.stringify almost never throws. It will drop unsupported types silently, it will serialize undefined as if the key did not exist, and it will handle nested structures of arbitrary depth. The main exception is circular references: if an object contains a property that references one of its own ancestors, stringify will throw a TypeError: Converting circular structure to JSON. This can happen unexpectedly with certain DOM nodes, framework component instances, or objects that have back-references to their parent containers.

JSON.parse, on the other hand, is strict. It throws a SyntaxError for any deviation from the JSON grammar, including trailing commas, single-quoted strings, JavaScript comments, unquoted keys, and the literal undefined. The error message typically includes the position in the string where the unexpected character was found, which is the most useful piece of information for diagnosing the problem.

A common scenario where stringify succeeds but parse fails is when stringify produces valid JSON that gets concatenated with non-JSON content before being parsed. For example, a logging system might prepend a timestamp to a serialized payload, producing a string that is no longer valid JSON. Similarly, a server response might include a UTF-8 BOM at the start of the body, which causes parse to throw at position 0 with a message about an unexpected token.

Another scenario is large integers. JSON.stringify will serialize JavaScript numbers faithfully, but JavaScript numbers are IEEE 754 doubles, which cannot represent integers larger than 2^53 - 1 without loss of precision. If a server sends a JSON number larger than this threshold — common with database row IDs in some systems — JSON.parse will parse it as the nearest representable double, silently losing precision. This is a case where the parse appears to succeed but produces incorrect results rather than throwing an error.

Safe serialization patterns in production

In production code, the single most important practice around JSON.parse and JSON.stringify is to always wrap parse calls in a try-catch. The input to JSON.parse is often external: an API response, a localStorage value, a message queue payload, or a configuration file. Any of these can be malformed, truncated, or replaced with an error page by an upstream system. Without a try-catch, a malformed input will throw an uncaught SyntaxError and crash the affected request or component.

When catching a parse error, log the raw input string along with the error message. The position offset in the error message is only useful if you can see the full string to locate that position. Logging just the error without the input leaves you unable to reproduce the failure in development. For sensitive data, log a truncated or sanitized version rather than nothing at all.

For stringify, validate that your objects do not contain circular references before serializing them. Many frameworks and libraries produce objects with internal circular references that look like plain data but are not. A simple check is to call JSON.stringify in a try-catch and handle the circular reference TypeError separately from any downstream errors.

When implementing a replacer-reviver pair for custom types, define them as named functions rather than inline lambdas, and place them in a module that is imported wherever the serialization pair is needed. This ensures that the serialize and deserialize logic stay in sync when the type changes. Document the tagged format you use — for example, how you mark a Map with a __type field — so that any future consumer of the serialized data knows how to reconstruct the original type. When the serialized format needs to change, version the tag so that old serialized data can still be deserialized by code that understands the new format.

Quick fix checklist

  • Wrap every JSON.parse call in a try-catch block
  • Log the raw input string when a parse error is caught
  • Use a reviver to convert ISO date strings back to Date objects
  • Use a replacer to handle Map, Set, or other non-serializable types
  • Do not use JSON round-trip to clone objects containing Date, Map, or class instances
  • Return the value argument unchanged in replacer when no transformation is needed
  • Test reviver logic against JSON strings whose root is a primitive like null or a number
  • Use structuredClone() instead of JSON round-trip for general-purpose deep cloning

Related guides

Frequently asked questions

What does JSON.parse return when given the string 'null'?

JSON.parse('null') returns the JavaScript value null. This is correct and expected behavior. JSON null is a valid JSON value, and parse faithfully converts it to the JavaScript null primitive. If your code always expects an object from JSON.parse, you should add a null check after parsing to handle this case before accessing any properties.

Why does JSON.stringify return undefined for some values?

JSON.stringify returns undefined when the top-level value is itself undefined, a function, or a Symbol. For these types at the top level, stringify returns the JavaScript value undefined rather than a string. For the same types nested inside an object, stringify omits those properties from the output. For these types inside an array, stringify replaces them with null to preserve array length.

Is JSON.parse safe to use instead of eval for parsing JSON?

Yes, JSON.parse is safe and eval is not. Eval executes arbitrary JavaScript, which means passing untrusted JSON-like input to eval can run malicious code. JSON.parse only processes JSON grammar and throws a SyntaxError for anything outside it. You should never use eval to parse JSON from external sources. All modern environments provide JSON.parse natively.

How does the reviver function traversal order work?

The reviver is called bottom-up, starting at the deepest leaf values and moving toward the root. By the time the reviver processes a container object, all of its nested properties have already been through the reviver and may have been transformed. The last call is always for the root value with an empty string as the key. If the reviver returns undefined for the root, JSON.parse returns undefined.

Can JSON.stringify produce output that JSON.parse cannot read?

No. Any string produced by JSON.stringify with no replacer is guaranteed to be valid JSON and can be parsed by JSON.parse without error. However, if you use a replacer that introduces invalid JSON characters or structure, the output may not be parseable. The circular reference TypeError is thrown before any output is produced, so it does not create invalid partial JSON.

Why does my Date object become a string after JSON round trip?

JSON has no date type. When JSON.stringify encounters a Date object, it calls the Date's toISOString() method and serializes the result as a JSON string. JSON.parse reads that string as a plain string with no knowledge that it originally represented a date. Use a reviver function that detects ISO 8601 strings and converts them back to Date instances to restore the original type.

What happens to undefined properties in an object passed to JSON.stringify?

Properties with undefined values are silently omitted from the JSON output. No error is thrown and no placeholder is inserted. This means the output object will have fewer keys than the input object if any values are undefined. If you need to represent the absence of a value in JSON, use null instead of undefined, since JSON.parse('null') returns null rather than dropping the key.

How do I serialize a Map to JSON and get a Map back after parsing?

Use a replacer that converts Map instances to a tagged plain object containing the entries array, then use a matching reviver that detects the tag and reconstructs the Map. The replacer receives the Map and returns something like { __type: 'Map', entries: [...map.entries()] }. The reviver checks for __type === 'Map' and returns new Map(value.entries). Both functions must be used consistently for the round trip to work.

Is JSON.parse faster than eval for parsing JSON strings?

Yes, JSON.parse is generally faster than eval for parsing JSON in modern JavaScript engines. Engines implement JSON.parse as a dedicated native parser optimized specifically for JSON grammar, whereas eval must handle the full JavaScript grammar. The performance difference is most noticeable for large JSON documents. Aside from performance, JSON.parse is also safer, and there is no reason to use eval for JSON parsing in any context.

All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-05.