Analysing 1 GB of JSONL logs in Python: grep vs pandas vs rugo
Same question, same file, same answer: 899,041 matching rows. pandas needed 2,500 MB of RAM to produce it. rugo needed 5 MB — 500× less — and finished 11× sooner. If you have ever had a log-analysis script die in a 512 MB container, that ratio is the whole story.
We needed rolling 1-hour average response times for the
/api/data endpoint, computed from 1 GB of JSONL
traffic logs, on an M3 MacBook. Three approaches, all
returning identical results:
| Method | Time | Peak memory | Rows matched |
|---|---|---|---|
| grep + Python | 6.98 s | ~42 MB | 899,041 |
| pandas | 13.37 s | ~2,500 MB | 899,041 |
| rugo | 1.23 s | ~5 MB | 899,041 |
The row count is the part that makes the rest of the table worth reading. It is easy to look fast by doing less work, or by quietly dropping rows on a parse error. All three approaches agreed on the count, and agreed on the overall average response time to within 0.01 ms. The speed and memory differences are not a difference in what was computed.
The problem
The logs are JSONL — one JSON object per line. Each line looks like this:
{"timestamp": "2026-06-15T14:32:11", "url": "/api/data", "method": "GET",
"status": 200, "response_ms": 142.3, "user_agent": "Mozilla/...",
"bytes_sent": 12450}
After 30 days a single file is over 1 GB, roughly 6 million
entries. We want rolling 1-hour average response times for
/api/data — which tells us whether latency is
trending up, not merely what the average was overall. About
15% of traffic hits that endpoint, and we need 3 of the 8
fields.
Those two ratios — 3 of 8 columns, 15% of rows — are what the rest of this article is about.
Approach 1: grep + Python
grep is the obvious first move for searching logs:
import subprocess import json result = subprocess.run( ["grep", "/api/data", "logs.jsonl"], capture_output=True, text=True, ) entries = [] for line in result.stdout.split("\n"): if line: entry = json.loads(line) if entry["url"] == "/api/data": entries.append(entry)
The split between the two halves matters. grep does its filtering in about 0.4 seconds — it is genuinely excellent at text matching. But every matched line then has to be parsed into a Python dictionary, and that costs roughly 6.5 seconds.
7 seconds total, and that is before you write a single line of the rolling-average calculation. Memory stays reasonable at ~42 MB because the work is streamed, but you are hand-rolling the statistics yourself.
Approach 2: pandas
pandas is the standard answer for time-series analysis:
import pandas as pd df = pd.read_json("logs.jsonl", lines=True) df = df[df["url"] == "/api/data"] df = df.sort_values("timestamp") df["ts"] = pd.to_datetime(df["timestamp"]) df = df.set_index("ts") df["rolling_avg"] = df["response_ms"].rolling("1h").mean()
It works, and it is by far the most expressive of the three.
But read_json parses every column of every row,
and the entire 1 GB file is materialised in memory before
the first filter runs.
13.4 seconds, and ~2,500 MB peak RSS. The filter on the second line discards 85% of what the first line just spent two and a half gigabytes building. This is the reason log-processing scripts fall over in serverless functions and small containers: not that pandas is slow, but that its memory ceiling is set by the size of your input file rather than the size of your answer.
Approach 3: rugo
rugo is built around a different principle: read less data in the first place.
from rugo import jsonl timestamps = [] values = [] with jsonl.read_jsonl( "logs.jsonl", columns=["url", "response_ms", "timestamp"], predicates=[("url", "==", "/api/data")], ) as reader: for morsel in reader: timestamps.extend(morsel.column("timestamp").to_pylist()) values.extend(morsel.column("response_ms").to_pylist())
Two things happen before any data becomes a Python object:
-
Column projection — only 3 of the 8
fields are read.
method,status,user_agentandbytes_sentare never parsed. -
Predicate pushdown — only rows where
url == "/api/data"are parsed. At ~15% of traffic, 85% of the file never becomes an object at all.
Results arrive as Morsels — batches of rows streamed from the file — so memory is bounded by the batch, not the file. Peak RSS was ~5 MB.
1.23 seconds. Same answer.
The numbers
| Method | Time | Entries | Avg response (ms) | Peak memory |
|---|---|---|---|---|
| grep + Python | 6.98 s | 899,041 | 1,488.77 | ~42 MB |
| pandas | 13.37 s | 899,041 | 1,488.78 | ~2,500 MB |
| rugo | 1.23 s | 899,041 | 1,488.77 | ~5 MB |
The file: 1.05 GB, 6 million log entries, 15% matching
/api/data. All three agree on 899,041 rows and
on the overall average to within 0.01 ms.
Why rugo is faster here
It is not that rugo parses JSON faster than pandas. It is that rugo parses less JSON.
When the file has 8 columns and you need 3, pandas still reads and parses all 8. When 15% of rows match your filter, pandas loads 100% of them first and discards the rest afterwards. rugo pushes both decisions ahead of the decode, so the skipped columns and skipped rows are never paid for in either time or memory.
The corollary is the honest limit of the approach: the advantage scales with selectivity. Ask for every column of every row and there is nothing to skip, and the gap collapses. This design wins on selective reads and merely ties on unselective ones.
When to use what
grep when you need lines matching a pattern and you are going to read them as text. Nothing beats it at that. Just be aware that the moment you add Python JSON parsing behind it, the pipeline is slower than rugo.
pandas when you genuinely need the whole frame — all columns, no filtering, many operations per row — and you have the memory. Its ecosystem and expressiveness are real advantages that neither of the other two match.
rugo when you need specific columns from specific rows of a large file, or when your memory ceiling is fixed and your input size is not.
Reproducing this
pip install rugo is the whole install. There
are no runtime dependencies — no PyArrow, no pandas, no
NumPy — and the installed footprint is 11.6 MB with a 30 ms
cold import (linux x86_64, CPython 3.12; see the
footprint comparison for how
that was measured and how it compares).
The benchmark script is available if you want to run these numbers yourself. rugo reads and writes Parquet, CSV and JSONL through the same API shape.
These logs come from opteryx.app, and the original write-up — with more on why the endpoint was being monitored — is on the Opteryx engineering blog as Monitoring opteryx.app — A Faster Way to Search Web Traffic Logs.