v0.1.4 — a prefetch paid for and then thrown away
[0.1.4] - 2026-09-09
Added
-
defeated_prefetches: a relation that was prefetched and then re-queried
anyway.prefetch_relatedfills a cache on each parent object, and the
related manager hands it back only to the accessors that can read it. Ask it
anything else and it goes to the database once per parent, with the prefetch
query already paid for on top - so the loop costs more than it would have
with no prefetch at all, while reading like an optimisation.Which accessors, measured rather than assumed: ten parents with three
children each, queries counted withCaptureQueriesContexton Django 6.1.
.filter(),.exclude(),.order_by(),.first(),.last(),.only(),
.defer(),.values(),.values_list(),.distinct(),
.select_related(),.annotate()and.reverse()each cost 12 queries
where the cache costs 2..count(),.exists(),.all()and a slice cost
2 - the related manager has answeredcountandexistsfrom the prefetch
since Django 4.1, which is not obvious, and reporting them would be a
finding whose fix changes nothing. Only the first group is reported, and
every method in it re-queries on earlier Django versions too.Reported only where the prefetch and the accessor are provably the same
object: a name bound in the same scope, or the loop variable iterating one.
That boundary was measured too. Matching on the relation name anywhere in
the same file found 79 sites across nine large projects; five were read by
hand and four of the five were coincidence - the same relation name,
unrelated objects. The scope-local rule finds 7 in the same nine projects,
and all 7 were read and confirmed: Wagtail, Saleor (four, one of them under
a comment that says "get cached variant with related fields"), pretix and
DefectDojo. A prefetch in a view and the accessor in a template tag is the
same defect and is not reported, because the fix is to delete or rewrite a
line and a guess is not a good enough reason to suggest that.nplusonefinds the neighbouring problem - eagerly loaded and never touched- at runtime, and
unused_eager_loadinghere answers that one statically
for DRF viewsets. Neither answers this one, where the prefetch is used and
the accessor cannot read it.
- at runtime, and
Fixed
-
The check found the same relation name in a different function and called
it one object. The first version walked the module body into the functions
it contained, so a name bound in one function matched an accessor in
another. Saleor has exactly that pair a hundred lines apart, and it was
reported as a finding. Each function is now scanned as a scope of its own.
The cost is a name a nested function closes over, which is the right trade:
a missed finding costs less than a confident wrong one. -
Three of its own tests asserted on line numbers that were off by one or
two, so they passed without ever reaching the code they were written to
guard. Found by reverting each guard in turn and watching which tests failed- two did not. The tests now key on the enclosing function name instead, and
all five go red when their guard is removed.
- two did not. The tests now key on the enclosing function name instead, and
-
The server introduced itself as 0.1.0 for three releases. The version was
written out by hand in three places and only one of them was ever updated, so
a client asking the server who it was got an answer that had been wrong since
the first tag. There is now one__version__, read from the installed
package metadata, and a documentation gate that fails when a version is
spelled out anywhere it can be derived. -
The local runner did not lint, so CI failed on an unused import.
Seventeen suites green here, three jobs red on GitHub nine seconds in, over
one leftoverfrom pathlib import Path. CI runsruff check src/ tests/as
its first step andrun_tests.shnever did. A local runner that passes what
the pipeline fails is worse than no local runner, because it is trusted; it
now runs the same invocation, first.
Changed
- The README stopped promising an invocation that cannot work. It offered
uvx django-chainsaw-mcp, anduvxbuilds an isolated environment - which
is exactly what a tool that has to import your project cannot use. It starts
and then fails every check. The install section now says plainly that it must
run in an interpreter that can import the project, gives the two ways to do
that, and a documentation gate rejects a bareuvxinvocation in a code
fence.