Skip to content
LocalOnly
JSON Tools

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.

Updated October 18, 20263 min readTool runs in your browser

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.log

Frequently asked questions

Command Palette

Search for a tool or command