Unused eager loads (experimental)#

An eager load that nothing reads costs a join or an extra query for no benefit:

rows = session.scalars(
    select(Customer).options(selectinload(Customer.orders))
).all()
names = [c.name for c in rows]  # Customer.orders is never read

Set queryguard_unused_eager_load to turn the check on:

Value Effect
off (default) Nothing is installed; there is no cost.
warn A QueryGuardWarning; the test passes.
fail The test fails, in fail mode, unless queryguard_fail_on leaves the kind out.

queryguard_mode = off turns this check off too. The finding has the kind unused-eager-load, the label Customer.orders, and the file and function of the query that eager-loaded it.

Opting out#

The kind works with the usual mechanisms:

@pytest.mark.allow_nplusone(kind="unused-eager-load")
def test_export(session): ...

@pytest.mark.allow_nplusone("Customer.orders")  # one relationship
def test_report(session): ...

or unused-eager-load Customer.orders function=load_customers in queryguard_ignore.

How it decides#

While the option is on, the plugin does two things inside the body of each test (fixtures and teardown are not measured):

  1. A do_orm_execute listener reads the loader options of every top-level ORM SELECT and notes each relationship loaded with selectinload, joinedload, subqueryload or immediateload, and the query's call site.
  2. InstrumentedAttribute.__get__ is wrapped, and every attribute read on a mapped instance is noted as (class, name). Reads by the unit of work (flush, cascades, backrefs) do not go through it.

A relationship is reported when all of these hold:

Why this approach#

Approach Verdict
Attribute events (set, init_scalar, modified) A read of a loaded attribute fires no event. Not usable.
Inspect InstanceState (unloaded, dict) at the end Shows what is loaded, not what was read. Needs every instance tracked, with weak references.
Wrap InstrumentedAttribute.__get__ while enabled (chosen) One set insertion per read, only while a test body runs; works the same for sync and async sessions (the read happens in plain Python). It is a patch of a SQLAlchemy class, applied only when the option is on and removed at the end of the session.
Per-class descriptor replacement The same cost with more moving parts (mappers configured later, inheritance).

Measured with scripts/bench_eager_overhead.py (CPython 3.13, SQLAlchemy 2.1, SQLite in memory, median of 7 runs, one machine):

Mode ns per attribute read us per ORM SELECT with selectinload
option off (default) 171 653
on, outside a test body 213 679
on, inside a test body 322 711

With the option off, scripts/bench_overhead.py shows the same overhead as before the change (Core about 30 %, ORM about 40 %, within run-to-run noise).

Limitations#

The check is conservative on purpose (when unsure it reports nothing) but it can still be wrong.

Edit this page on GitHub