Skip to main content

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

queryspy-0.1.0.tar.gz (32.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

queryspy-0.1.0-py3-none-any.whl (16.5 kB view details)

Uploaded Python 3

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

Hashes for queryspy-0.1.0.tar.gz
Algorithm Hash digest
SHA256 4086c5cb00da79836620b4a4b66b620cec2521a9f99403ab770514409019cdaa
MD5 8b6b7e886aeb28d15c83764893a5468e
BLAKE2b-256 52e71330a4945ec87dd0c8735c27a1e6e9bb9f42d66826703945e18fb1204b07

See more details on using hashes here.

Provenance

The following attestation bundles were made for queryspy-0.1.0.tar.gz:

Publisher: release.yml on sqla-native/queryspy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for queryspy-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 eccc3e90bcd5107a2cc5533f8e133ea21492c14e9ce14057df4ad1a1cfa0f377
MD5 1186ad05be27913d935214e586eb6bb7
BLAKE2b-256 70a805d81e23ef9aa5b01f59c263ff5fedccb59df12aa4ed242088d71d1b732e

See more details on using hashes here.

Provenance

The following attestation bundles were made for queryspy-0.1.0-py3-none-any.whl:

Publisher: release.yml on sqla-native/queryspy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page