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.
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.
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.
The JSON serializer has three exceptions to “field name maps to string value”. Each one breaks a naive parser in a different way.
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.
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.
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.
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.
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
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.
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.
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.
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.
-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.
-o json against a journal you copied off a machine that is gone.PRIORITY lies about which lines are errors.💌 Get notified on new features and updates