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): ...
Or, in application code, for a block you know is doing round trips on purpose:
with queryspy.ignore():
for account in accounts:
audit(account.owner)
Queries there are still counted — only detection is suppressed.
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.
In your running app
A per-request query panel for FastAPI, Starlette, Litestar — or Flask and anything else on WSGI. No framework dependency, no new packages.
from queryspy.asgi import QuerySpyMiddleware # FastAPI, Starlette, Litestar
app.add_middleware(QuerySpyMiddleware, budget=10)
from queryspy.wsgi import QuerySpyMiddleware # Flask, Pyramid, Bottle
app.wsgi_app = QuerySpyMiddleware(app.wsgi_app, budget=10)
WARNING queryspy: GET /projects - 12 queries in 41.3ms
N+1 detected: 11 queries for Project.tasks (lazy load)
triggered from app/api/projects.py:31 in list_projects()
fix: .options(selectinload(Project.tasks))
Or open the panel — the thing FastAPI has never had a Debug Toolbar equivalent for:
app.add_middleware(QuerySpyMiddleware, panel=True) # then GET /__queryspy__
Recent requests, query counts, how much of the wall clock was actually spent in the database, and every finding with its source line and fix. One self-contained HTML page — no CDN, nothing fetched from anywhere. Off by default.
Concurrent requests are isolated from each other: recording is scoped to a context variable, so interleaved requests never record each other's queries. Failed requests still report — the report is most useful precisely when the request blew up.
In CI
pytest --queryspy-report=queryspy.sarif
SARIF uploads to GitHub code scanning, which puts each N+1 as an annotation on the line of the pull request that causes it. There is an Action for the whole sequence:
permissions:
security-events: write
- uses: sqla-native/queryspy@v0
with:
args: tests/
Requesting a report collects without enforcing — outcomes are unchanged unless you also gate. Adopting the report first and the gate second is usually the shorter path on a suite that has never been measured.
Adopting it on a codebase that already has N+1s
Turning the gate on and watching twenty tests go red is how a linter gets switched back off. Record what is already there, then fail only on new ones:
pytest --queryspy-baseline=queryspy-baseline.json --queryspy-baseline-update
pytest --queryspy-baseline=queryspy-baseline.json --queryspy-strict
A finding is identified by (kind, label, file, function) — not by line number,
count, or which test found it — so the baseline survives unrelated edits instead
of expiring on every reformat. Entries that stop occurring get reported so you
can prune them.
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. Timing is available too
(spy.db_duration_ms, spy.slowest), and it is often the real story: twelve
queries where one takes 31 of the 39 milliseconds is a slow query wearing an
N+1's clothes.
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 |
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.
It also does not detect eager loads that are never used, and that is a
deliberate omission rather than a gap. Measured: SQLAlchemy's AttributeEvents
exposes only mutation hooks with no read event, InstanceState.unloaded is
empty after an eager load, and reading an attribute does not alter state.dict.
The only routes left are patching InstrumentedAttribute.__get__ — precisely
what killed nplusone — or taking over your entire model instrumentation. Not
worth the thing that makes this library trustworthy.
Django is out of scope too. This is sqla-native, and Django is the one
ecosystem that already has working tools.
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.4.0.tar.gz.
File metadata
- Download URL: queryspy-0.4.0.tar.gz
- Upload date:
- Size: 77.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
49104d036cc48c94320a509fccac74024d59b5e8dcb99c803668b1380e09ea31
|
|
| MD5 |
a55805a03e8dd84d6fe104f3bf50b04f
|
|
| BLAKE2b-256 |
2f8dd9f725b240f2f5cfe51142bb90b15ef122e9c03c15dea14a1bc59e93d6ea
|
Provenance
The following attestation bundles were made for queryspy-0.4.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.4.0.tar.gz -
Subject digest:
49104d036cc48c94320a509fccac74024d59b5e8dcb99c803668b1380e09ea31 - Sigstore transparency entry: 2387108516
- Sigstore integration time:
-
Permalink:
sqla-native/queryspy@cb960db05422c17e1035550e110bea78bd7e31bf -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/sqla-native
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@cb960db05422c17e1035550e110bea78bd7e31bf -
Trigger Event:
push
-
Statement type:
File details
Details for the file queryspy-0.4.0-py3-none-any.whl.
File metadata
- Download URL: queryspy-0.4.0-py3-none-any.whl
- Upload date:
- Size: 34.7 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 |
9703071ba384dbb03434fa24d0a7ae6eefe84dd911905f9a0301b9246d82a0ee
|
|
| MD5 |
06e8f742330d172a7c24c6af232dca92
|
|
| BLAKE2b-256 |
5676de4a5ab025828652eb19bb8a9010cbd47d39f9bbf05d13b7f218e7a03a44
|
Provenance
The following attestation bundles were made for queryspy-0.4.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.4.0-py3-none-any.whl -
Subject digest:
9703071ba384dbb03434fa24d0a7ae6eefe84dd911905f9a0301b9246d82a0ee - Sigstore transparency entry: 2387108519
- Sigstore integration time:
-
Permalink:
sqla-native/queryspy@cb960db05422c17e1035550e110bea78bd7e31bf -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/sqla-native
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@cb960db05422c17e1035550e110bea78bd7e31bf -
Trigger Event:
push
-
Statement type: