Changelog#
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased#
0.4.0rc4 - 2026-10-13#
Fixed#
session.refresh()of an object whose collection is mappedlazy="selectin"orlazy="subquery"(also reached through alazy="joined"relationship) was reported as a relationship-load N+1 with aselectinloadhint for the strategy that is already mapped; two creates followed by a refresh each were enough to fail a test, on sync and async sessions. The loads SQLAlchemy runs for a refresh or an expired reload are no longer counted as lazy loads. Alazy="select"collection read per parent and animmediateload()option are reported as before.- The refresh a repository runs after each create or update (advanced-alchemy's
auto_refresh) was a column-load finding as soon as a test created five rows. Asession.refresh()of an object that the latest flush of its Session inserted or changed is no longer counted (one object per class and flush); a write loop itself stays the opt-in write-loop rule's finding. A loop of refreshes of objects that were only read or flushed without a net change, a refresh of every row of one bulk insert and a second refresh of the same write are reported as before. On litestar-fullstack this removes the column-load finding of two tests that create ten tokens each; their repeated-statement findings remain. session.refresh(obj, ["name"])in a loop was labelled as an expired load with anexpire_on_commit=Falsehint; it is now a refresh (Book (refresh)) with the refresh hint.- In
warnmode, a finding turned into an error byfilterwarnings(error::pytest_queryguard.QueryGuardWarningor a blanketerror) no longer prints about 50 lines of the plugin's hook wrapper source before the finding; the failure shows the finding message, as infailmode. - Under pytest-xdist, a collection error without
-xno longer rewrites the baseline or writes the--queryguard-sariffile: the run is handled as the interrupted run a serial run is, with the same reason. Closes #194. - Behavior change: a serial run with
--continue-on-collection-errorsand a collection error also no longer rewrites the baseline or writes the--queryguard-sariffile, where it did before. The tests of the module that did not collect never ran: a rewritten baseline would drop their entries and an uploaded report would close their alerts. The summary gives the interrupted-run reason; the GitHub Action skips the upload with a warning. - An internal error in the budget markers, the
allow_missing_indexcheck or the end of a measurement in thepytest_runtest_callhook no longer fails the user's test: it ends measurement with aQueryGuardInternalWarningand the "queryguard: measurement stopped" section, as other internal errors do. The collection-report and pytest-xdist worker hooks are guarded the same way. AKeyboardInterruptorSystemExitat the end of a measurement still stops the run, and leaves no collector or eager-load tracker behind. Closes #197. - Under pytest-xdist, a run stopped by
-xor--maxfailis handled as in a serial run instead of as an interrupted one: the baseline is not updated because the run stopped early (it no longer blames a collection error, Ctrl-C or an internal error), and the--queryguard-sariffile is written. A collection error under-x, serial or with pytest-xdist, is still reported as an interrupted run. Closes #187. - The GitHub Action example in
docs/ci.mdno longer fails on a first run: thebaselineinput is commented out until the file exists. The examples useactions/checkout@v7andactions/setup-python@v7(Node 24) and run onpushtomainpluspull_request, so a pull request does not run twice. - A table name in a missing-index finding is now matched the same way in
queryguard_index_ignore_tables,allow_missing_index,queryguard_ignoreandallow_nplusone(..., kind="missing-index"): a bare name,schema.table(also for a table the report prints without its schema), quoted or unquoted identifiers (unquoted as written, case-sensitive; quoted exactly, may hold dots). The documentedallow_nplusone("public.audit_log", kind="missing-index")now matches. Every spelling that matched before still does, and the labels in reports and baselines are unchanged. This can leave out missing-index findings that were reported before: inqueryguard_ignoreandallow_nplusone, a bare name (audit_log) now also matches the table in a schema off the search path (labelaudit.audit_log),schema.tablematches a table printed without its schema, and an unquoted name matches a table whose label is quoted (UserAccountfor"UserAccount"). Findings of other kinds match as before. docs/sarif.mdanddocs/baseline.mdlisted five finding kinds; the SARIF file lists six rules,unused-eager-loadincluded.- The budget failure says
expected at most 1 query(it said1 queries); other limits are unchanged. - Documentation found in a walk of the 0.4.0rc3 docs, each snippet run in a
fresh environment: the Flask-SQLAlchemy, SQLModel, Litestar and
pytest-asyncio recipes show
pytest.iniwithpythonpath = .(copied literally, they failed withNo module named 'app') and the models and test files they were missing;python-api.mdandmigrating-from-queryspy.mdimport the fixed modules where they expect the fixed behaviour;python-api.mddocumentscolumn_load_threshold,small_repeat_thresholdandwrite_loop_thresholdofassert_no_nplusone(); the legacy recipe puts its settings in the example'spytest.ini, which pytest reads instead of apyproject.toml; the JUnit XML of the CI recipe, a line number in the budgets recipe, a test file name in the FastAPI recipe, a missing file on the getting-started page and the stale0.4.0rc2install lines are corrected.tests/test_doc_examples.pynow runs the SQLModel, Litestar and pytest-asyncio pages as projects too (skipped where the package is not installed).
0.4.0rc3 - 2026-10-13#
Added#
QueryGuardDeprecationWarning, aDeprecationWarningsubclass exported frompytest_queryguard, is the class the deprecation policy uses. It is not aQueryGuardWarning, sofilterwarnings = ["error::pytest_queryguard.QueryGuardWarning"](andignore::for findings) neither turns a deprecation into a failure nor hides it.docs/stability.mdnames it in step 1 of the policy. Nothing is deprecated yet.
Changed#
-
The repeated-statement rules no longer report pagination and chunked
INloads when the bound parameters show them: only theOFFSETor an integer keyset bound changes, in one direction, by at least a page of more than one row (the walk may be repeated once); or oneINlist of at least 50 values changes, the lists share no value and all but one have the same length.LIMIT 1 OFFSET nwalks, a page per item, a keyset bound per row, string and date keyset bounds, per-itemINlists, a paginated lookup inside a loop and identical repeats are still reported. Closes #60. The accuracy corpus has five more CLEAN scenarios for these shapes and five more BUG scenarios that imitate them; on its 141 scenarios the default repeated-statement precision goes from 80% to 97% (90% to 97% without the five CLEAN ones), recall is unchanged at 95%, no BUG scenario changes in any configuration, and the release gate's minimum precision for the kind is now 90%. See "Pagination and chunks" indocs/how-detection-works.md. -
docs/stability.mdhas an "Experimental" section: thequeryguard_unused_eager_loadoption, theunused-eager-loadfinding kind and the missing-index check on MySQL and MariaDB are exempt from the deprecation policy and may change or go in a minor release with a changelog entry. The policy had no such tier although the option was labelled experimental. -
max_queries()andmax_db_time()now yield the publicQueryLog(the objectrecord_queries()yields) instead of the internalQueryCollector, and are annotatedAbstractContextManager[QueryLog].with max_queries(3) as log:giveslog.count,log.queries,log.slowest()andlog.assert_no_nplusone(). Code that read.statementsof the yielded object, an undocumented attribute, must uselog.queriesorlog.count. -
The declared minimum SQLAlchemy is now 2.0.13 (
sqlalchemy>=2.0.13). The suite and the async helpers needAsyncAttrs, which does not exist in earlier 2.0 releases, so the old>=2.0bound promised more than was tested. -
The options with a fixed set of values now agree on case.
queryguard_mode,queryguard_hypopgand--queryguard-modeused to rejectFAIL,ONandWarnwhilequeryguard_unused_eager_loadandqueryguard_repeat_scopeaccepted any case; all of them now ignore case and surrounding spaces, and unknown values are still a usage error. -
Lower the per-statement overhead by about a sixth in the in-memory SQLite micro-benchmark: one shared
CallSiteper source location, one write check per statement and a cache fornormalize(). Behaviour is unchanged. Newdocs/performance.mdandscripts/bench_suite.py(a whole-suite benchmark for file-based SQLite or PostgreSQL). -
allow_nplusonewith patterns now also applies to missing-index findings.kind="missing-index"was accepted but never matched anything and was then reported as a stale ignore; it now works like the same pattern inqueryguard_ignore. The marker without arguments still leaves the index check alone (allow_missing_indexskips it). -
The accuracy corpus has 23 more scenarios (133 in all): GraphQL-style resolvers and DataLoaders,
asyncio.gatherand Celery-style fan-out, caching repositories,with_for_update, nested transactions,yield_per, largeselectinload, ORM bulk statements, upserts, and BUG scenarios that look like pagination, batching or polling.docs/accuracy.mdand the release gate's minimum scenario count are updated; the plugin is unchanged. -
Tests and CI: a release gate (
.github/workflows/release-gate.yml,scripts/release_gate.py) that checks the test matrix, the accuracy minimums, the documentation examples, clean installs, the upgrade from the previous release, the overhead budget, the package contents and the changelog before a release is tagged. Seedocs/releasing.md. -
PostgreSQL index check: a statement on a partitioned table is now one finding on the partitioned table, with
CREATE INDEX ON <table> (...)(which creates the index on every partition) and its hypopg verification, instead of one finding and oneCREATE INDEXper partition (#86). An ignore or baseline entry that named a partition no longer matches: replace the partition's name with the partitioned table's inqueryguard_index_ignore_tables,allow_missing_indexandmissing-indexpatterns, and record the baseline again with--queryguard-update-baseline(seedocs/index-check.md). A statement that names a partition itself is reported under the partitioned table too; inheritance children (INHERITS) are still reported one by one. Each partition is looked up once per session (pg_partition_root()), only for statements with findings. The advisor's reason for an invalid existing index now also namesCREATE INDEX ON ONLYa partitioned table whose partitions are not all attached. -
The classes and functions in
pytest_queryguard.__all__now reportpytest_queryguardas their__module__. Tracebacks and logs showpytest_queryguard.NPlusOneDetectedinstead ofpytest_queryguard.api.NPlusOneDetected, so the printed name matches the documented import path and the submodules can move without changing it. Afilterwarningsentry that names the submodule (error::pytest_queryguard.warnings.QueryGuardWarning) keeps working. -
The keyword of the
max_queriesmarker is nowlimit, as in themax_queries(limit)function:@pytest.mark.max_queries(limit=3). The keywordn=of earlier releases is still accepted, without a warning. It may be removed in a future minor release, but only after a deprecation period with a warning, as described indocs/stability.md. The positional form is unchanged. -
Every finding in a report now has a
kind: ... label: ...line, the pair thatqueryguard_ignore,allow_nplusoneand the baseline match on, for examplekind: relationship-load label: Order.customer. For a repeated statement or a write loop the line replaces the separate line with the normalized statement (a statement longer than 200 characters is cut and ends with*, so the printed label still works as a pattern). An ignore pattern that is the exact label now matches even when the label holds[or]. The line is in the SARIF message too (#105).
Fixed#
-
On SQLAlchemy 2.0.20 to 2.0.49 (and 2.1.0b1, 2.1.0b2) the plugin's
do_orm_executelistener triggered SQLAlchemy issue 13301 (fixed in 2.0.50): the outer statement'syield_per,autoflush,populate_existingandidentity_tokenoptions became load options of itsselectinloadandimmediateloadstatements. Ayield_perplusselectinloadtest failed with "Can't use the ORM yield_per feature in conjunction with unique()", andautoflush=Falsestopped the loader statement from flushing, only because the plugin was installed. On those versions the plugin now removes the inherited options from a loader statement before SQLAlchemy reads them, so the statement runs as it does without the plugin. When ado_orm_executelistener of your own takes part, the plugin changes nothing: your listener triggers the same issue without the plugin (#169). -
Tests: the accuracy corpus now checks that a scenario never fails without a finding in every run, not only in the pytest-xdist worker that happened to measure it.
-
On SQLAlchemy 2.0.13 to 2.0.29 the column load of a joined-inheritance subclass (reading
manager.budgetafterselect(Employee)) was not recognised as a column load and was reported as a repeated statement. Before 2.0.30 SQLAlchemy reportsis_selectandis_column_loadas False for thefrom_statement()execution that loads those columns; the plugin now also reads the refresh state from the statement and the execution options. The oldest-versions job of the release gate now installs exactlysqlalchemy==2.0.13, the declared minimum, instead of the latest 2.0.x. Fixes #161. -
Free-threaded CPython (3.14t): statements, relationship and column loads and database time reported from several threads into one collector lost updates (
counted 15839, expected 16000), which budgets and the truncation flag rely on. The collector now updates them under a lock on a build without the GIL and takes no lock with the GIL (scripts/bench_overhead.pyshows no change on CPython 3.12). Tests: the memory test measures what the plugin itself allocated, because the whole-process figure on the free-threaded build reflects SQLAlchemy's late freeing, not a leak in the plugin. Free-threaded builds are still not in the CI matrix;docs/getting-started.mdsays so. Fixes #159. -
The repeat rules (repeated statement and small repeat) no longer count transaction-control statements:
BEGIN,COMMIT,ROLLBACK,SAVEPOINT,RELEASE,START TRANSACTIONandSET TRANSACTION. With SQLAlchemy's pysqliteBEGINrecipe a loop that commits each pass was reported withBEGINas a repeated statement and the advice to fetch rows in one query. Budgets still count them. ABEGINthat opens a block (BEGIN load_order(:id); END;) is still counted, and a loop with aSAVEPOINTper row is still a write loop. The accuracy corpus gives the same result before and after. Fixes #158. -
Stability: with
-W errororfilterwarnings = error, an internal error no longer fails the user's test.QueryGuardInternalWarningwas raised as an exception from the test call, from the terminal summary (which aborted it), from arecord_queries()block and, for the unused eager-load check, from inside SQLAlchemy'sdo_orm_execute. Now it is always shown and recorded, never raised, and an "ignore" filter still hides it (#124). - Stability: an error inside the
after_beginlistener no longer reaches the user'sSession.begin(). Like every other listener, it now stops the measurement with aQueryGuardInternalWarning(#123). - Stability: a baseline file nested too deep for the JSON parser is a usage
error instead of an
INTERNALERROR, and any error while writing the baseline is reported instead of crashing the end of the session (#125). - PostgreSQL index check: an internal error after the check opened its
savepoint (or, on an autocommit connection, its own transaction) left that
savepoint or transaction open on the user's connection. In autocommit mode
the user's later writes were then never committed, and inside a transaction
enable_seqscanstayedoff. The check now rolls them back before the error stops the plugin (#126). -
Stability: an error inside
pytest_configure, the per-test start or end of the measurement, the pytest-xdist hand-over of the findings,pytest_unconfigureor the attribute-read hook of the unused eager-load check no longer ends the session with anINTERNALERRORor fails every test. It stops the measurement and is reported once, like an error in a listener; an error in the xdist hand-over marks the run incomplete, so the baseline is not rewritten and the SARIF summary says findings may be missing.KeyboardInterrupt,SystemExitandasyncio.CancelledErrorare still never swallowed.docs/stability.mddescribes the contract under "Failing open" (#127). -
Tests: the accuracy corpus now runs on PostgreSQL (
QUERYGUARD_CORPUS_PG_URL). Two scenarios (bug_23_get_or_create_loop,bug_30_flush_per_insert) failed there because seeded rows with explicit ids did not advance the sequences; the corpus moves them after each setup. The results on PostgreSQL 17 match SQLite; seedocs/accuracy.md. Fixes #120. -
SARIF: a finding whose call site is unknown (for example
session.executehanded straight to a worker thread) is located at the test instead of being left out of the file, so code scanning shows it. The terminal text, the baseline and the ignore rules are unchanged.docs/limitations.mdexplains the cause and the workaround (#132). -
GitHub Action: the
packageinput now defaults to the plugin in the action's own checkout instead of the namepytest-queryguard, so the installed plugin matches the pinned ref and accepts--queryguard-sarif(pip used to pick the stable 0.3.0 while 0.4 was a release candidate) (#106). -
The index check notice for a database it cannot check (SQL Server, Oracle) said the check needs PostgreSQL 16, although SQLite, MySQL and MariaDB are supported. It now lists every supported database, and the help texts of
--queryguard-index-checkandqueryguard_index_checkname MySQL and MariaDB too. Fixes #154. -
PostgreSQL index check: a finding for an index the plan walks end to end (a filter on the trailing column of a composite key, a whole index with a filter, a whole-index bitmap) now says
t scans the whole index ix: the ... does not match the start of any index, as the SQLite check does, instead of saying the table is read in full. The closing note no longer calls such a scan sequential (#130). -
A lazy load keeps its relationship when another statement runs between the load and its
SELECT: statements of otherAsyncSessions running concurrently (asyncio.gather), theBEGINof SQLAlchemy's pysqlite recipe or any statement of abeginorafter_beginlistener. The attribution is now parked on theSessionthat issued the load instead of on the thread, so these loops are no longer reported a second time as a repeated statement. Fixes #97 and #99. -
The relationship-load finding shows a lazy load as its sample SQL. It used the first statement with the relationship's label, which was the
selectinloadquery when that ran first; that query is now used only when no lazy load was recorded (#131). -
The SARIF level of an unused-eager-load finding now follows the test outcome: with
queryguard_unused_eager_load = warnit iswarning, noterror. Fixes #98. -
A statement that starts with a comment (sqlcommenter tags, a
-- notein atext()) is now classified like the statement after the comment. A loop of commented single-rowINSERTs was reported as a repeated statement with the advice to fetch the rows in one query, and the write-loop rule never saw it. Fixes #96. -
The MySQL and MariaDB index check no longer fails on a literal
%in a statement that was run with theno_parametersexecution option: theEXPLAINwas formatted by PyMySQL (query % args) although the statement itself was not, so it was reported as "could not be explained" (a literal%%was sent as%). Fixes #116. -
--queryguard-update-baselineno longer rewrites the baseline, and--queryguard-sarifno longer writes the file, after an interrupted run (a collection error, Ctrl-C, a usage error or an internal pytest error). The summary says why. Fixes #95. -
Tests: the public API snapshot recorded a wrong return type for
QueryLog.slowest()and did not assert it. It now recordstuple[Query, ...]and checks every member ofQueryLogthe same way. -
--queryguard-update-baselinewrites a usable baseline when a test lives in a directory whose name is not valid UTF-8: such characters are written as\udcXXescapes (other text stays readable), so the entry matches on the next run instead of the write failing. Fixes #89. -
PostgreSQL index check:
NOT IN (subquery)on PostgreSQL 17 and 18 no longer getsfix: CREATE INDEX ON t (col)for the compared column, which was wrong even for a primary key (#83). Plan conditions withCASE,AT TIME ZONE,EXTRACT(... FROM ...),count(*),COLLATEor an array subscript are now parsed instead of matched as text, so a column inside them is no longer suggested as a plain index and gets the expression-index note (#84). A row comparison such as(a, b) > ($1, $2)no longer asks for an expression index (#85). -
PostgreSQL index check: a server newer than the tested releases (16 to 18) is still checked and now gets one notice in the session summary that its version is untested. Older servers keep their one "not checked" notice. The tested versions are listed in
docs/index-check.md. - The index check no longer rolls back a transaction the application opened by
hand on an AUTOCOMMIT connection (
exec_driver_sql("BEGIN"), with pg8000 or asyncpg). Theautocommitflag is the driver's setting, not the server's state, so the check now tries a savepoint first and starts a transaction of its own only when none is open (SQLSTATE 25P01). Fixes #91. - Documentation: the index check page was published as raw Markdown from the partial-index paragraph on, including its SQLite section (#117). A test now fails the build for any page left like that.
- Documented that PostgreSQL evaluates
IMMUTABLEfunctions with constant arguments at plan time, so the index check and the advisor can call such a function again and its non-transactional side effects (for examplenextval()) are not rolled back with the savepoint. Fixes #92. - The repeated-statement and small-repeat rules no longer count schema
reflection:
PRAGMAstatements, reads ofsqlite_master,sqlite_schemaand their temp variants, and reads ofpg_catalogorinformation_schema(MetaData.reflect(),Inspector.has_table(),create_all(), Alembic's version-table check).max_queriesbudgets still count them. Seedocs/how-detection-works.mdfor the exact rule (#129). -
PostgreSQL index check: a sequential scan whose filter is only not-equal comparisons against parameters (
<> $1,NOT INwith bound values) is no longer reported as a missing index. No index serves them, so the advice could not be followed, even on an indexed column; the SQLite check never reported them. A filter with one more servable condition, or a not-equal against a constant in the SQL (which a partial index serves), is still reported (#133). -
PostgreSQL index check: a server newer than the tested releases (16 to 18) is still checked and now gets one notice in the session summary that its version is untested. Older servers keep their one "not checked" notice. The tested versions are listed in
docs/index-check.md. - Documentation: the index check page was published as raw Markdown from the partial-index paragraph on, including its SQLite section (#117). A test now fails the build for any page left like that.
-
Baseline: a file nested more than 100 levels deep is now rejected by a depth check before parsing, so the "nested too deep" usage error no longer depends on the interpreter hitting its recursion limit (which on Python 3.10 could surface as a second, unraisable
RecursionError). -
--queryguard-update-baselineon a partial run (-k,-m,--deselect,--lf,--sw,--ignore, a node id or a path narrower thantestpaths, with or without pytest-xdist) no longer drops the entries of tests that did not run. Only the entries of tests that ran are replaced, the others are kept, and the summary says how many were kept. A deleted test's entries are removed on the next full run.docs/baseline.mddescribes which runs are full and which are partial. Fixes #148. -
--queryguard-update-baselinewith--collect-onlyemptied the baseline file. It now leaves the file alone and says why.
0.4.0rc2 - 2026-10-12#
Added#
- Community files:
CONTRIBUTING.md,SECURITY.md,CODE_OF_CONDUCT.md(Contributor Covenant 2.1), issue forms and a pull request template. - The documentation site has a description, canonical URL and Open Graph tags
on every page, a favicon,
sitemap.xml,robots.txt, a social preview image, a client-side search (no third-party code), "Edit this page" links and Previous/Next links. Every page needs adescriptionindocs/site.toml; the strict build fails without one. - A "Recipes" section in the documentation: FastAPI (
TestClientandhttpx.AsyncClientwith dependency-overridden sessions), Flask-SQLAlchemy, SQLModel, Litestar, pytest-asyncio configuration pitfalls, budgets for hot endpoints, adopting the plugin on a legacy codebase and CI. A new example,examples/fastapi-dependency-override, is run by the test suite. - Missing-index check for MySQL and MariaDB, behind the same switch
(
queryguard_index_check,--queryguard-index-check). Each distinctSELECT,UPDATEandDELETEis explained withEXPLAINand the real bound parameters (neverANALYZE); a table read in full (type: ALL, or an index read in full on a column that leads no index) although the statement filters or joins on its columns is amissing-indexfinding with aCREATE INDEXhint and an Alembic call. The suggestion is not verified: neither server has hypothetical indexes. Tested with PyMySQL and aiomysql on MySQL 8.4 and MariaDB 11.8 on a developer machine; CI does not run a MySQL or MariaDB server. Seedocs/index-check-mysql.md.
Changed#
- Tests: an accuracy corpus under
tests/corpus: 110 small scenarios of realistic ORM code (views, serializers, FastAPI, Flask-SQLAlchemy, SQLModel, repositories, pagination, bulk writes, async), each marked BUG or CLEAN, run through the plugin in its default configuration and with each opt-in rule. Limitations it found are indocs/limitations.md;scripts/accuracy_report.pyprints the precision and recall.
Fixed#
- A lazy load or column load that autoflushed pending changes first was attributed to the flush's write: the UPDATE or DELETE carried the relationship name and the load's SELECT carried none, so one loop was also reported as a repeated statement. Writes no longer take the pending attribution (#59).
- Documentation: the limitations page, the FAQ and the README now say that a
raw
sqlite3or other DB-API connection and non-SQLAlchemy ORMs (Django ORM, Peewee, Tortoise ORM) are not seen, and that nothing reports it; the limitations page also points to the repeat-scope limits. - The SQLite index check no longer says a table "is read in full" when the
plan walks an existing index that the filter cannot use (
SCAN INDEX ix_...). It says the table scans the whole index and that the filter does not match the start of any index; the suggestedCREATE INDEXis unchanged. - A SARIF result merged from several tests no longer shows one test's count in
message.textnext to the total inproperties.count. The message now starts withSeen in N tests: T times in total, up to M times in one test, andpropertiesgainstestsandpeakCountfor such results.count, the fingerprints and the baseline identity are unchanged. Seedocs/sarif.md. - The
repeated-statementhint no longer says to fetch the rows withWHERE id IN (...)when the statement already usesIN (...). It says the batched query ran several times and should be called once, or the calls combined. - Added
queryguard_repeat_scope(testorsession, defaulttest) andQueryLog.assert_no_nplusone(repeat_scope=...)for false positives in tests that make several requests. The repeat, relationship, column-load and write-loop rules counted over the whole test, so a test that made several independent requests (a test client, for example, with oneSessionper request) reported the same authentication or organisation lookup as a loop. Withsession, eachSession, or each connection checkout for statements noSessionran, is counted on its own. The default is unchanged, but a finding whose count spans several Sessions or checkouts now says so and names the option. Seedocs/several-sessions.md. - SARIF locations are now relative to the repository root when
GITHUB_WORKSPACEis set and the pytest rootdir is inside it. Before, they were relative to the rootdir, so with the action'sworking-directoryset to a subdirectory GitHub created alerts on paths that do not exist and showed no pull request annotations. Fingerprints and baseline identities are unchanged. Seedocs/sarif.md. - The action skips the SARIF upload for runs triggered by Dependabot (read-only token), and a refused upload no longer fails the job.
0.4.0rc1 - 2026-10-12#
Release candidate: install it with pip install --pre pytest-queryguard.
This release adds baselines, SARIF output and a GitHub Action, per-finding
ignores, new finding kinds (column loads, write loops, unused eager loads), query
timing and max_db_time, an index advisor, and a missing-index check for SQLite.
It also adds a documentation site and runnable examples. Three things can change the
result or the output of an existing test run without any new option: the column-load
kind is on by default, the index advisor is on by default with the index check, and
the index check now also runs on SQLite. They are listed first under "Changed" as
behavior changes, with how to turn them off, as the pre-1.0 clause of the
stability policy requires.
Added#
Missing indexes
- Missing-index check for SQLite, behind the same switch as PostgreSQL
(
queryguard_index_check,--queryguard-index-check). Each distinctSELECT,WITH,UPDATEandDELETEin a test body is explained withEXPLAIN QUERY PLANand its real bound parameters; the statement is not run, so data and transaction are untouched. A scanned table that the statement filters or joins on, or an automatic index, is amissing-indexfinding with aCREATE INDEX ix_<table>_<columns> ON <table> (<columns>)hint.LIKE, views,ORDER BYwithout a filter, primary keys and unrecognized plan shapes are left alone. Works withsqlite3, aiosqlite, SQLAlchemy ORM and Core, and pytest-xdist; findings honorqueryguard_ignore,allow_missing_index,queryguard_index_ignore_tables, the baseline and SARIF. Seedocs/index-check-sqlite.md.scripts/bench_overhead.py --index-checktimes it. - Index advisor. Each missing-index finding now suggests an index: a
CREATE INDEXbuilt from the equality, range andORDER BYcolumns of the plan node, a partial index when a constant predicate is selective, and the Alembicop.create_indexcall. When the hypopg extension is installed, the suggestion is verified inside the check's savepoint: whether the planner would use the index and the statement's estimated cost before and after; hypothetical indexes are reset, also after errors, on sync drivers and asyncpg. No new index is suggested when the table already has one for these columns (invalid, partial, or valid but unused), and the finding says why. Without hypopg the suggestion stays and one notice says how to install it. New optionsqueryguard_index_advisor(defaulttrue, used with the index check) andqueryguard_hypopg(auto,onoroff). Findings carry the suggestion in an additiveadvicefield. Seedocs/index-advisor.md.
New findings and rules
- Column-load detection, a new finding kind
column-load. Instances that each load a deferred column, or whose attributes were expired bycommit(),expire()orexpire_all(), on their own are reported with the right advice:undefer(Model.column)(or removingdefer(),load_only()ordeferred()), orexpire_on_commit=False;session.refresh()in a loop gets its own hint. The ini optionqueryguard_column_load_threshold(default 5 instances) and thecolumn_load_thresholdargument ofQueryLog.assert_no_nplusone()set the limit. The kind works withqueryguard_ignoreandallow_nplusone. - Write-loop detection, opt in:
queryguard_write_loop_threshold(off by default) and thewrite_loop_thresholdargument ofQueryLog.assert_no_nplusone(). The sameINSERT,UPDATEorDELETEshape run row by row from one source line is reported as the new finding kindwrite-loop, with a hint to usesession.add_all(),executemanyor a bulk statement.executemanyand multi-rowVALUESstatements are not counted. The statements of one ORM flush count as one write, soadd_all()and oneflush()is not reported even where the dialect sends oneINSERTper row; aflush()orcommit()per row is. The flush is found through SQLAlchemy'sSession._flushframe; if that changes, the rule counts every statement. The kind works withqueryguard_ignore,allow_nplusoneandqueryguard_fail_on. - Experimental detection of unused eager loads, off by default:
queryguard_unused_eager_load(off,warnorfail). A relationship a test loads withselectinload(),joinedload(),subqueryload()orimmediateload()and never reads is reported as the new finding kindunused-eager-load(labelClass.relationship), with a hint to remove the option. While the option isoffnothing is installed. Opt out withallow_nplusone(kind="unused-eager-load")orqueryguard_ignore. Seedocs/unused-eager-load.mdfor how it works and its limits. queryguard_small_repeat_threshold, an ini option (off by default) and thesmall_repeat_thresholdargument ofQueryLog.assert_no_nplusone(): a statement that ran a few times (belowqueryguard_repeat_threshold) from one source line, each time with different parameters, is reported as a repeated statement. Identical parameters, runs from several lines, writes and pagination are left alone. The default threshold of 5 is unchanged, so a batch of two to four statements is not flagged unless this is turned on.
Adopting it on an existing project
- Baseline file for existing findings:
queryguard_baseline(ini) or--queryguard-baseline=PATH, and--queryguard-update-baselineto write it. A finding whose identity (kind, label, file and function; rootdir-relative paths, no line numbers or counts) is in the file does not fail or warn, for every finding kind. New findings are reported as before, and entries that matched nothing in the run are listed as stale baseline entries. The file is JSON with aversionfield, sorted for clean diffs; a missing, corrupt or unknown-version file is a usage error. Works with pytest-xdist: workers apply the file, the controller merges what they saw and writes it on an update. Seedocs/baseline.md. - SARIF output:
--queryguard-sarif=PATHwrites the findings of the run as SARIF 2.1.0 for GitHub code scanning (the--queryguard-reportflag is unchanged). One rule per finding kind with a fix hint, one result per finding identity with a rootdir-relative location,partialFingerprintsderived from the identity, anderrororwarninglevel followingqueryguard_fail_on. Ignored and baselined findings are left out. Works with pytest-xdist: workers send their findings to the controller, which writes the file once. Seedocs/sarif.md. - A composite GitHub Action in
action/: installs the plugin, runs pytest with--queryguard-sarif, uploads the file withgithub/codeql-action/upload-sarif(pinned by commit SHA) and fails the step when pytest fails. Inputs:pytest-args,python-version,package,baseline,fail,working-directory,sarif-file,upload,category. The upload is skipped for pull requests from forks. A CI job runs the action onexamples/action-sample. Seedocs/ci.md.
Ignoring and gating
queryguard_ignore, an ini option that leaves single findings out of the reports. One pattern per line,KIND LABEL [file=GLOB] [function=GLOB], matched withfnmatch. The kinds arerelationship-load,repeated-statement,column-load,write-loop,unused-eager-loadandmissing-index;fileis relative to the rootdir.allow_nplusonetakes label patterns and the keywordskind,fileandfunctionto ignore only the findings they match. Without arguments it still ignores the whole test.- Ignore patterns that matched no finding in the run are listed in the terminal summary as stale ignores (not with pytest-xdist).
queryguard_fail_on, an ini option with finding kinds (fnmatchpatterns, one per line). Infailmode only these kinds fail a test; the other kinds are reported asQueryGuardWarning. Empty means every kind fails, as before. Budgets are not affected.
Timing and budgets
- Per-statement timing.
Query.durationis the time in seconds the database and the driver took for a statement, measured around the cursor call;QueryLog.db_timeis the total andQueryLog.slowest(n)the slowest statements. max_db_time(seconds), a context manager, and themax_db_timemarker: fail when the statements take longer than the limit in total. The failure lists the slowest statements. Amax_queriesfailure now shows the total database time and its slowest statements as well.
Documentation and examples
- A documentation site built from
docs/(scripts/build_docs.py, with the newdocsdependency group: Markdown and Pygments): getting started, how detection works, a configuration reference that a test keeps in step with the options, FAQ, limitations, a comparison with other tools and a migration guide from nplusone. The README is now a short overview that links to it. examples/with runnable projects (sync N+1, async FastAPI, PostgreSQL and SQLite missing indexes, baseline and SARIF), each with a README and a smoke test in CI.
Changed#
Behavior changes
- The
column-loadfinding kind is on by default (threshold 5, seequeryguard_column_load_threshold). A test that loops over deferred columns, or over objects expired bycommit(), passed before and now fails infailmode. To keep such tests green while you fix them, use a baseline,queryguard_fail_onwithoutcolumn-load(it then only warns), orqueryguard_ignore = ["column-load *"]. - Those loops used to be reported as a
repeated-statementwith the wrong advice (WHERE id IN (...)); they are no longer counted by that rule, so a baseline or ignore entry for such a finding has to use the new kind. - The index advisor is on by default whenever the index check is on
(
queryguard_index_advisor = falseturns it off). Missing-index findings now carry afix: CREATE INDEX ...line and analembic:line, and without the hypopg extension one notice at the end of the session says how to install it (queryguard_hypopg = offsilences it). queryguard_index_checknow also checks SQLite. A project that turned the check on and ran its tests on SQLite used to get a "not checked" notice and now gets findings, which fail tests infailmode.- Durations below one millisecond are printed in microseconds (
37 us) instead of0.0 msin the report and in budget failures. - The hint for a lazy-loaded many-to-one or one-to-one relationship is now
joinedload(...); collections still getselectinload(...). - A test stores at most 10000 statements and 10000 relationship loads for its
reports (
queryguard_max_events,0for no limit). Past the limit they are only counted. - When a listener raises, the plugin still stops measuring so that it never breaks the suite, but it warns and prints a "queryguard: measurement stopped" section instead of staying silent (see Fixed).
Everything else is off by default and changes nothing until you turn it on:
queryguard_small_repeat_threshold, queryguard_write_loop_threshold,
queryguard_unused_eager_load, queryguard_baseline, queryguard_fail_on (empty:
every kind fails, as before) and --queryguard-sarif.
Internals
- The missing-index check reads the conditions of a plan node with a tokenizer
and parser (
pytest_queryguard.predicates) instead of regular expressions over the text. PostgreSQL's JSON plan has no structured form of a predicate, so the relation, alias, schema and index still come from the plan's fields and the condition text is parsed into a tree; the old text matching stays as the fallback for syntax the parser does not know. Findings do not change. The parsing is tested on realEXPLAIN (GENERIC_PLAN, VERBOSE, FORMAT JSON)plans captured from PostgreSQL 14 to 18 (scripts/capture_plans.py). - The measurement registers one more SQLAlchemy listener (
after_cursor_execute) to time statements.scripts/bench_overhead.pyputs the added cost of all the measurement, not of this listener alone, at about 20 to 55 microseconds per statement on in-memory SQLite (roughly +30 to +40 %); against a real database round trip it is small. See the FAQ. - Internal: detectors now hand their results to a stable
Findingmodel (kind,label,file,function,message,severity) before the report is rendered. The messages and the public API are unchanged. - The check that decides whether a stack frame belongs to SQLAlchemy, pytest or
the standard library is now cached per file name. Measured with
scripts/bench_overhead.py, this cuts the per-statement overhead of the measurement by about half.
Fixed#
- The index check no longer reports a missing index, and the advisor no longer
recommends a redundant one, for a statement that an existing partial index
may serve. The generic plan cannot see bound values, so it could not know
that
status = $1matches... WHERE status = 'open'. Such statements are skipped and counted in the session summary. A missing index on them is not found; see the limitations. - Loops over deferred or expired columns were reported as a repeated statement
with the wrong advice (
WHERE id IN (...)). They are now acolumn-loadfinding and no longer count towards the repeated-statement rule. - When a listener raises, the plugin still stops measuring so that it never
breaks the suite, but it no longer does so silently. The warning names the
listener or step that failed, and the end of the session prints a
"queryguard: measurement stopped" section with the listener, the error and
the line that raised it, once per session, with or without
--queryguard-report.
0.3.0 - 2026-10-11#
Added#
- The missing-index check now covers async engines on asyncpg. asyncpg
prepares statements, and PostgreSQL would take the
$ninsideEXPLAINas theEXPLAIN's own parameters. So on asyncpg theEXPLAINruns in a PL/pgSQLDOblock on the test's own connection and savepoint, and the plan comes back through a transaction-local setting. Findings, notices and guarantees are those of sync engines. Other async drivers are still skipped with a notice. Seedocs/design-0.3.md. - On a database without PL/pgSQL, the asyncpg index check now reports one
notice for the session instead of one failed
EXPLAINper statement. pytest_queryguard.record_queries(), a context manager that records the SQL statements a block sends and yields aQueryLog. The log has the statements asQueryobjects (sql,callsite,relationship), theircount, andassert_no_nplusone(), which applies the N+1 rules to the block and raisesNPlusOneDetected.Query,QueryLog,CallSiteandNPlusOneDetectedare exported for type hints. It works in pytest and outside it, and changes nothing else. AQueryLogis also sized, iterable and indexable (len(),for,log[i],log[a:b]) but not acollections.abc.Sequence.QueryGuardInternalWarning(exported since 0.2.0) is now documented. Arecord_queries()block that ends after an internal error (including one recorded before the block, which had already stopped the recording) issues it, saying that the log may be incomplete, andQueryLog.assert_no_nplusone()raisesRuntimeErrorinstead of passing on such a log. This also applies outside pytest and with--queryguard-mode=off, where the plugin itself reports nothing.
Changed#
- The index check reads the first column of each btree index and the
varcharcolumns of each table from the catalog once per engine for the whole session instead of once per test. Any statement that may change them (CREATE,DROP,ALTER,REINDEX,TRUNCATEand other DDL, seen in tests and fixtures alike) clears the cache, as does the end of a transaction that ran one. On 100 tests with 10 indexed statements each against PostgreSQL 17, the check went from about 2.3 ms to 1.2 ms per statement (#5). The same statement is explained again after such DDL, so a rolled-back index change no longer hides a finding. Clearing is safe while a plan is being read, also from another thread or when a connection is garbage collected, and when a two-phase transaction with DDL ends. A statement that cannot be planned is counted once in the "could not be explained" summary even when DDL made the check retry it. QueryLog.assert_no_nplusone()is documented as ignoring thequeryguard_*ini options: a project with a customqueryguard_repeat_thresholdpassesrepeat_thresholdexplicitly.- Paths in the
NPlusOneDetectedmessage, and in theQueryBudgetExceededmessage of awith max_queries(n):block, are relative to pytest's rootdir, as in the plugin's own failures, instead of the working directory (which is still the base outside pytest).
Fixed#
- The index check now judges temporary tables: trailing columns of their
multicolumn indexes and
varcharcasts were silently skipped, because PostgreSQL names the schemapg_tempin plans andpg_temp_<n>in the catalog. - The index check now reports a partial index read in full as a bitmap.
With an index such as
(id) WHERE deleted_at IS NULLandWHERE deleted_at IS NULL AND email = ?, the plan beforeANALYZEwas skipped, while the plan after it was reported (#4).
0.2.0 - 2026-10-11#
Added#
- Missing-index check for PostgreSQL 16 and newer, off by default. With
--queryguard-index-checkorqueryguard_index_check = true, each distinctSELECTin a test body is planned withEXPLAIN (GENERIC_PLAN, VERBOSE, FORMAT JSON)andenable_seqscanoff, inside a savepoint on the statement's own connection. The test fails (or warns, inwarnmode) when the plan reads a whole table to apply a filter, walks a whole index for an equality filter, or uses an index condition that leaves out a btree index's first column (PostgreSQL 16 and 17). The message names the table, the condition, the SQL and the call site and, when the column is obvious, gives aCREATE INDEXhint. --no-queryguard-index-checkto turn the check off for one run.queryguard_index_ignore_tablesini option and@pytest.mark.allow_missing_indexmarker to exclude small lookup tables or whole tests from the index check.- A session summary for statements the index check could not check: other databases, PostgreSQL before 16, async engines, statements PostgreSQL could not plan, and a check stopped by a broken savepoint.
- CI job that runs the test suite against PostgreSQL 15, 16 and 17.
Changed#
INlists whose placeholders carry multi-word, schema-qualified or quoted type casts ($1::DOUBLE PRECISION,$1::public.mood) or useCAST(? AS INTEGER)now collapse to one form for N+1 detection.
0.1.0 - 2026-10-11#
First release.
Added#
- Automatic N+1 detection for SQLAlchemy 2 in every test, with no
configuration. Two rules: one relationship lazy-loaded for several parent
objects, and one normalized
SELECTrepeated several times. - Failure messages that point to the line of application code that caused
the N+1, show the SQL involved and, for relationship loads, suggest a
selectinloadfix. @pytest.mark.max_queries(n)marker (alsomax_queries(n=...)) that fails a test running more thannSQL statements and lists every statement with its call site.pytest_queryguard.max_queries()context manager, which raisesQueryBudgetExceededand also works outside pytest.@pytest.mark.allow_nplusonemarker to skip N+1 detection for one test.- Ini options
queryguard_mode(fail,warn,off),queryguard_relationship_thresholdandqueryguard_repeat_threshold, readable from[tool.pytest.ini_options],pytest.ini,setup.cfgand pytest 9's native[tool.pytest]table. --queryguard-modecommand-line option that overrides the ini value.--queryguard-reportcommand-line option that prints the ten tests with the most queries at the end of the session.- Support for
AsyncSession, FastAPI'sTestClientand code that queries from other threads. - Only the test function body is measured; fixture setup and teardown are not.
- An internal error disables the plugin with a single warning instead of failing the test suite.