How to Unescape JSON Strings in Logs with jq
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.
Escaped JSON strings in logs are hard to read and impossible to search. You open the logs to find one order, and it is there, but as one long string full of backslashes that no search can look inside.
The JSON Unescape tool turns one such string back into readable JSON, and its page explains how escaping builds up in layers. This guide is about logs specifically: reading a whole file of these lines with jq, and changing the code that writes them.
Two log lines, one searchable
Both lines below were written by the same logger, pino, for the same order object. The only difference is how the object was passed in:
{"level":30,"msg":"{\"id\":\"ord_8f2k1\",\"total\":129.9,\"items\":[{\"sku\":\"A-1\"}]}"}
{"level":30,"order":{"id":"ord_8f2k1","total":129.9,"items":[{"sku":"A-1"}]},"msg":"order paid"}In the first line the order is a string, so a log platform sees one long msg value. A query such as order.total > 100 cannot match it. In the second line the order is an object, and every field can be searched, filtered and charted.
Unescape JSON in logs with jq
jq's fromjson parses a string that contains JSON. Apply it to the field that holds the escaped text:
$ head -1 app.log | jq '.msg | fromjson'
{
"id": "ord_8f2k1",
"total": 129.9,
"items": [
{
"sku": "A-1"
}
]
}Real files mix both kinds of line, and fromjson stops with an error on a plain message such as order paid. Add ? to skip the error and // to fall back to the original value. The brackets matter: without them, jq applies the fallback to the string instead of the object.
$ jq -c '(.msg | fromjson?) // .msg' app.log
{"id":"ord_8f2k1","total":129.9,"items":[{"sku":"A-1"}]}
"order paid"Docker's json-file logs
Docker's default json-file logging driver stores every line a container prints as a string in a log field, so a JSON log line from your app arrives escaped even when the app logged it correctly:
$ cat container.log
{"log":"{\"level\":30,\"msg\":\"order paid\"}\n","stream":"stdout","time":"2026-10-18T09:15:02.481Z"}
$ jq -c '.log | fromjson' container.log
{"level":30,"msg":"order paid"}This layer is expected, and log collectors for Docker unwrap it. If a value is escaped twice, chain the calls: jq '.msg | fromjson | .payload | fromjson'.
Fixing the logging call
The escaped line comes from serialising the object yourself and then handing the result to a logger that serialises again. Pass the object and let the logger do it once:
// Before: the order becomes an escaped string inside msg
logger.info(JSON.stringify(order));
// After: the order is a real object in the log line
logger.info({ order }, "order paid");The same rule holds for other structured loggers: give them the object as a field, keep the message a short human-readable sentence, and never call JSON.stringify on something you are about to log. Watch for the less obvious cases too, such as logging an HTTP response body that arrived as a string, or a value read from a text column. Parse those first, then log the parsed object.
Keeping it from coming back
A one-line check in a test or CI job catches the regression. jq -e exits with status 1 when the result is false or null, so it can fail a build:
# Fails if the order was logged as a string instead of an object
node scripts/log-sample-order.js | jq -e '.order | type == "object"'To audit an existing log file, list the messages that look like serialised JSON:
jq -c 'select(.msg | test("^\\s*[{\\[]")) | .msg' app.logRelated tools
Escape any string so it can be safely embedded inside JSON.
Convert a JSON value into an escaped JSON string literal.
Encode text or files to Base64, with an optional URL-safe variant.
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.
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.
Base64 turns bytes into plain text so they can travel where only text is allowed. Here are the five places developers meet it most, with real examples, and the cases where it is the wrong tool.
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.