FAQ#

The questions below are the ones to ask before adding a test-time check to a large suite: what it costs, how often it is wrong, how to silence it, and where it does not fit. The answers marked as measured link to how.

How does this relate to SQLAlchemy's raiseload?#

raiseload is a loader option (relationship(lazy="raise"), or .options(raiseload(Order.customer)) per query) that makes SQLAlchemy raise InvalidRequestError the moment code touches a relationship that was not loaded. It prevents lazy loading everywhere the option applies, in tests and in production, and it needs a change in the model or the query.

pytest-queryguard changes nothing in your code. It watches the statements a test sends and reports patterns, including ones raiseload does not cover: the same statement repeated in a loop with session.get(), Core and text() statements, loops over deferred or expired columns, row-by-row writes and missing indexes. The two work together: raiseload blocks a lazy load, so a blocked access raises instead of reaching the database, and queryguard has no lazy load to count at that point. Use raiseload where you want a hard rule in the model, and queryguard where you want to find the places that need one.

Two parents each lazy-load one relationship. Is that really a finding?#

Yes, by default. The relationship rule counts distinct parent objects, and the default threshold is 2, so order_a.customer and order_b.customer loaded separately is reported as Order.customer was lazy-loaded 2 times. That is the smallest N+1, and test databases hold a few rows, so a higher default would miss loops in tests that only create a couple of objects. If two is too strict for your code, raise it:

[tool.pytest.ini_options]
queryguard_relationship_threshold = 3

or silence one place, as in the next question.

How often is it wrong?#

A finding means that a pattern happened in the test: the same relationship loaded for several parents, or the same SELECT run five times or more. It does not measure production latency. The patterns that are reported although the code is fine, all listed on the Limitations page, are:

What it misses: statements outside SQLAlchemy, statements in fixtures, and a self-referential walk up a parent chain. The accuracy page measures precision and recall per rule on a corpus of realistic scenarios, and the comparison runs the same scenarios through other tools.

A finding is a false positive. How do I silence it?#

From the narrowest to the widest:

See Ignoring findings. Patterns that match nothing are listed as stale ignores, so they do not outlive the code.

My integration test makes several requests and fails with repeated-statement#

By default the repeat rules count everything one test runs. A test that calls an API five times, with a test client for example, runs the same lookups (authentication, the organisation, a webhook) once per request, each in its own SQLAlchemy Session. Nothing loops, but the count is five. The failure says so when it can: ran 9 times across 8 different Sessions or connection checkouts, with a note naming the option. Set it to count each Session on its own:

[tool.pytest.ini_options]
queryguard_repeat_scope = "session"

A loop inside one request, or any code that runs the same statement five times in one Session, is still reported. The same applies to lazy loads, column loads and write loops. See Tests with several sessions or requests for how the scope is chosen and where it stops being exact.

Does it work with pytest-xdist?#

Yes for detection, budgets, the baseline and SARIF: each worker measures its own tests, and the controller merges what the workers saw. The session report (--queryguard-report) needs one process and prints queryguard report: not available with pytest-xdist. The summary sections of the index check and the stale-ignore list are printed by the process that runs the tests, so they are not shown under xdist. The test suite runs the plugin under -n 2 and -n 4 in CI.

Which PostgreSQL drivers work?#

N+1 detection and budgets hook SQLAlchemy events, not a driver, so they do not depend on the driver. The index check needs a driver it can talk to through SQLAlchemy: CI runs it with pg8000 (sync) and asyncpg (async), on PostgreSQL 14 to 18. psycopg2 and psycopg 3 are LGPL-licensed, so the project does not depend on them and does not run them in CI. They were checked by hand during review and gave the same findings for plain statements, % in SQL, expanding IN, autocommit and stream_results; this is not covered by an automated test. On 2026-10-12 the 14 index scenarios of the comparison also gave the same results through psycopg 3 (3.3.6) as through pg8000, on PostgreSQL 17.11; psycopg2 was not part of that run. Async drivers other than asyncpg (for example psycopg's async mode) are skipped by the index check, with a notice in the session summary.

Why was nothing reported for my test?#

Does it slow my tests down?#

It adds work per statement: a stack walk for the call site and a timer around the cursor call. scripts/bench_overhead.py measures it, and CI compares it with a stored baseline.

Measured with scripts/bench_overhead.py on an Intel Core i5-13500H, Python 3.13, SQLAlchemy 2.1.1, median of 15 runs of 5000 statements pinned to one core, each configuration in a fresh process, against an in-memory SQLite database:

Per statement Measurement off On Added
Core select by key 41 us 50 us +9 us (+22 %)
ORM select by primary key 76 us 107 us +31 us (+40 %)

Two rounds differ by a few points, so read these as "about a fifth to two fifths" and not as exact figures. The percentages are large because an in-memory SQLite statement is almost free: the added cost is a fixed 9 to 31 microseconds, and a statement that goes to a real database takes far longer, so the share is much smaller there. These are micro-benchmarks. On a generated suite of 300 tests against a file-based SQLite database and against PostgreSQL the difference between running with and without the plugin was smaller than the run-to-run noise; see Performance for the method and numbers.

The options that are off by default cost nothing until you turn them on:

The numbers depend on your machine and database.

Does it work with Django, Peewee or raw SQL?#

Not with Django's ORM, Peewee or Tortoise ORM: only SQLAlchemy 2 is supported (the comparison lists tools for Django). Raw SQL that goes through SQLAlchemy (text(), Core) is seen; a raw DB-API connection that SQLAlchemy does not manage is not.

Can it break my test suite?#

It is designed not to. If one of its listeners raises, the plugin stops measuring for the rest of the session instead of failing your tests, issues a QueryGuardInternalWarning that names the listener, and prints a "queryguard: measurement stopped" section at the end of the session with the error and the line that raised it. A test then passes or fails on its own assertions. Invalid configuration values are the exception: they stop pytest at start-up with a usage error, before any test runs.

On SQLAlchemy 2.0.20 to 2.0.49, every do_orm_execute listener, the plugin's included, makes the outer statement's yield_per and autoflush options reach its selectinload statement (SQLAlchemy issue 13301): yield_per then fails with "Can't use the ORM yield_per feature in conjunction with unique()". The plugin undoes that for its own listeners, so a test does not fail only because the plugin is installed. A listener of your own triggers the issue with or without the plugin; upgrading to SQLAlchemy 2.0.50 or later fixes the cause.

The checks do not change the SQL your code sends. The index check explains statements without running them: on PostgreSQL inside a savepoint that is rolled back, on SQLite and MySQL with EXPLAIN, so data and the test's transaction are left as they were.

How do I turn it off?#

Does it work in a monorepo?#

Yes. File paths in reports, the baseline and ignore patterns are relative to pytest's rootdir, so they depend on where pytest is configured, not on the directory you start it from. With one pytest.ini at the repository root and pythonpath = services/a services/b, a finding reads first at services/a/app/orders.py:10 and the baseline stores that path. If each service has its own configuration file and its own rootdir, keep one baseline file per service: the same finding is app/orders.py there.

Call sites skip everything in site-packages. Install the monorepo's own packages editable (pip install -e, uv sync), or a finding in a shared library points to the test instead of the library line. For GitHub code scanning from a service directory, the action's working-directory input keeps SARIF paths relative to the repository root (see SARIF).

Does it work with async tests?#

Yes. AsyncSession runs on a sync engine underneath, which the plugin watches, so an async def test run by pytest-asyncio or anyio is measured like any other, and the call site is the await in your code. Lazy loads through awaitable_attrs are reported as relationship loads. The pytest-asyncio recipe covers configuration mistakes that look like database problems (strict mode, async fixtures, event loop scopes), and Getting started has an async FastAPI example. On SQLAlchemy 2.1 and newer install sqlalchemy[asyncio] for greenlet.

When should I not use it?#

Edit this page on GitHub