How to tail jsonl logs in real time.
tail -f is a beautiful tool that breaks the moment a line becomes structured JSON. Here's how to actually watch ndjson logs live without squinting at a wall of {.
Why tail -f file.jsonl is painful
The Unix philosophy assumes a log line is something a human can read. A line is short, a line is plain. When the line is {"ts":"2026-05-12T08:14:22Z","level":"error","svc":"checkout","msg":"payment provider returned 502","user_id":"u_8841","trace":"a8c3...","retry":3}, you do not read it. You scan for the substring you care about, get it wrong, and squint harder.
The specific failure modes when you try to stream JSONL logs with plain tail -f:
- Density. JSON-per-line packs ten interesting fields into a string of punctuation. Your eyes do not have a parser.
- Pretty-printing breaks the line invariant. The moment you pipe through
jq ., a single record explodes into ten lines. Anything downstream that assumed one-record-per-line (grep, awk, your eyes) is now broken. - Multi-line errors split awkwardly. Stack traces inside a
msgfield arrive as one giant string with literal\nsequences. Pretty unreadable on the terminal. jq -ckeeps it dense;jq .blows it up. Neither is what you actually want, which is a colourised one-liner with the four fields you care about and nothing else.- Search across the tail.
greplooks at bytes, not structure.grep errormatches the user agent string containing the word "error" too, and you can't ask "where$.level == "error" AND $.region == "us-east-1"" without writing a tiny jq program every time.
You can muddle through with tail -f app.jsonl | jq -c 'select(.level=="error")'. We have all done this. We have all then regretted it the moment we wanted to scroll back.
A short primer on NDJSON / JSONL
NDJSON and JSONL are the same format with two names. One JSON value per line, separated by \n. No commas between records, no enclosing array, no embedded newlines inside values. UTF-8. That's it.
There is no formal RFC for JSONL; the closest thing to a spec is ndjson.org, which codifies the obvious constraints (no literal newline characters inside JSON values, UTF-8 only, one record per line). In practice every tool that calls itself "ndjson-aware" or "jsonl-aware" agrees on the same shape.
Where does it come from? Most of your structured log pipeline:
- Vector, Fluent Bit, Filebeat, Fluentd — output JSON-per-line by default when sinking to disk.
- structlog (Python), bunyan / pino (Node), zerolog / zap (Go) — emit NDJSON to stdout.
- AWS CloudWatch JSON exports, GCP Cloud Logging JSON, Vercel function logs — all ship as JSONL when you pull them down.
Which is why every on-call engineer with a laptop and a download URL ends up running tail -f on a JSONL file at 3am.
Five tools for tailing structured JSON logs
Here's the honest landscape, in increasing order of "how much state does this thing keep across a re-tail".
1. tail -f | jq
The default. It works. It's everywhere. It has no memory.
tail -f app.jsonl | jq -c 'select(.level=="error") | {time,msg}'
Good for "show me errors as they happen, I don't care about scrollback". Bad for everything else. See the jq manual if you want to push this further.
2. lnav
The log navigator. Auto-detects JSON, presents it as a virtual table, lets you write SQL against the records. Strong for forensic dives after the incident. Less ergonomic in the "I just want to watch the live tail in colour" case, because the TUI takes a moment to learn.
3. humanlog / jl
Pretty-printers. They consume NDJSON and emit colourised one-liners with the timestamp, level, and message highlighted. Drop-in pipe replacements:
tail -f app.jsonl | humanlog
Great for terminal viewing. They don't search, don't persist filters, don't help you scroll back. If your need is just "make this readable", they're perfect.
4. vector / fluent-bit in dev mode
Overkill, but it lets you filter at the pipeline. Useful when you actually want to forward a filtered subset somewhere else, or you're already running Vector to ship logs and you just want to add a console sink with a VRL filter. Not what you reach for to triage one file.
5. JSONBolt
Two surfaces, one engine. In the GUI, open the JSONL file and jsonbolt re-reads it as the file grows on disk — new rows appear at the bottom, your scroll holds, and the search box (with regex and key/value toggles) keeps highlighting matches as they arrive. From the CLI, NDJSON is first-class: jb search reads line-by-line from stdin (use - as the file argument) or from a glob. Pair it with tail -f for a live filter, or run jb search against a static snapshot for forensic dives.
# Live tail with a where-predicate, JSONL in, JSONL out tail -f app.jsonl | jb search --where '.level == "error"' --emit object - # Run a single predicate against a glob of rotated logs jb search --where '.level == "error" && .region == "us-east-1"' 'logs/*.ndjson' # LLM-shaped output: JSONL envelope, 1 MB cap, 256B preview truncation tail -f app.jsonl | jb search --where '.level == "error"' --ai -
Where-predicates support comparison, contains / startsWith / endsWith, regex via matches /…/i, length tests, and boolean composition. Stdin is parsed line-by-line, so a 40 GB rotated log is the same workflow as a 40 KB one. If you want to know how it compares to other viewers, see the comparison post.
When you need more than tail -f | jq
Concrete scenarios where the pipe-and-pray approach falls apart:
- 10GB file growing at 100 MB/min.
tail -f | jqhandles the new lines fine but gives you zero random access to anything that scrolled by. You can re-grep the file, but now you're running a full scan every time. - You need to scroll back through millions of structured events to find the spike.
taildoesn't go backwards.less +Fsort of does, but you lose the structure. - You're on Windows. Native
tailis either Git Bash, WSL, or a PowerShell cmdlet that handles JSON badly. Each one is its own paper cut. - You want a saved predicate. "Show me
level=="error" AND user_id=="u_8841"" should be one click to re-run, not a copy-pasted jq filter you maintain in a scratch file. - You want to export the matching subset. The output of
jqis JSON. The postmortem template wants CSV. Doable, painful.
tail -f | jq is fine. If you'll keep coming back to the same filter for the next hour while the incident burns, you want a tool that remembers.
A worked example: on-call SRE flow
It's 02:41. Pager says checkout error rate is up. The aggregator is laggy, but the raw NDJSON file on the bastion is current. You SCP the last hour, or you tail it directly. Either way, you end up with a JSONL file that is still growing.
On the laptop, open app.jsonl in JSONBolt. The app re-reads it as the file grows — new rows arrive at the bottom of the tree, the scroll holds, search highlights keep updating. Press Ctrl + F, type error, toggle .* for regex if you need it, step through with F3. Use Ctrl + P to copy the jq-style path of any match — that string pastes directly into jb get or jb search --path later.
For the SSH-only case, do the same triage straight from the bastion. jb search reads NDJSON from stdin and applies a real predicate:
# live: filter the tail of a growing JSONL log tail -f app.jsonl | jb search --where '.level == "error" && .region == "us-east-1"' --emit object - # after-the-fact: scan rotated logs with the same predicate jb search --where '.level == "error" && .region == "us-east-1"' --emit object 'logs/app-*.ndjson' # forwarding bounded matches into a postmortem note or a model tail -f app.jsonl | jb search --where '.level == "error"' --ai - > incident.jsonl
For follow-up CSV — the postmortem ticket usually wants a flat table — open the result file in the desktop app and use File → Export Selection Value As, or feed it to your spreadsheet-side conversion of choice. The CLI itself emits plain, JSON, or JSONL; CSV/XML conversion lives in the GUI.
Operational tips
- Rotate by size, not time. Time-based rotation produces tiny files at low-traffic hours and giant ones during peak. Size-based rotation keeps every tail predictable.
- Don't pretty-print NDJSON at the producer. If your structlog config has
indent=2, fix that. The whole point of NDJSON is one record per line. - Pick one timestamp key and one format. ISO-8601 strings with a Z suffix, ideally. Tools sort and filter better when the timestamp is unambiguous.
- Local tailing is for dev and on-call. Long-term retention belongs in Loki / Elastic / Datadog / CloudWatch. The point of a good local tailer is not to replace the aggregator; it's to triage faster when the aggregator is lagging or when you've already pulled the file down.
- Watch out for log lines that aren't JSON. Half the producers in production will occasionally emit a stray
panic:line that isn't valid JSON. A good NDJSON viewer skips them and surfaces the count somewhere visible; a fragile pipeline blows up on them. For a broader look at handling files that fight back, see opening large JSON files.
The takeaway
tail -f was designed for syslog. JSONL is what we ended up shipping instead. The gap between those two facts is why every on-call rotation has at least one engineer with a tail | jq alias and a quiet sigh.
Pick the tool that matches the scenario. For one-off "show me the next 30 seconds of errors", pipe it through jq and move on. For "I'm going to live in this file for the next hour", use something that remembers your filter and lets you scroll back. JSONBolt is free for files up to 50MB and runs locally — no log forwarding, no SaaS account, no waiting for the aggregator to catch up. Pro is $80/yr or $80 lifetime if you want unlimited size and commercial use.