jq vs Python: I Timed Both on a 100K-Line Log

– min read

Last week I had to answer “which IPs are hammering my API” at 2am from a 100 MB nginx JSON log, and I wrote it in both jq and Python while waiting. Which one should you actually reach for? Instead of guessing, I seeded a synthetic log and timed every one-liner on the same box.

Rows of servers in the CERN data center
CERN data center, photo via Wikimedia Commons (CC BY-SA 3.0)



jq and jq on GitHub – the command-line JSON processor. Python – because you already have it installed.


What you need

  1. jq 1.6+ (apt install jq)
  2. Python 3.10+ (already there on Ubuntu)
  3. A JSON-lines log file, one object per line


The method (and the dataset)

The dataset is synthetic, seeded, and safe to publish: 100,004 lines of JSON-lines access log, 40 of them deliberately corrupted, IPs drawn only from the RFC 5737 TEST-NET ranges (203.0.113.x, 198.51.100.x, 192.0.2.x), status mix weighted like real traffic (85% 200s, 5% 301, 5% 404, 3% 500, 2% 403). Runner: 2 vCPU Ubuntu VM, jq 1.8.1, Python 3.14.4. Each command ran 5 times, I took the median wall time.

Bar chart comparing median runtimes of jq and Python one-liners on a 100k-line JSON log


python3 - <<'EOF'   # seed the synthetic log (excerpt)
import json, random
random.seed(20261104)
ips=["203.0.113.%d" % random.randint(2,254) for _ in range(400)]
rec={"remote_addr": random.choice(ips), "path": "/api/v1/orders",
     "status": 200, "request_time": round(random.expovariate(8),4)}
EOF


Round 1: count requests per status code

The bread-and-butter question: what broke today?

jq

jq -R -r 'fromjson? | .status' /tmp/jqbench/access.jsonl \
  | jq -s -r 'group_by(.) | map({s:.[0], n:length}) | sort_by(-.n)[]
               | "\(.s)\t\(.n)"'
200	84836
301	5102
404	5008
500	2943
403	2031

1.49 s (two jq processes piped).

Python

python3 -c "
import json
from collections import Counter
c = Counter()
for l in open('/tmp/jqbench/access.jsonl'):
    try: c[json.loads(l)['status']] += 1
    except Exception: pass
for k, v in c.most_common(): print(k, v)
" 
200 84836
301 5102
404 5008
500 2943
403 2031

0.90 s. First point to Python, and for once the shorter script too.


Round 2: top 10 talkative IPs

jq -Rrn '[inputs | fromjson? | select(type=="object") | .remote_addr]
  | group_by(.) | map({ip:.[0], n:length}) | sort_by(-.n)[:10][]
  | "\(.n)\t\(.ip)"' /tmp/jqbench/access.jsonl
801	198.51.100.165
791	192.0.2.152
781	192.0.2.43
765	192.0.2.150
765	192.0.2.34
765	203.0.113.149
761	192.0.2.41
740	203.0.113.4
725	203.0.113.83
724	203.0.113.20
python3 -c "
import json
from collections import Counter
c = Counter()
for l in open('/tmp/jqbench/access.jsonl'):
    try: c[json.loads(l)['remote_addr']] += 1
    except Exception: pass
for ip, n in c.most_common(10): print(n, ip)
" 
801 198.51.100.165
791 192.0.2.152
781 192.0.2.43
765 203.0.113.149
765 192.0.2.34
765 192.0.2.150
761 192.0.2.41
740 203.0.113.4
725 203.0.113.83
724 203.0.113.20

jq: 1.45 s, Python: 1.06 s. Same shape, same winner. Note the -Rrn '[inputs...] trick: read raw lines, parse with fromjson?, slurp once. It beats jq -s on dirty files because a slurp of the whole file dies if any single line is malformed.


Round 3: slowest endpoints

jq -Rrn '[inputs | fromjson? | select(type=="object")]
  | group_by(.path)
  | map({path:.[0].path, avg:([.[].request_time]|add/length), n:length})
  | sort_by(-.avg)[:5][] | "\(.avg*10000|round/10000)\t\(.n)\t\(.path)"' /tmp/jqbench/access.jsonl
0.121	9946	/api/v1/orders
0.1208	10079	/admin
0.1204	10137	/api/v1/search
0.1201	9944	/api/v1/products
0.1198	9926	/static/app.js
python3 -c "
import json
from collections import defaultdict
d = defaultdict(list)
for l in open('/tmp/jqbench/access.jsonl'):
    try:
        r = json.loads(l)
        d[r['path']].append(r['request_time'])
    except Exception: pass
for path in sorted(d, key=lambda p: -sum(d[p])/len(d[p]))[:5]:
    print(round(sum(d[path])/len(d[path]), 4), len(d[path]), path)
" 
0.121 9946 /api/v1/orders
0.1208 10079 /admin
0.1204 10137 /api/v1/search
0.1201 9944 /api/v1/products
0.1198 9926 /static/app.js

jq: 2.29 s vs Python: 1.09 s. Aggregating numbers inside jq is where it hurts: [.[].request_time]|add/length] builds throwaway arrays per group, while Python’s defaultdict does it natively.


Round 4: stream every 500 out

Different job: no aggregation, just a pass-through filter. This is where jq was born to live.

jq -R -c 'fromjson? | select(.status==500)' /tmp/jqbench/access.jsonl
{"@timestamp":"2026-11-02T16:00:47","remote_addr":"198.51.100.207","method":"GET","path":"/.env","status":500,"body_bytes":134105,"request_time":0.054}
{"@timestamp":"2026-11-02T16:00:50","remote_addr":"203.0.113.138","method":"GET","path":"/api/v1/orders","status":500,"body_bytes":100202,"request_time":0.0205}

jq: 0.77 s, 2943 matching lines. Python: 1.02 s, same 2943 lines. jq finally wins, and its output keeps the original compact JSON, ready to pipe into the next tool.


The gotcha that cost me an hour

My first pass used the obvious command, no -R, no fromjson?:

jq -c 'select(.status==500)' /tmp/jqbench/access.jsonl | wc -l
# jq: parse error: Expected separator between values at line 322, column 1

Plain jq aborts at the first malformed line. Silent killer: in my broken run wc -l reported 6 lines and jq exited 0 through the pipe, so the count was just wrong with no error. Real logs from nginx have corrupt lines. The defensive forms are jq -R 'fromjson? | ...' or jq -Rs never: pick -R 'fromjson?' and the bad lines are skipped, exactly like Python’s try/except. Both numbers in this post came from error-skipping commands, so the comparison is fair.


Head to head

Taskjq 1.8.1Python 3.14
Status counts1.49 s0.90 s
Top 10 IPs1.45 s1.06 s
Slowest endpoints2.29 s1.09 s
Stream-filter 500s0.77 s1.02 s
Handling corrupt linesneeds -R 'fromjson?', aborts by defaulttry/except, one line
One-liner ergonomicswins: no boilerplate, -r/-c output flagsstring quoting in -c gets ugly fast
Deep slicing / reformattingwins: group_by, map, path syntaxwrite a dict comprehension
Heavy aggregation (avg, sum by group)loses: forces slurp + temp arrayswins: stdlib collections


Verdict

If your log wrangling is a single filter or a shape transform you will fire from the shell all day, pick jq: fastest stream pass (0.77 s vs 1.02 s here), zero boilerplate, and composable with grep and sort. If the question ends in “average”, “group by”, or your log is dirty and the answer must be right, pick Python: it won three of four benchmarks and its try/except line swallows corrupt entries with less thinking. My honest 2am answer: jq to slice, Python to summarize. Which of these one-liners you keep in muscle memory depends on how you found this post, so if you want the plain-text (not JSON) version of the same tricks, my Nginx Access Log Analysis with Bash and Python covers awk and grep. For latency hunting at the database layer see Enable Slow Log on MySQL/MariaDB, and when the log itself is eating your disk, this Docker log rotation script is the fix.

Mohamad ZulfahmyCybersecurity · OffSec · Entrepreneur
About me ↗
ON THIS PAGE
On this page