Comparison with other tools#

This page compares pytest-queryguard with other tools that look for N+1 queries or count queries. It is written by the author of pytest-queryguard, so read it with that in mind: the matrix is built from the competitors' own documentation and from running their packages, every cell says which, and the measurements are ones I ran myself, on scenarios I wrote.

Who is who#

Tool Version (release date) First release on PyPI Targets License
pytest-queryguard 0.4.0rc4 (2026-10-13); stable 0.3.0 (2026-10-11) 2026-10-10 SQLAlchemy 2 MIT
queryspy 0.5.0 (2026-10-04) 2026-08-04 SQLAlchemy 2.0+ MIT
pytest-querycount 0.4.0 (2026-09-27) 2026-09-27 SQLAlchemy 2.x MIT
nplusone 1.0.0 (2018-05-21) 2015-06-30 Django, SQLAlchemy, Peewee MIT
django-zeal 2.2.4 (2026-08-28) 2024-07-20 Django MIT
fastapi-nplusone 0.1.0 (2026-02-10) 2026-02-06 FastAPI with SQLAlchemy BSD-3-Clause
pytest-capquery 0.3.3 (2026-04-04) 2026-04-01 SQLAlchemy, Python 3.13+ CC BY-NC-SA 4.0 (see below)

Release dates come from the PyPI JSON API on 2026-10-12, re-checked on 2026-10-13. Most of these projects are young: pytest-queryguard, queryspy and pytest-querycount shipped their first release in the last three months, so expect all of them to change. This table will be out of date; check the projects' own pages.

pytest-capquery's PyPI metadata has no license field. Its README badge and the LICENSE file in its GitHub repository are Creative Commons Attribution-NonCommercial-ShareAlike 4.0, which is not an open-source license and restricts commercial use. Read it before you adopt it.

Feature matrix#

pytest-queryguard queryspy 0.5.0 pytest-querycount 0.4.0 nplusone 1.0.0 django-zeal 2.2.4 fastapi-nplusone 0.1.0 pytest-capquery 0.3.3
Works with SQLAlchemy 2 yes yes (v) yes (v) no: import fails on 2.1.4, silent or errors on 2.0.54 and 1.4.54 (v) not applicable (Django only) (r) SQLAlchemy, version not stated (r) SQLAlchemy, version not stated (r)
Fails tests with no marker or option yes (v) no: --queryspy-strict or queryspy_fail_on; budgets only with a marker or queryspy_budget (v) no: markers, or --querycount-max (v) not a pytest plugin (r) needs a fixture or middleware setup (r) logs a warning from a middleware (r) needs a fixture; assertions in the test (r)
N+1 from lazy-loaded relationships yes (v) yes (v) by repeated statement shape, without the relationship name (v) yes, SQLAlchemy 1.3.24 only (v) yes, Django (r) by repeated normalized query per request (r) by an expected query count in a block (r)
Repeated statements (session.get() loops, Core, text()) yes (v) session.get() loops yes; a Core select() loop and a loop with literal values not reported (v) yes (v) no (r) Django only yes (r) by count (r)
Deferred and expired column loads yes, with the right hint (v) yes, with the right hint (v) as a repeated statement, generic hint (v) no (r) .only() and .defer() (r) no (r) no (r)
Hint: joinedload or selectinload by cardinality yes (v) yes (v) generic text (v) no Django hints (r) no no
Query budget (max_queries) yes (v) yes (v) yes (v) no no no exact count in a block (r)
Budget failure lists every statement with its call site yes (v) no: the marker and queryspy_budget print only "expected at most N, got M"; the assert_max_queries() context manager adds the database time and N+1 findings, not the statements (v, 2026-10-13) yes, with timing (v) not applicable not applicable not applicable prints a copy-paste assertion (r)
Time budget and per-query timing max_db_time, timing in QueryLog (v) timing and the slowest statement; no time-budget option in the installed 0.5.0 (v, 2026-10-13) timing in the failure and the report (v) no no no no
Baseline for existing findings yes (v) yes (v) no baseline; --querycount-write-budgets writes exact budgets into your tests (v) no allow-list in settings (r) no no
SARIF and a GitHub Action SARIF yes; a composite action in this repository, action/ (v) SARIF yes (v: _serialize.py in the installed package); an action, sqla-native/queryspy@v0 (r) not found: no SARIF code in the installed package (v) no no no no
Missing-index check PostgreSQL 16+, SQLite, and MySQL and MariaDB (tested by hand, not in CI), with a CREATE INDEX; hypopg verification on PostgreSQL (v) none: no EXPLAIN code in the installed package (v) PostgreSQL only, no_seq_scan, with a CREATE INDEX suggestion (v) no no no no
Unused eager loads experimental, off by default (v) no, deliberately (r) no yes, for Flask-SQLAlchemy and Django per its README (r) no (r) no no
Row-by-row write loops opt-in (v) not reported (v) not reported (v) no no no no
Per-finding ignore marker, ini patterns and baseline (v); no with block marker and an ignore() block (v) ignore= for the index check only (r) whitelist and an ignore block (r) allow-list and an ignore block (r) no no
In-app middleware and request panel no yes: ASGI and WSGI, optional panel (v: modules present) no Django, Flask-SQLAlchemy, WSGI (r) Django middleware (r) yes, FastAPI middleware (r) no
Async SQLAlchemy yes (v) yes (v) yes (v) no no not stated yes, through sync_engine (r)
pytest-xdist tested in CI not stated summary per worker; budgets and N+1 unaffected (r) not stated not stated not stated not stated
Documentation site this site yes (r) README README README README README

"not stated" means I did not find a statement in the README, not that it does not work.

Where the others are ahead#

What I measured#

N+1 and budgets on SQLite#

A larger run, with 143 scenarios and precision and recall per finding kind, is on the accuracy page.

I ran 45 scenarios in tests/scenarios (SQLAlchemy ORM and Core, sync and async, SQLite) against three packages, each in its own virtual environment on 2026-10-12: pytest-queryguard next at b64d19c, queryspy 0.5.0 and pytest-querycount 0.4.0, with Python 3.13, SQLAlchemy 2.1.4 and pytest 9.1.1. The first 41 scenarios are the ones from an earlier comparison; the four scenarios 09d, 09e, 11d and 11e were added later for pytest-queryguard's own tests.

How each tool was run: pytest-queryguard with its defaults; queryspy with --queryspy-strict (without it, only the budgets in the scenarios fail: 2 of 45 tests); pytest-querycount with @pytest.mark.no_n_plus_one on every test that has no budget (without markers, all 45 pass). Budgets use the tool's own marker. The "queryguard, small-repeat and write-loop on" column adds queryguard_small_repeat_threshold = 2 and queryguard_write_loop_threshold = 2.

"Right verdict" is my judgement from the scenario's name (_bad should fail, _ok and _legit should pass); scenarios where I could not say are marked -. The scenarios were written for pytest-queryguard, and some of them test decisions it made on purpose (for example, three session.get() calls are below its default threshold), so the table is biased towards the tool that wrote it.

Scenario Right verdict queryguard queryguard, small-repeat and write-loop on queryspy pytest-querycount
01_lazy_n1_bad fail FAIL FAIL FAIL FAIL
02a_selectinload_ok pass pass pass pass pass
02b_joinedload_ok pass pass pass pass pass
02c_subqueryload_ok pass pass pass pass pass
03_session_get_loop_bad fail FAIL FAIL FAIL FAIL
03b_session_get_loop_3_bad fail pass (wrong) FAIL FAIL FAIL
04_async_awaitable_attrs_bad fail FAIL FAIL FAIL FAIL
04b_async_selectin_ok pass pass pass pass pass
04c_async_get_loop_bad fail FAIL FAIL FAIL FAIL
05a_repeat_x2 - pass FAIL FAIL FAIL
05b_repeat_x3_batch_legit - pass FAIL FAIL FAIL
05c_repeat_x4 - pass FAIL FAIL FAIL
05d_repeat_x5 - FAIL FAIL FAIL FAIL
05e_repeat_x6 - FAIL FAIL FAIL FAIL
06_legit_diff_shapes_ok pass pass pass pass pass
07a_budget_exact_ok pass pass pass pass pass
07b_budget_over_by_1_bad fail FAIL FAIL FAIL FAIL
08_m2m_lazy_bad fail FAIL FAIL FAIL FAIL
08b_m2m_selectin_ok pass pass pass pass pass
09_selfref_parent_walk_bad fail pass (wrong) pass (wrong) pass (wrong) pass (wrong)
09b_selfref_joined_ok pass pass pass pass pass
09c_selfref_kids_lazy_bad fail FAIL FAIL FAIL FAIL
09d_many_to_one_lazy_bad fail FAIL FAIL FAIL FAIL
09e_many_to_one_joinedload_ok pass pass pass pass pass
10a_dynamic_loop_bad fail FAIL FAIL FAIL FAIL
10b_writeonly_loop_bad fail FAIL FAIL FAIL FAIL
10c_raiseload_ok pass pass pass pass pass
11a_deferred_loop_bad fail FAIL FAIL FAIL FAIL
11b_undefer_ok pass pass pass pass pass
11c_expire_after_commit_refresh_bad fail FAIL FAIL FAIL FAIL
11d_expire_after_commit_few_ok pass pass pass FAIL (wrong) FAIL (wrong)
11e_expire_on_commit_false_ok pass pass pass pass pass
12a_insert_flush_per_row - pass FAIL pass pass
12b_add_all_one_flush_ok pass pass pass pass pass
12c_core_insert_per_row - pass FAIL pass pass
12d_core_executemany_ok pass pass pass pass pass
12e_budget_counts_writes_bad fail FAIL FAIL FAIL FAIL
13a_core_select_loop_bad fail FAIL FAIL pass (wrong) FAIL
13b_count_loop_bad fail FAIL FAIL FAIL FAIL
14a_pagination_3_pages_legit pass pass pass FAIL (wrong) FAIL (wrong)
14b_two_parents_legit pass FAIL (wrong) FAIL (wrong) FAIL (wrong) FAIL (wrong)
14c_same_parent_twice_ok pass pass pass pass pass
14d_identity_map_get_ok pass pass pass pass pass
14e_in_list_varying_len_legit pass FAIL (wrong) FAIL (wrong) FAIL (wrong) FAIL (wrong)
14f_literal_binds_loop_bad fail FAIL FAIL pass (wrong) FAIL

Score, on the 38 scenarios with a right verdict: pytest-queryguard 34, with the two extra rules on 35, queryspy 31, pytest-querycount 33. On the 34 of those that belong to the original 41: 30, 31, 28 and 30. These numbers measure agreement with my labels, not how useful a tool is. What the table shows:

Missing indexes on PostgreSQL#

14 scenarios on PostgreSQL 17.11 (docker, the official image), with pytest-queryguard --queryguard-index-check through two drivers and pytest-querycount --querycount-no-seq-scan. queryspy has no such check, so it is not in the table. The tables have 20 to 40 rows. Python 3.13, SQLAlchemy 2.1.4. The advisor ran without hypopg.

Scenario Right verdict queryguard (pg8000) queryguard (psycopg 3) pytest-querycount (psycopg 3)
i01: equality on an unindexed FK column fail FAIL FAIL FAIL
i02: equality on an indexed column pass pass pass pass
i03: SELECT without a filter pass pass pass pass
i04: primary-key lookup pass pass pass pass
i05: lower(email) with an expression index pass pass pass pass
i06: lower(city) with no expression index fail FAIL FAIL FAIL
i07: filter on the second column of a two-column index fail FAIL FAIL pass (wrong)
i08: filter on the first column of that index pass pass pass pass
i09: LIKE prefix on an unindexed text column fail FAIL FAIL FAIL
i10: ORDER BY id LIMIT 5, no filter pass pass pass pass
i11: range on an unindexed timestamp fail FAIL FAIL FAIL
i12: filter on a 2-row lookup table, no index - FAIL FAIL FAIL
i13: join on an unindexed FK, no WHERE pass pass pass pass
i14: IN list on an unindexed FK column fail FAIL FAIL FAIL

i07 is a judgement call: the two-column index can serve the filter with a full index scan, and PostgreSQL 18 may skip-scan it. pytest-queryguard reports it on PostgreSQL 16 and 17 and pytest-querycount does not. On the 13 scenarios with a right verdict, pytest-queryguard agreed with my labels on 13 with both drivers, pytest-querycount on 12. Both report the 2-row lookup table (i12), which is by design; both give you a way to exclude such tables. pytest-queryguard's finding also includes an Alembic call for simple cases and says "an expression index may be needed" for i06; pytest-querycount's suggests CREATE INDEX for plain columns and gives no suggestion for the expression.

What I did not measure#

SQLite for the index check (the page for it has its own tests), MySQL, PostgreSQL versions other than 17, async drivers in this comparison, memory and speed, other SQLAlchemy versions, and any real application. Overhead numbers are in scripts/bench_overhead.py and are for pytest-queryguard only.

Choosing#

Edit this page on GitHub