Double-Encoded JSON: Why Your JSON Is Full of Backslashes
Seeing \" everywhere or a JSON object wrapped in quotes? That is double-encoded JSON. Learn how it happens, how to decode it safely and how to stop it.
You open a log, an API response or a database column and the JSON looks like this:
"{\"id\":42,\"name\":\"Ada\",\"tags\":[\"admin\"]}"
The data is all there, but it is wrapped in quotes and every inner quote has a backslash in front of it. Your code calls JSON.parse, gets back a string instead of an object, and data.id is undefined. This is double-encoded JSON: a JSON document that was serialized, and then serialized again as a string.
You can paste a string like this into the tool below and unstringify it back into readable JSON. It runs in your browser, so nothing you paste is uploaded.
JSON StringifyTurn a JSON document into an escaped string you can paste into code, or reverse an escaped string back into readable JSON.How it happens
JSON.stringify turns a value into JSON text. If that value is already JSON text, it is a string, so it gets quoted and its quotes get escaped:
const user = { id: 42, name: 'Ada' };
const once = JSON.stringify(user);
// {"id":42,"name":"Ada"}
const twice = JSON.stringify(once);
// "{\"id\":42,\"name\":\"Ada\"}"
Nobody writes JSON.stringify(JSON.stringify(x)) on purpose. It happens when two layers of code each think they own serialization:
- Fetch with a pre-stringified body passed to a helper that stringifies again. For example, an axios or
kycall givenJSON.stringify(payload)asdata, or a wrapper that always callsJSON.stringifyon whatever it receives. - Returning a string from a framework handler. Express
res.json(str), a NestJS controller, or a Lambda that returnsbody: JSON.stringify(result)inside a response that API Gateway serializes again. - Storing JSON in a text column, then serializing the row. The column holds JSON text; the ORM returns it as a string; the API serializes the whole row, and the column comes out escaped.
- Structured loggers. You log
JSON.stringify(event)as the message, and the logger writes its own JSON line with that message as a string field. - Message queues and webhooks. A producer sends JSON text as the payload, and the transport wraps it in an envelope that is itself JSON.
How to recognize it
- The value starts and ends with
"instead of{or[. - Inner quotes appear as
\", and nested levels show\\\". typeof JSON.parse(text)is"string", not"object".- Pretty printers show one long line in a different color, because they see a single string value.
The number of backslashes tells you how many times it was encoded. Each extra layer escapes the backslashes from the layer before it:
| Layers | A quote inside the data looks like |
|---|---|
| 1 (normal) | " |
| 2 | \" |
| 3 | \\\" |
| 4 | \\\\\\\" |
Decoding it safely
Parse until you get something that is not a string. Cap the loop so a value that really is a string does not keep going forever:
function parseDeep(text, maxDepth = 5) {
let value = JSON.parse(text);
for (let i = 0; i < maxDepth && typeof value === 'string'; i++) {
try {
value = JSON.parse(value);
} catch {
break; // a real string, not more JSON
}
}
return value;
}
parseDeep('"{\\"id\\":42}"'); // { id: 42 }
Do not try to fix it with string replacement such as text.replaceAll('\\"', '"'). It breaks as soon as the data contains a real backslash, like a Windows path C:\Users or a regex, and it does nothing about \n, \t or \u00e9 escapes. JSON.parse handles every escape correctly; regex does not.
Fixing it at the source
Pick one place that serializes and let every other layer pass objects through:
// Wrong: the client library serializes `json` again
await ky.post('/api/users', { json: JSON.stringify(user) });
// Right: hand it the object
await ky.post('/api/users', { json: user });
// Wrong in Express: res.json() stringifies its argument
res.json(JSON.stringify(result));
// Right
res.json(result);
For logs, pass objects as fields rather than pre-stringified messages: logger.info({ event }, 'order created') instead of logger.info(JSON.stringify(event)). For database columns that hold JSON, use a native json or jsonb type, or parse the column in the data layer so the rest of the code never sees raw JSON text.
When escaped JSON is what you want
Sometimes a JSON string inside JSON is correct: an API field defined as a string, an environment variable, a test fixture embedded in source code, or a GraphQL variable typed as String. In those cases escaping is required, and doing it by hand is error-prone. Stringify once with a tool or with JSON.stringify, and keep the receiving side's single JSON.parse in mind.
Check the result
Once the value parses into an object, format it to confirm the structure looks right.
JSON FormatterPaste JSON to indent it readably and check that it parses, with the line and column of any error and a collapsible tree for finding your way around an unfamiliar response.If you changed code to remove a serialization layer, compare the API response before and after. Apart from the escaping, the two documents should be identical.
JSON ComparePaste two JSON documents to see every added, removed and changed value listed by path. Key order is ignored, so only real differences are reported.