JSON Logging: Write Structured Logs You Can Query
Last verified on Nov 8, 2026.
JSON logging means each log event is one JSON object on one line, with named fields for the time, level, message, and context. That is what makes logs queryable: you filter level == "error" or order_id == "o-123" instead of writing a regular expression for every message. Getting it right takes more than switching on a JSON formatter, because a log line that happens to be valid JSON is not automatically useful. This guide covers the schema, working examples in three languages, and the failure modes.
What a Good JSON Log Line Looks Like
The OpenTelemetry docs make a distinction worth keeping: a log encoded as JSON is not automatically structured. Structured logs imply a consistent schema or well-defined typed fields that downstream processing can depend on, and JSON with ad hoc keys is only semistructured. See structured logging for the broader idea.
OWASP says each event should record when, where, who, and what, and that a team should keep a consistent, documented approach to field names, lengths, data types, and the date format. Applied to a service log, a useful core schema is:
Keep the message constant and put variable data in fields. "message":"order created","order_id":"o-123" can be counted and grouped. "message":"order o-123 created for a@example.com" cannot, and it puts personal data into a free-text field that no filter will catch. See secrets in logs.
RFC 3339 defines the internet profile of ISO 8601 timestamps, with Z for UTC. Log in UTC and let the viewer convert, so lines from servers in different zones sort together.
Go: slog
Go's standard library has had log/slog since 1.21. Its JSONHandler writes line-delimited JSON, and With adds attributes to every record from that logger.
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)).With("service", "checkout-api", "env", "prod")
logger.Info("order created", "order_id", "o-123", "amount_cents", 4200)
logger.Error("payment failed", "order_id", "o-123", "error", errors.New("card declined"),
"trace_id", "5b8efff798038103d269b633813fc60c")Run with Go 1.26, this printed:
{"time":"2026-11-08T06:53:14.130537055Z","level":"INFO","msg":"order created","service":"checkout-api","env":"prod","order_id":"o-123","amount_cents":4200}
{"time":"2026-11-08T06:53:14.130679127Z","level":"ERROR","msg":"payment failed","service":"checkout-api","env":"prod","order_id":"o-123","error":"card declined","trace_id":"5b8efff798038103d269b633813fc60c"}The message key is msg. If your schema says message, rename it with ReplaceAttr in HandlerOptions rather than letting two services disagree.
Python: Standard Logging
The Python logging cookbook shows JSON serialization with the standard library. A small formatter produces one object per line and puts the exception into a field:
import json, logging, sys, datetime
class JsonFormatter(logging.Formatter):
def format(self, record):
entry = {
"time": datetime.datetime.fromtimestamp(record.created, datetime.timezone.utc).isoformat(timespec="milliseconds"),
"level": record.levelname,
"message": record.getMessage(),
"logger": record.name,
}
entry.update(getattr(record, "fields", {}))
if record.exc_info:
entry["exception"] = self.formatException(record.exc_info)
return json.dumps(entry, default=str)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
log = logging.getLogger("checkout")
log.info("order created", extra={"fields": {"order_id": "o-123", "amount_cents": 4200}})An exception logged with log.exception(...) came out as one line, with the traceback as a single string whose newlines are escaped:
{
"time": "2026-11-08T06:53:14.205+00:00",
"level": "ERROR",
"message": "payment failed",
"logger": "checkout",
"order_id": "o-123",
"exception": "Traceback (most recent call last):\n File \"/tmp/jl/app.py\", line 22, in <module>\n 1/0\n ~^~\nZeroDivisionError: division by zero"
}That is the property you want. The encoder escapes the newlines, so a multi-line stack trace does not become many broken records.
Node.js: pino
pino writes newline-delimited JSON by default. Its default base fields are pid and hostname, and its default time is epoch milliseconds, so set the base and timestamp explicitly:
const pino = require('pino')
const logger = pino({ base: { service: 'checkout-api', env: 'prod' }, timestamp: pino.stdTimeFunctions.isoTime })
logger.info({ order_id: 'o-123', amount_cents: 4200 }, 'order created')
logger.error({ err: new Error('card declined'), order_id: 'o-123' }, 'payment failed'){
"level": 30,
"time": "2026-11-08T06:53:15.542Z",
"service": "checkout-api",
"env": "prod",
"order_id": "o-123",
"amount_cents": 4200,
"msg": "order created"
}Note "level":30. pino logs the level as a number by default, and the err object is serialized with type, message, and stack. Map the numeric level to a name with a formatters.level option if your other services log strings, because a field that is a number in one service and a string in another is the most common way a shared schema breaks. pino's redact option replaces named paths with [Redacted] before the line is written.
For deeper coverage of each library, see the Python logging guide, the Go logging guide, and the pino guide.
Query JSON Logs With jq
Because every line is one object, jq works on a raw file or a stream:
jq -c 'select(.level == "ERROR")' app.log # errors only
jq -r 'select(.order_id == "o-123") | [.time, .message] | @tsv' app.log
jq -s 'group_by(.message) | map({message: .[0].message, count: length})' app.logjq stops with a parse error on a line that is not JSON. A startup banner, a library that prints with print, or a stack trace written to stderr by the runtime will all break it. Send plain text elsewhere, or parse with jq -R 'fromjson? // empty' to skip bad lines.
Common Failure Modes
- Type conflicts.
useris a string in one service and an object in another, orstatusis200and"200". Filters on that field then miss the lines that use the other type. See the field drift guide for catching this. - Unbounded keys. Putting an ID in the key, such as
{"o-123": "paid"}, creates a new field for every value. Use{"order_id": "o-123", "status": "paid"}. See the cardinality jumps guide. - Pretty-printed output. Multi-line JSON makes each line a separate record for any file tailer. Write compact objects.
- Unescaped input. Build the line with a JSON encoder, never with string concatenation. OWASP calls for sanitizing carriage return and line feed characters to prevent log injection, and a real encoder does that by escaping them, so a user-supplied
\ncannot forge a second log line. - Secrets and personal data. Allow-list the fields you log from request bodies. Do not dump whole objects.
- No trace context. Add
trace_idandspan_idfrom the active span so lines join to traces. The OpenTelemetry logs guide shows how a bridge does this automatically.
Other tools can emit JSON without app changes: see nginx logs for log_format ... escape=json, and Postgres logs for jsonlog. The full set of habits that go with a schema is in logging best practices.
Sending JSON Logs to Sazabi
Sazabi, an AI-native observability and security platform, stores application logs, along with metrics and traces, as searchable records, and its docs say structured log fields preserve context during an investigation. Send JSON lines through the OpenTelemetry Collector or Fluent Bit, or send from an SDK as covered in the OpenTelemetry integration. A stable field set, as described above, gives a question like sazabi logs ask "errors by service for order_id o-123" named fields to work with instead of message text. See the log search docs for how answers and saved queries work.
Frequently Asked Questions
What is JSON logging?
JSON logging writes each log event as a single JSON object on its own line, with named fields such as time, level, and message. Tools can filter on a field directly instead of parsing free text with regular expressions.
Should JSON logs be pretty-printed?
No. Write one compact object per line so that file tailers, shippers, and tools like jq treat each line as one event. Pretty-print only at read time, for example with jq.
How do I log a stack trace in JSON?
Put the stack trace in a single string field such as error.stack. The JSON encoder escapes the newlines, so the event stays on one line and the trace is not split into many records.
Does JSON logging prevent log injection?
It helps when you use a real JSON encoder. The encoder escapes quotes and newline characters, so user input containing a line break stays inside one string value instead of forging a second log line. Building JSON by string concatenation gives no such protection.
Sources
- slog.NewJSONHandler writes line-delimited JSON, and Logger.With adds attributes to every record (accessed Nov 8, 2026)
- The logging cookbook shows how to emit JSON-serialized structured messages from the standard logging package (accessed Nov 8, 2026)
- pino writes newline-delimited JSON, defaults base fields to pid and hostname, and provides stdTimeFunctions.isoTime for ISO 8601 UTC time (accessed Nov 8, 2026)
- pino redact paths replace sensitive keys with [Redacted] (accessed Nov 8, 2026)
- Each event should record when, where, who, and what; sanitize CR and LF to prevent log injection; and keep a consistent documented set of field names, lengths, data types, and date formats (accessed Nov 8, 2026)
- A log encoded as JSON is not automatically structured; structured logs imply a consistent schema or well-defined typed fields (accessed Nov 8, 2026)
- RFC 3339 defines the internet timestamp profile of ISO 8601, with Z for Coordinated Universal Time (accessed Nov 8, 2026)
- Sazabi stores application logs as searchable records and structured log fields preserve context (accessed Nov 8, 2026)