Performance#

What pytest-queryguard costs when it is on, how that was measured, and what was done to lower it. The numbers come from one laptop and one afternoon; treat them as an order of magnitude and run the scripts on your own suite.

What the plugin does per statement#

With measurement on, every statement passes through a few listeners:

Once per test the collected statements are normalized and grouped by the detectors. The missing-index check, the index advisor and eager-load tracking are off by default and cost nothing until turned on; their costs are in the FAQ.

Part of the cost is not the plugin's own code. Registering any listener on these events makes SQLAlchemy take its slower event-dispatch path for every statement. In the benchmark below, four listeners that do nothing add about 6 microseconds per Core statement and about 8 per ORM statement, before any plugin code runs. That part cannot be removed without giving up the events.

Method#

Absolute times on this machine drifted by up to a factor of two between sessions (the same micro-benchmark gave 41 and 71 microseconds per Core statement with measurement off on different days), so only numbers measured back to back are compared below.

Micro-benchmark: in-memory SQLite#

Added time per statement with measurement on, median of 15 runs, two rounds each (both rounds shown):

Statement Measurement off Before: added After: added
Core select by key 41 us +10.5 us (+25 %), +11.3 us (+28 %) +8.8 us (+21 %), +9.1 us (+22 %)
ORM select by primary key 76 us +38.1 us (+50 %), +37.0 us (+48 %) +30.0 us (+39 %), +31.9 us (+42 %)

The added cost fell by about 17 % in both loops. The percentages are large because an in-memory SQLite statement is almost free. In the same session a no-op listener floor of about +6 us (Core) and +8 us (ORM) is included in every "added" figure.

Where the plugin's own part goes, per statement, measured by timing the listeners in place: about 4 us in the before_cursor_execute listener (call site, event, bookkeeping), 2 us in do_orm_execute and under 1 us in the timer listener.

A realistic case: a suite of 300 tests#

Backend Wall clock, off Wall clock, on Added
File-based SQLite (local disk) 38.0 to 39.0 ms/test 37.9 to 39.6 ms/test between -0.3 % and +2.2 %
PostgreSQL 16 (local container) 12.6 to 13.8 ms/test 12.6 to 13.2 ms/test between -9 % and 0 %

These are four independent runs of seven repetitions for each backend, two against the code before and two after the changes. The differences are smaller than the run-to-run noise: single runs on the same checkout differed by 10 % or more (fsync on a laptop disk, the database container), and the plugin often came out "faster" than the run without it. The honest reading is that on a suite that does real database work the overhead is below what this setup can resolve, in the order of a few percent at most.

The CPU time of the test process (which leaves out waiting for the disk and the server) is less noisy and gives a rough upper figure: between -10 % and +7 % of the suite's CPU time, which is the same conclusion with a wider error bar.

A profile of the same suite (cProfile, 300 tests) puts the time spent in the plugin's own functions at about 0.2 s in total, around 0.6 ms per test with the profiler's own inflation, and that figure fell by roughly 10 % with the changes below.

Two limits of this measurement: the suite is small and made up for the benchmark, and the databases were on the same machine, so each statement was cheaper than it would be over a network. Over a network the plugin's share gets smaller, not larger.

What changed#

Behaviour is unchanged: the existing tests, the public API snapshot and the accuracy corpus give the same results, and each change has a regression test.

What did not help#

How the overhead grew before this#

The relative ORM overhead in the micro-benchmark rose from about 35 % to about 50 % between the overhead gate's first commit and 0.4.0rc2. There is no single commit behind it: each feature that looks at every statement added one to three microseconds (column-load detection, the ORM flush tag, the Session scope, the autoflush attribution), and an in-memory SQLite statement is so cheap that each step shows as a few percentage points. Run to run the same commit varies by about 5 points, so a bisect needs repeated, pinned runs.

Reproduce#

uv run python scripts/bench_overhead.py --statements 5000 --runs 15
uv run python scripts/bench_suite.py --runs 7
uv run python scripts/bench_suite.py --runs 7 --backend postgres \
    --url postgresql+pg8000://user:password@localhost:5432/bench

bench_suite.py --src measures another checkout, so two versions can be compared on the same machine; scripts/bench_gate.py is the CI check that guards the micro-benchmark ratio.

Edit this page on GitHub