How to get JSON output from journalctl

Introduction

journalctl -o json turns your longest log lines into null.

It does this quietly. No warning, no exit code, no line in the output saying anything went missing. A stack trace goes in and "MESSAGE": null comes out. This guide covers the flag you want, the three ways a JSON value stops being a string, and how to check whether it has already happened to you.

The short answer

journalctl -o json

One JSON object per line. Feed it straight to jq, to a log shipper, or to anything that reads newline-delimited JSON.

Want it readable instead?

journalctl -n 20 -o json-pretty

Four output modes produce JSON:

Mode What you get
json one object per line, newline separated
json-pretty the same objects, indented across several lines
json-seq RFC 7464 text sequences: 0x1E before each object, 0x0A after
json-sse wrapped for Server-Sent Events

Use json for pipelines. Use json-pretty when you are reading with your eyes.

Every value is a string

This surprises people the first time they parse it.

{
  "PRIORITY" : "3",
  "__REALTIME_TIMESTAMP" : "1788254047551204",
  "_PID" : "378643"
}

PRIORITY is "3", not 3. _PID is "378643". __REALTIME_TIMESTAMP is microseconds since the Unix epoch, also as a string.

So .PRIORITY <= 3 is a string comparison in jq and a string comparison in SQL. Convert first:

journalctl -o json | jq 'select((.PRIORITY|tonumber) <= 3)'

Turn the timestamp into something human-readable the same way:

journalctl -n 5 -o json \
  | jq -r '[(.__REALTIME_TIMESTAMP|tonumber/1000000|floor|todate), ._HOSTNAME, .MESSAGE] | @tsv'
2026-09-01T09:14:02Z	web-01	GET /health 200
2026-09-01T09:14:03Z	web-01	{"level":"error","status":500,"route":"/charge"}

That command works until it doesn’t. The next section is why.

Three times a value is not a string

The JSON serializer has three exceptions to “field name maps to string value”. Each one breaks a naive parser in a different way.

1. Long fields become null

journalctl replaces a field with null when the field name, an =, and the value together reach 4096 bytes. For MESSAGE that means a message of about 4088 bytes or more disappears.

{"__REALTIME_TIMESTAMP":"1788254047551204","_SYSTEMD_UNIT":"billing.service","PRIORITY":"3","MESSAGE":null}

The entry is still there. The priority is still there. The text you wanted is gone.

Java stack traces, Python tracebacks, verbose SQL errors and pretty-printed API payloads all clear 4 KB without trying. These are the entries you keep logs for, and they are the ones the default drops.

Turn the limit off with -a:

journalctl -o json -a

-a (long form --all) removes the size threshold entirely. There is no middle setting.

2. Repeated fields become arrays

A journal entry may carry the same field twice. JSON objects may not. So journalctl emits an array:

{"_UDEV_DEVLINK" : [ "/dev/alias1", "/dev/alias2" ]}

Most entries never do this. The ones that do will hand your parser an array where it expected a string.

3. Binary fields become arrays of bytes

A value that is not printable UTF-8 comes out as a list of byte values, each 0–255:

{"BINARY" : [ 116, 104, 105, 115, 32, 105, 115, 32, 97, 32, 98, 105, 110, 97, 114, 121, 32, 118, 97, 108, 117, 101, 32, 7 ]}

Kernel messages and anything logging raw device output can land here.

Note the shape collision: case 2 and case 3 both produce a JSON array. A list of strings is a repeated field. A list of numbers is binary data.

What that does to a real command

Run the @tsv command above against a journal containing all three cases:

jq: error (at journal.ndjson:4): array ([104,105,0,...) is not valid in a csv row
2026-09-01T09:14:02Z	web-01	GET /health 200
2026-09-01T09:14:03Z	web-01	{"level":"error","status":500,"route":"/charge"}
2026-09-01T09:14:07Z	web-01	
2026-09-01T09:14:19Z	db-01	I/O error on sda

Five entries in. Four lines out, one of them blank, one error on stderr, and jq still exits 0. Pipe that into a file and nothing tells you a line is missing.

Handle all three cases explicitly:

journalctl -o json -a | jq -r '
  def text:
    if   type == "string" then .
    elif . == null        then "<dropped by journalctl>"
    elif type == "array"  then
           (if all(.[]; type == "number")
            then (map(select(. > 31)) | implode)
            else join(" | ") end)
    else tostring end;
  [(.__REALTIME_TIMESTAMP|tonumber/1000000|floor|todate),
   ._HOSTNAME,
   (.MESSAGE|text)] | @tsv'
2026-09-01T09:14:02Z	web-01	GET /health 200
2026-09-01T09:14:03Z	web-01	{"level":"error","status":500,"route":"/charge"}
2026-09-01T09:14:07Z	web-01	<dropped by journalctl>
2026-09-01T09:14:11Z	db-01	hiÿ
2026-09-01T09:14:19Z	db-01	I/O error on sda

Five entries in, five lines out. With -a on the front, the third line would carry its stack trace instead of the placeholder.

Cut the fields you do not need

A journal entry in JSON carries thirty-odd fields. Most pipelines want four.

journalctl -o json --output-fields=MESSAGE,_HOSTNAME,_SYSTEMD_UNIT,PRIORITY

Four fields come back anyway, whatever you ask for: __CURSOR, __REALTIME_TIMESTAMP, __MONOTONIC_TIMESTAMP and _BOOT_ID. journalctl treats them as addressing information rather than content.

Want the message and nothing else? Skip JSON:

journalctl -o cat

One line is not always one line

journald splits a stream into records at each newline. If it reads LineMax bytes without finding one, it inserts a record boundary anyway. LineMax defaults to 48K.

So a single 200 KB line printed by your application was already five journal entries before JSON entered the picture. -a does not reassemble them, because there is nothing left to reassemble.

This matters mostly for services that dump large blobs to stdout. For those, log to a file and ship the file.

Shipping the JSON somewhere

The whole point of machine-readable output is that a machine reads it.

journalctl -o json -a --cursor-file=/var/lib/journal-ship.cursor \
  | curl -fsS -X POST --data-binary @- \
      "https://logs.example.com/api/v1/ingest_logs/YOUR-SOURCE-TOKEN"

Three details in that command are load-bearing.

-a, so long entries arrive with their text.

--data-binary, not -d. curl strips newlines out of -d @-, and the ingest endpoint stores one row per line. With -d, a batch of seven entries lands as one row with all seven jammed together.

--cursor-file, so the next run starts where this one stopped. Read the caveat on it in sending journalctl logs to a centralized logging system before you rely on it: the cursor moves when journalctl reads the entries, not when curl delivers them.

The CL Agent does the same thing on a timer and holds the cursor until the server answers 2xx.

Check whether you have already lost messages

Logs already landing in Central Logging? Ask the database.

SELECT COUNT(*) AS dropped
FROM logs
WHERE json_valid(msg)
  AND json_type(msg, '$.MESSAGE') = 'null';

Use json_type, not IS NULL. On a test set with one dropped message and one entry that has no MESSAGE field at all, json_type(...) = 'null' returns 1 and json_extract(msg, '$.MESSAGE') IS NULL returns 2. Both run without error. json_extract cannot tell “the field is JSON null” apart from “the field is not there”.

Keep the json_valid(msg) guard. json_extract raises an error on the first row that is not JSON, and one plain-text line from a syslog relay is enough.

Find out which service is losing the most:

SELECT json_extract(msg, '$._HOSTNAME')     AS host,
       json_extract(msg, '$._SYSTEMD_UNIT') AS unit,
       COUNT(*)                             AS dropped
FROM logs
WHERE json_valid(msg)
  AND json_type(msg, '$.MESSAGE') = 'null'
GROUP BY host, unit
ORDER BY dropped DESC;

A non-zero count here means the shipper on that host is running without -a.

Reading your application’s own JSON

Your service logs JSON. journald wraps it in JSON. So the payload arrives as a string inside MESSAGE, and reaching a field takes two hops:

SELECT json_extract(json_extract(msg, '$.MESSAGE'), '$.status') AS status,
       COUNT(*)                                                 AS n
FROM logs
WHERE json_valid(msg)
  AND json_valid(json_extract(msg, '$.MESSAGE'))
GROUP BY status;

One hop returns nothing and no error, which is the failure that costs an afternoon.

When you do not need JSON

-o short-iso gives you sortable timestamps and one line per entry. If you are grepping rather than parsing, that is smaller, faster and easier to read.

journalctl -u nginx -o short-iso --since "1 hour ago"

Reach for JSON when a program is the reader.

Where to go next

💌 Get notified on new features and updates

Only sent when a new version is released. Nothing else.