Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

loophound

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 runtimenplusone 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.

What it flags

  • 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.

Why static

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.

Install

pip install loophound

Usage

loophound 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

In CI

- run: pipx run loophound app/ --strict

Exit codes: 0 clean · 1 a blocker (NP001), or any finding under --strict · 2 usage error.

Precision & suppression

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:ignore

Scope

loophound 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.

License

MIT © Jay Tank

About

Statically find N+1 queries - ORM calls made once per loop iteration (Django / SQLAlchemy) - with no app run, database, or test harness. Zero-config CLI with a CI-friendly exit code.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages