I maintain pytest-queryguard, a pytest plugin that fails a test when the code it runs sends one query per row. The problem it goes after is old: a test database holds a handful of rows, so a loop that lazy-loads a relationship costs a few extra queries and nobody notices. In production the same loop runs over hundreds of rows.

This test passes:

def test_list_orders(session): assert len(list_orders(session)) == 50 

and list_orders looks fine in review:

def list_orders(session: Session) -> list[dict[str, object]]: rows = [] for order in session.scalars(select(Order)).all(): rows.append({"id": order.id, "customer": order.customer.name}) return rows 

With the plugin installed, the same test fails:

queryguard: N+1 detected Order.customer was lazy-loaded 50 times first at app/orders.py:10 in list_orders called from tests/test_orders.py:7 in test_list_orders parents loaded at app/orders.py:9 in list_orders kind: relationship-load label: Order.customer docs: https://maciejziel.github.io/pytest-queryguard/how-detection-works.html#relationship-load sql: SELECT customer.id, customer.name FROM customer WHERE customer.id = ? fix: load it with the parent query, e.g. select(Order).options(joinedload(Order.customer)) 

Nothing to import or configure. Add the joinedload and it passes.

Then I ran it on a suite I didn't write

My own corpus of scenarios said the rules were accurate. Then I ran the plugin on Faraday, a Flask-SQLAlchemy app with 2112 tests on PostgreSQL. With the defaults of the code I had at the time, 551 tests failed.

I went through them, and nearly all were noise. The biggest group, 422 findings, came from Faraday's user factory: its post_generation hook looks up the role for every user it creates. That is an N+1, technically. It also never runs in production, and fixing it changes nothing for anyone using Faraday.

About 480 more came from a statistics helper called on six separate lines of a test. The plugin saw the same counts repeat and called it a loop. The rest were mostly tests sending several requests through one Session that the whole suite shares.

A tool that fails a quarter of your suite on the first run gets uninstalled on the first run. So 0.10.0 changed two things.

Test code is its own category now

If every frame of your code on a statement's stack is test code (the test module, conftest.py, files under tests/, any module that defines a factory_boy or polyfactory factory), its N+1 no longer fails the test. It is counted, and the run ends with a section like this (the example from the docs):

============================ queryguard: test code ============================= 422 N+1 findings in test code left out (299 tests): the statements ran only in test modules, conftest.py, factories or helpers under tests/, not in the application. To see them: pytest -o queryguard_test_code=warn, or set queryguard_test_code = warn (or fail) in the pytest configuration. Why: https://maciejziel.github.io/pytest-queryguard/how-detection-works.html#test-code tests/test_accounts.py::test_create_account tests/test_accounts.py::test_delete_account tests/test_billing.py::test_invoice_lines tests/test_billing.py::test_refund tests/test_orders.py::test_list_orders and 294 more 

One frame of the application on the stack is enough to make the statement the application's, so a loop in your code that a test helper calls is still reported. The rule is by file, which means a function written inside a test module is test code too. If that's how you reproduce bugs, set queryguard_test_code = fail.

The second change: every statement now carries its call path, the lines of your code from the statement up to the test. Two calls on separate lines have different paths and count apart; a loop has one path. That stack walk isn't free. In 0.10.0, on my in-memory SQLite micro-benchmark, it added about 3 µs per Core statement and 8 per ORM statement. On the 300-test suite I use for comparisons, where a test costs about 5 ms, 1.0.0 adds 13 to 15% on a GitHub runner and 18 to 20% on my laptop. On a suite where each test waits for a real database it's a few percent.

On Faraday, failures went from 551 to 79. Some of those 79 are real N+1s in its serializers. The others are loops in the test that call the application once per pass, ten paginated requests in a for loop for example. From the stack, that looks exactly like a loop in the application, and I don't have a way to tell them apart.

Django, and how experimental it is

0.10.0 also added Django support. It runs next to pytest-django, with nothing in INSTALLED_APPS or settings, on Django 5.2, 6.0 and 6.1. The fix in the message names select_related() or prefetch_related(), and on 6.1 .fetch_mode(models.FETCH_PEERS).

It is marked experimental: findings and options can change in a minor release. It doesn't have the unused-eager-load, unused-columns or cartesian-load checks, the static check, or the in-app mode yet. That is still the case in 1.0.0: the stable API covers the SQLAlchemy side, and Django stays experimental, along with the static check and the MCP server. Taking Django out of experimental is the plan for 1.1.

To show what "experimental" means in practice: the first 1.0 release candidate fixed a bug where a DRF ListAPIView with a class-level queryset and a nested serializer passed silently. That view runs no line of your project, so the plugin took its statements for the test's own code and left them out. The most common DRF N+1, missed by default, by the rule I just described.

On six Django projects (DRF, Read the Docs, django-cms, pretix, saleor, django-oscar; 8,945 tests), the plugin in warn mode changed no test outcome. I read 98 of its findings in the projects' code: 68 were real loops, 28 debatable, 2 false. That run was on 2026-11-07, two days before the release candidate.

The other tools

If you're on Django and django-zeal works for you, keep it. It has covered Django for years and it's the established choice. On my Django corpus of 73 scenarios, pytest-queryguard had 100% precision and 89% recall by default, django-zeal 2.3.0 93% and 70%, nplusone 84% and 43%. I wrote that corpus together with the integration, so 100% there means the cases I thought of work, not that it's right on your code. On overhead they're close: on a 300-test pytest-django suite mine added 3.0% at the median, django-zeal −0.5%, which is noise.

nplusone had its last release in 2018. It patches Query.__iter__, so on SQLAlchemy 2.0 it misses parents loaded with select(), and on 2.1 the import fails. My corpus has no lazy-load scenario written with session.query(), the API it patches, so I can't tell you how it does on legacy code.

For SQLAlchemy I ran every pytest tool I found on 278 scenarios in GitHub Actions, on 2026-11-10, with the release candidate (1.0.0 has the same code). With defaults, pytest-queryguard flagged 117 of 135 buggy scenarios and 2 of 143 clean ones. queryspy, pytest-querycount and n-plus-one-sniffer flagged 110 to 115 buggy and 30 to 35 clean. Discount that heavily. I wrote the corpus, its clean side includes the pagination and chunking shapes my rules were built to skip, and three clean scenarios use my own ignore() API. The others catch nearly every N+1 shape they claim to; pytest-querycount caught all of them. They also report runs of 2 to 4 identical statements, which mine doesn't by default. The workflow is public; fork it and run gh workflow run head-to-head.yml.

In CI

Any CI that runs pytest already enforces it. There is also a GitHub Action that uploads the findings as SARIF and, on a pull request, compares query counts with the base branch:

- uses: MaciejZiel/pytest-queryguard-action@v1 with: pytest-args: tests/ 

The comment it leaves looks like this:

### pytest-queryguard: queries compared with the base branch  412 tests compared: 3120 -> 3187 statements (+67). 2 run more, 1 fewer; 1 new finding, 0 fixed. #### New findings | Kind | Label | Where | Times | Tests | | relationship-load | `Order.items` | `app/orders.py in list_orders` | 50 | `tests/test_orders.py::test_list` | 

A reviewer sees that a change made a test run more queries even when no rule fired yet.

What it doesn't see

Statements that skip SQLAlchemy and Django's database layer: a raw sqlite3 or asyncpg connection, Peewee, any other ORM. It doesn't say so either. It only measures the test body, not fixtures. Work spread over threads counts together. And a finding says a pattern happened, not that production is slow. The limitations page has the rest.

Trying it

pip install pytest-queryguard pytest 

For Django: pip install "pytest-queryguard[django]" pytest-django. On an existing suite, record a baseline once and only new findings fail:

pytest --queryguard-baseline=queryguard-baseline.json --queryguard-update-baseline 

The repo is github.com/MaciejZiel/pytest-queryguard. If one of my scenarios is labelled wrong, or I configured your tool badly in the comparison, open an issue. I'd rather fix the benchmark than defend it.