Double-Encoded JSON: Why It Happens and How to Decode It

By Wesley · · 7 min read

You open a log line or an API response expecting an object and get this instead: one long string, full of backslashes, that happens to contain JSON. That is double-encoded JSON (also called nested, stringified or escaped JSON). It is valid JSON, which is exactly why ordinary formatters leave it alone.

A payload that was serialized twice
{
  "event": "order.created",
  "payload": "{\"id\":42,\"items\":[\"apple\"],\"meta\":\"{\\\"source\\\":\\\"web\\\"}\"}"
}

Here payload is not an object. It is a string whose content is JSON, and inside it meta is another string that holds JSON again. A parser did nothing wrong by returning strings: that is what the document says.

How the backslashes multiply

Serializing a string means escaping two characters that matter here: the double quote becomes \" and the backslash becomes \\. Encode the result again and every backslash from the previous round is doubled, then one more is added for the quote. After n rounds a single quote is preceded by 2n − 1 backslashes.

Original value
"0 backslashes
Encoded once
\"1 backslash
Encoded twice
\\\"3 backslashes
Encoded three times
\\\\\\\"7 backslashes
Each extra round of encoding doubles the existing backslashes and adds one more.

This is why find-and-replace on \" only works for exactly one layer. With two or more layers a plain replace leaves stray backslashes behind and the text no longer parses.

LayersA quote appears asWhat a formatter shows
0"A normal object
1\"One string field containing JSON text
2\\\"A string containing a string containing JSON
3\\\\\\\"Mostly backslashes

Where double encoding comes from

  • Serializing twice. Code calls JSON.stringify(obj) (or json.dumps) and hands the result to a framework that serializes the response body again. The second pass sees a string and escapes it.
  • Structured logging. A logger writes each entry as a JSON line with a message field. If the message was already JSON text, it is stored as an escaped string. Container and cloud log pipelines often add one more envelope on top.
  • Message envelopes. When Amazon SNS delivers to SQS, the queue receives a JSON envelope whose Message field is a string. If your message was JSON, it is now one layer deep, and the SQS record body that wraps the envelope adds another.
  • Text columns. JSON saved into a VARCHAR or TEXT column comes back as a string, and the API that returns the row encodes that string once more.
  • Form fields and query strings. A JSON value placed in a hidden input, a URL parameter or an environment variable is a string by definition, and tends to stay one when it is copied into a larger document.

Decoding it in code

The reliable approach is the opposite of find-and-replace: parse, look at every string, and parse again when a string holds an object or array. Repeat until nothing changes.

JavaScript
function deepDecode(value) {
  if (typeof value === 'string') {
    const text = value.trim();
    const looksLikeJson =
      (text.startsWith('{') && text.endsWith('}')) ||
      (text.startsWith('[') && text.endsWith(']'));
    if (!looksLikeJson) return value;
    try {
      return deepDecode(JSON.parse(text));
    } catch {
      return value; // just a string that starts with a brace
    }
  }
  if (Array.isArray(value)) return value.map(deepDecode);
  if (value && typeof value === 'object') {
    return Object.fromEntries(
      Object.entries(value).map(([key, v]) => [key, deepDecode(v)])
    );
  }
  return value;
}

deepDecode(JSON.parse(raw));
Python
import json

def deep_decode(value):
    if isinstance(value, str):
        text = value.strip()
        if text[:1] in "{[" and text[-1:] in "}]":
            try:
                return deep_decode(json.loads(text))
            except ValueError:
                return value
        return value
    if isinstance(value, list):
        return [deep_decode(v) for v in value]
    if isinstance(value, dict):
        return {k: deep_decode(v) for k, v in value.items()}
    return value

On the command line, jq decodes one known field with fromjson:

jq '.payload |= fromjson | .payload.meta |= fromjson' event.json

Four things that go wrong

  1. Decoding strings that should stay strings. "123", "true" and "null" all parse as JSON. If you parse every string that can be parsed, a ZIP code becomes a number and an order ID like "9007199254740993" silently turns into 9007199254740992, because JavaScript numbers are exact only up to 253. Decode only strings that start with { or [, as the snippets above do.
  2. Fragments copied without their outer quotes. Logs often show {\"id\":42} with no surrounding quotes. That is not valid JSON on its own. Wrap it in double quotes, parse it as a string literal to remove one layer of escaping, then parse the result.
  3. Fields that are meant to hold JSON text. A template, a signed payload or a webhook body kept for signature verification must stay byte-for-byte identical. Decoding them for reading is fine; writing the decoded form back is not.
  4. Treating the symptom. A decoder helps you read the data today. If you own the producer, fix it there: pass objects to the logger instead of pre-serialized strings, return objects from handlers and let the framework serialize once, and enable raw message delivery on SNS subscriptions that feed SQS.

Reading it without writing code

For a one-off look at a log line, a script is more than you need. The Nested JSON Parser on this site applies the same rule as the code above: it finds every string that holds an object or array, decodes it at any depth, and leaves numeric-looking strings untouched. It also accepts a fragment pasted without its outer quotes.

Nested JSON Parser and ViewerPaste escaped JSON and decode every layer in one click. Runs in your browser.
One limit to know
The tool parses with the browser's JSON.parse, so real numbers beyond 253 in the input lose precision in the tree view, like in any JavaScript program. Numbers stored as strings are safe.

Related reading: how JSON diff works if you need to compare the decoded result with another document.