— Blog / Engineering

How to tail jsonl logs in real time.

Engineering Anika Rao May 12, 2026 9 min read

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:

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:

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:

Rule of thumb. If you'll look at this log once and never again, 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

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.

← All posts jsonbolt · v1.4.2