Catch N+1 queries by reading your code — no app run, no database, no test harness.
The N+1 query problem is the quiet performance killer: you load a list of rows, then fire another query for each one inside a loop. Ten orders become eleven queries; a thousand become a thousand and one. It never shows up in a unit test, it looks fine in review, and then a list view times out in production.
The usual tools catch it at runtime — nplusone
hooks the ORM while your code executes, django-perf-rec counts queries inside
your tests. Both need the code to run. loophound reads the source with
Python's AST and flags the pattern on the pull request, before anything runs:
$ loophound app/
N+1 NP001 app/views.py:23:8 Database query issued inside a loop (likely N+1).
order = Order.objects.get(id=oid)
↳ Batch it: fetch outside the loop, or use select_related() / prefetch_related().
N+1? NP002 app/reports.py:41:12 Related objects fetched per iteration on 'author' (likely N+1).
counts[author.id] = author.books.all().count()
↳ Batch it: prefetch_related() so the related rows load in one query.
2 file(s) · 1 N+1 · 1 suspected
Exit code 1 on a confirmed N+1, so it drops straight into CI.
- NP001 (blocker) — a database query inside a loop, reached through a clear
ORM anchor (
.objects,.query,session):for i in ids: User.objects.get(id=i),[session.query(User).get(i) for i in ids]. - NP002 (warning) — a related collection accessed on the loop variable:
for u in users: u.orders.all()— the classic reverse-relation N+1.
It covers Django, SQLAlchemy, and Flask-SQLAlchemy patterns. It never imports or runs your code, needs no database, and requires no configuration.
A runtime detector is accurate but needs a running app, a populated database,
and a code path that actually executes the loop. That's great in a test suite
and useless on a fresh pull request. loophound trades a little precision for
reach: it catches the pattern the moment the code is written, in pre-commit or
CI, with nothing to set up.
pip install loophoundloophound app/ # scan a directory
loophound views.py # scan a file
cat views.py | loophound - # or read stdin
loophound app/ --strict # treat NP002 warnings as failures too
loophound app/ --json # machine-readable output- run: pipx run loophound app/ --strictExit codes: 0 clean · 1 a blocker (NP001), or any finding under --strict ·
2 usage error.
Static detection can't count real queries, so it errs toward flagging. Two
guards keep noise down: NP001 requires an ORM anchor, and NP002 only fires on
loopvar.<relation>.<method>() — so dict.get() or str.count() on a loop
variable are never mistaken for queries. When it's still wrong (a query you
intend, a tiny fixed loop), silence that line:
User.objects.get(id=i) # loophound:ignoreloophound finds the shape of an N+1 — a query per iteration. It can't prove
the loop runs at scale, and it won't catch an N+1 hidden behind a helper
function it can't see into (both on the roadmap). But the in-loop query is the
overwhelmingly common case, and that's the one it catches on the PR.
MIT © Jay Tank