queryspy
N+1 and query-budget detection for SQLAlchemy 2.0 — sync and async.
Django developers get assertNumQueries, nplusone, and the Debug Toolbar.
SQLAlchemy developers get told to write their own before_cursor_execute
listener. queryspy is the missing piece: point it at your existing test suite
and it tells you which line of your code fires an N+1, and what to do about it.
N+1 detected: 11 queries for User.addresses (lazy load)
triggered from app/services/users.py:28 in list_users()
SELECT address.id AS address_id, address.email AS address_email, address.user_id ...
fix: .options(selectinload(User.addresses))
Install
pip install queryspy
One runtime dependency: SQLAlchemy. The pytest plugin registers itself.
Quick start
Run your existing suite in strict mode and see what lights up:
pytest --queryspy-strict
Or assert deliberately, in the tests where it matters:
from queryspy import assert_max_queries, no_n_plus_one
def test_list_users(session):
with no_n_plus_one():
list_users(session)
def test_list_users_is_two_queries(session):
with assert_max_queries(2):
list_users(session)
Mark the places where an N+1 is a deliberate trade-off:
@pytest.mark.queryspy(allow_n_plus_one=True)
def test_admin_report(session): ...
What it catches
Three detectors, applied in order of precision. Each claims the queries it explains, so nothing is reported twice.
| Detector | Catches |
|---|---|
lazy_load |
A relationship lazily loaded once per parent row |
column_load |
A deferred column, or an attribute refreshed after commit, loaded per instance |
repeated_statement |
The same statement executed N times — a session.get() loop, parents fetched one at a time, anything the ORM hooks cannot see |
The third matters more than it looks. A loop of await session.get(User, uid)
is not an ORM lazy load at all — no relationship-load hook will ever fire for
it — but it is still N round trips where one would do.
Async
Async is not symmetric with sync, and it is worth knowing why.
In async SQLAlchemy a plain lazy load raises MissingGreenlet rather than
silently N+1'ing, so the classic lazy-load bug is loud. What is quiet in async
code is:
users = (await session.scalars(select(User))).all()
for user in users:
await user.awaitable_attrs.addresses # one query per user
queryspy catches that (it does set lazy_loaded_from), along with
session.get() loops and per-item repository calls. AsyncSession needs no
special setup — it wraps a sync Session, and the listeners are registered on
the class.
Async findings are attributed to your source line too. That takes a little work:
SQLAlchemy runs an async lazy load inside a spawned greenlet whose stack holds
no application frames at all, so queryspy walks up the greenlet chain to find
the caller. Without that, exactly the case you most want to diagnose would
report with no source line.
API
record() |
The recording window. spy.query_count, spy.findings(). |
assert_num_queries(n) |
Exactly n statements. |
assert_max_queries(n) |
At most n statements. |
no_n_plus_one() |
No findings. |
Query counts are statements that reached the driver, flushes included — the
same thing Django's assertNumQueries counts.
Every failure subclasses AssertionError, so pytest renders it like a failed
assert. A failing test body always wins over a queryspy assertion; your own
exception is never masked.
pytest options
| Option | Effect |
|---|---|
--queryspy-strict |
Fail any test that triggers an N+1 |
queryspy_budget = 10 |
Maximum statements per test |
queryspy_fail_on = n_plus_one |
The ini equivalent of --queryspy-strict |
queryspy_capture_stacks = false |
Skip source attribution (the main per-query cost) |
queryspy fixture |
A live recorder, for tests that want to inspect queries themselves |
Why not nplusone?
nplusone is still the answer everyone
gives, and it last shipped to PyPI in May 2018. It predates SQLAlchemy 2.0,
has no async support, and works by monkeypatching ORM internals — which is why
it stopped working rather than being ported.
SQLAlchemy 2.0 added do_orm_execute and an ORMExecuteState that exposes
lazy-load attribution as public API. queryspy is built entirely on that: no
patching, no private imports, a few hundred lines.
That API also makes correctness possible in a way it wasn't before. Measured on SQLAlchemy 2.0.51:
is_relationship_load |
lazy_loaded_from |
|
|---|---|---|
| true lazy load | True | set |
selectinload |
True | None |
subqueryload |
True | None |
A detector keyed on is_relationship_load would flag selectinload — the
fix — as the bug. queryspy keys on lazy_loaded_from, and the
false-positive suite is a release gate held to the same standard as the
detection suite.
What it does not do
Not a profiler, not a query optimiser, not a production APM. It does not change loader strategies or rewrite queries. It tells you where the problem is and what to paste; the fix is yours.
Requirements
Python 3.10+, SQLAlchemy 2.0+.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file queryspy-0.1.0.tar.gz.
File metadata
- Download URL: queryspy-0.1.0.tar.gz
- Upload date:
- Size: 32.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4086c5cb00da79836620b4a4b66b620cec2521a9f99403ab770514409019cdaa
|
|
| MD5 |
8b6b7e886aeb28d15c83764893a5468e
|
|
| BLAKE2b-256 |
52e71330a4945ec87dd0c8735c27a1e6e9bb9f42d66826703945e18fb1204b07
|
Provenance
The following attestation bundles were made for queryspy-0.1.0.tar.gz:
Publisher:
release.yml on sqla-native/queryspy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
queryspy-0.1.0.tar.gz -
Subject digest:
4086c5cb00da79836620b4a4b66b620cec2521a9f99403ab770514409019cdaa - Sigstore transparency entry: 2341480402
- Sigstore integration time:
-
Permalink:
sqla-native/queryspy@52cb338971e36a1e4b665da632f0963eb2b91a5d -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/sqla-native
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@52cb338971e36a1e4b665da632f0963eb2b91a5d -
Trigger Event:
push
-
Statement type:
File details
Details for the file queryspy-0.1.0-py3-none-any.whl.
File metadata
- Download URL: queryspy-0.1.0-py3-none-any.whl
- Upload date:
- Size: 16.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eccc3e90bcd5107a2cc5533f8e133ea21492c14e9ce14057df4ad1a1cfa0f377
|
|
| MD5 |
1186ad05be27913d935214e586eb6bb7
|
|
| BLAKE2b-256 |
70a805d81e23ef9aa5b01f59c263ff5fedccb59df12aa4ed242088d71d1b732e
|
Provenance
The following attestation bundles were made for queryspy-0.1.0-py3-none-any.whl:
Publisher:
release.yml on sqla-native/queryspy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
queryspy-0.1.0-py3-none-any.whl -
Subject digest:
eccc3e90bcd5107a2cc5533f8e133ea21492c14e9ce14057df4ad1a1cfa0f377 - Sigstore transparency entry: 2341480424
- Sigstore integration time:
-
Permalink:
sqla-native/queryspy@52cb338971e36a1e4b665da632f0963eb2b91a5d -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/sqla-native
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@52cb338971e36a1e4b665da632f0963eb2b91a5d -
Trigger Event:
push
-
Statement type: