<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mohammad Alsakka</title>
    <description>The latest articles on DEV Community by Mohammad Alsakka (@syrian963).</description>
    <link>https://dev.to/syrian963</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4118892%2Fb5947b42-bb3f-4487-96ef-ebb4fcd01940.png</url>
      <title>DEV Community: Mohammad Alsakka</title>
      <link>https://dev.to/syrian963</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/syrian963"/>
    <language>en</language>
    <item>
      <title>count() on a prefetched relation is free. filter() costs a query per row.</title>
      <dc:creator>Mohammad Alsakka</dc:creator>
      <pubDate>Thu, 10 Sep 2026 08:31:48 +0000</pubDate>
      <link>https://dev.to/syrian963/count-on-a-prefetched-relation-is-free-filter-costs-a-query-per-row-4acm</link>
      <guid>https://dev.to/syrian963/count-on-a-prefetched-relation-is-free-filter-costs-a-query-per-row-4acm</guid>
      <description>&lt;p&gt;&lt;em&gt;Twelve queries where you expected two, in a loop that looks like somebody&lt;br&gt;
already optimised it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Second of two posts from the same afternoon. The first, &lt;a href="https://dev.to/syrian963/djangos-exclude-does-not-drop-your-null-rows-4n6e"&gt;Django's .exclude() does not drop your NULL rows&lt;/a&gt;, is about a check I measured and did not build. This is the one that survived.&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;Here is a page that is slower than the version with no optimisation in it at&lt;br&gt;
all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prefetch_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lines&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With ten orders that is &lt;strong&gt;twelve queries&lt;/strong&gt;: one for the orders, one for the&lt;br&gt;
prefetch, and one per order because &lt;code&gt;.filter()&lt;/code&gt; cannot be answered from the&lt;br&gt;
cache the prefetch just filled.&lt;/p&gt;

&lt;p&gt;Delete the &lt;code&gt;prefetch_related&lt;/code&gt; and it is eleven. The prefetch is pure loss — an&lt;br&gt;
extra query, plus the memory to hold every line on the page, and not one row&lt;br&gt;
of it is read.&lt;/p&gt;

&lt;p&gt;What makes it survive code review is that it reads like care. Somebody thought&lt;br&gt;
about performance here. There is an eager load right there in the queryset.&lt;/p&gt;
&lt;h2&gt;
  
  
  Which accessors read the cache
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;prefetch_related&lt;/code&gt; fills a cache on each parent object, and the related&lt;br&gt;
manager hands it back — but only to the accessors that can use it. Ask it&lt;br&gt;
anything the cache cannot answer and it goes back to the database.&lt;/p&gt;

&lt;p&gt;I did not want to reason about which was which, so I counted. Ten parents,&lt;br&gt;
three children each, &lt;code&gt;CaptureQueriesContext&lt;/code&gt; around every variant, Django 6.1:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;written on a prefetched related manager&lt;/th&gt;
&lt;th&gt;queries&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.all()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.all()[0]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.all()[:2]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.count()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.exists()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.filter(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;one per parent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.exclude(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;one per parent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.order_by(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;one per parent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.first()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;one per parent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.last()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;one per parent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.only(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;one per parent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.defer(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;one per parent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.values(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;one per parent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.values_list(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;one per parent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.distinct()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;one per parent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;order.lines.select_related(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;one per parent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things in that table surprised me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.count()&lt;/code&gt; and &lt;code&gt;.exists()&lt;/code&gt; are free.&lt;/strong&gt; I had assumed they were the classic&lt;br&gt;
mistake — that you were supposed to write &lt;code&gt;len(order.lines.all())&lt;/code&gt; to use the&lt;br&gt;
cache. You are not. The related manager answers both from the prefetched&lt;br&gt;
result. Rewriting them changes nothing at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.first()&lt;/code&gt; is not free.&lt;/strong&gt; It looks like a cheap peek at data you already&lt;br&gt;
have in memory, and it is a fresh query with a &lt;code&gt;LIMIT 1&lt;/code&gt; on it. So is&lt;br&gt;
&lt;code&gt;.order_by()&lt;/code&gt;, which sorts a list that is already sitting in RAM by asking the&lt;br&gt;
database to sort it again, once per parent.&lt;/p&gt;

&lt;p&gt;The dividing line is not how expensive the operation sounds. It is whether the&lt;br&gt;
call can be answered from a list of already-fetched objects without changing&lt;br&gt;
the query — and &lt;code&gt;.filter()&lt;/code&gt;, &lt;code&gt;.order_by()&lt;/code&gt; and &lt;code&gt;.first()&lt;/code&gt; all change the&lt;br&gt;
query.&lt;/p&gt;

&lt;p&gt;I later re-ran the same fixture against Django 4.2, the oldest version still&lt;br&gt;
supported, and got an identical table. So this is not a recent behaviour to&lt;br&gt;
wait for or a legacy one to migrate off.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(A note on that: I originally wrote that &lt;code&gt;count&lt;/code&gt; and &lt;code&gt;exists&lt;/code&gt; had been&lt;br&gt;
cache-served "since Django 4.1". That was a recollection, not a measurement —&lt;br&gt;
the 4.1 release notes say nothing about it. I have measured 4.2 and 6.1 and&lt;br&gt;
they agree; which version it landed in, I do not know, and I have stopped&lt;br&gt;
claiming to.)&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Fixing it
&lt;/h2&gt;

&lt;p&gt;If the condition can move into the prefetch, move it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Prefetch&lt;/span&gt;

&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prefetch_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Prefetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lines&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;queryset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;OrderLine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
             &lt;span class="n"&gt;to_attr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active_lines&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# a plain list, no query
&lt;/span&gt;        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two queries, total, for any number of orders.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;to_attr&lt;/code&gt; matters more than it looks. It puts the rows on a new attribute and&lt;br&gt;
leaves &lt;code&gt;order.lines&lt;/code&gt; doing exactly what it did before — so a later&lt;br&gt;
&lt;code&gt;order.lines.filter(...)&lt;/code&gt; is an ordinary query with no prefetch behind it,&lt;br&gt;
rather than a prefetch being thrown away. Without &lt;code&gt;to_attr&lt;/code&gt; the filtered&lt;br&gt;
prefetch overwrites the default cache for that relation, which is fine until&lt;br&gt;
some other code on the page wanted all the lines and now silently gets the&lt;br&gt;
active ones.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;.order_by()&lt;/code&gt; and &lt;code&gt;.first()&lt;/code&gt; there is usually no need for a &lt;code&gt;Prefetch&lt;/code&gt; at&lt;br&gt;
all. The rows are already in memory; sort them there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;newest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if the accessor really does need something the cache cannot give — a&lt;br&gt;
different filter each time round the loop, say — then the honest fix is to&lt;br&gt;
&lt;strong&gt;delete the prefetch&lt;/strong&gt;. It is not helping. Paying for it and re-querying is&lt;br&gt;
the worst of both.&lt;/p&gt;
&lt;h2&gt;
  
  
  How often does this actually happen
&lt;/h2&gt;

&lt;p&gt;I turned it into a static check, and the first version was wrong in an&lt;br&gt;
instructive way.&lt;/p&gt;

&lt;p&gt;The obvious implementation is to find the relations a file prefetches, then&lt;br&gt;
find accessors on those names in the same file. Across those same nine&lt;br&gt;
projects that found &lt;strong&gt;79 sites&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I read five of them by hand. &lt;strong&gt;One was a real defect. Four were coincidence&lt;/strong&gt; —&lt;br&gt;
the same relation name on completely unrelated objects, because &lt;code&gt;lines&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;items&lt;/code&gt; and &lt;code&gt;findings&lt;/code&gt; are names that appear in a large codebase more than&lt;br&gt;
once. In one Saleor file, a name bound inside one function matched an accessor&lt;br&gt;
a hundred lines away in a different function.&lt;/p&gt;

&lt;p&gt;So I narrowed it: the prefetch and the accessor have to be provably the same&lt;br&gt;
object — a name bound in the same scope, or the loop variable iterating one.&lt;br&gt;
That finds &lt;strong&gt;7 across the same nine projects&lt;/strong&gt;, and I read and confirmed all&lt;br&gt;
seven:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wagtail&lt;/strong&gt; — &lt;code&gt;wagtail/admin/views/pages/edit.py:207&lt;/code&gt;, &lt;code&gt;values_list()&lt;/code&gt; on a
prefetched &lt;code&gt;comment_replies&lt;/code&gt; inside a comprehension over the prefetched
queryset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Saleor&lt;/strong&gt; — three in &lt;code&gt;graphql/meta/permissions.py&lt;/code&gt;, &lt;code&gt;.filter()&lt;/code&gt; on a
&lt;code&gt;user_addresses&lt;/code&gt; prefetched two lines earlier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Saleor&lt;/strong&gt; — &lt;code&gt;product_variant_delete.py:112&lt;/code&gt;. This is my favourite, because
the comment directly above it says &lt;em&gt;"Get cached variant with related
fields"&lt;/em&gt; and the next line is &lt;code&gt;variant.channel_listings.all().values_list(...)&lt;/code&gt;,
which throws the cache away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pretix&lt;/strong&gt; — &lt;code&gt;base/services/invoices.py:277&lt;/code&gt;, &lt;code&gt;p.answers.filter(...)&lt;/code&gt; inside
a loop over positions prefetched with &lt;code&gt;answers&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DefectDojo&lt;/strong&gt; — &lt;code&gt;dojo/jira/helper.py:868&lt;/code&gt;,
&lt;code&gt;finding_group.findings.filter(...)&lt;/code&gt; seven lines after
&lt;code&gt;Finding_Group.objects.prefetch_related("findings")&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Seven in nine mature projects is not an epidemic. It is also not nothing, and&lt;br&gt;
every one of them costs a query per row on a page somebody loads.&lt;/p&gt;

&lt;p&gt;The narrowing is the part I would keep. &lt;strong&gt;79 findings that are 80% wrong is&lt;br&gt;
worse than 7 that are right&lt;/strong&gt;, because the first number gets the tool switched&lt;br&gt;
off and takes the seven real ones with it.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the cache does not survive
&lt;/h2&gt;

&lt;p&gt;Worth knowing regardless of tooling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anything that changes the query: &lt;code&gt;.filter()&lt;/code&gt;, &lt;code&gt;.exclude()&lt;/code&gt;, &lt;code&gt;.order_by()&lt;/code&gt;,
&lt;code&gt;.only()&lt;/code&gt;, &lt;code&gt;.values_list()&lt;/code&gt;, and friends.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.first()&lt;/code&gt; and &lt;code&gt;.last()&lt;/code&gt;, which feel like indexing and are not.&lt;/li&gt;
&lt;li&gt;A second &lt;code&gt;.prefetch_related()&lt;/code&gt; on the related manager.&lt;/li&gt;
&lt;li&gt;Templates are fine: &lt;code&gt;{% for line in order.lines.all %}&lt;/code&gt; reads the cache, and
a template cannot call &lt;code&gt;.filter()&lt;/code&gt; with arguments anyway.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The check
&lt;/h2&gt;

&lt;p&gt;This is one of the checks in&lt;br&gt;
&lt;a href="https://github.com/syrian963/django-chainsaw-mcp" rel="noopener noreferrer"&gt;django-chainsaw-mcp&lt;/a&gt;, a&lt;br&gt;
static analyser for the Django questions that stop a deploy. It runs as a CLI&lt;br&gt;
and as an MCP server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;django-chainsaw prefetch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reports &lt;code&gt;.filter()&lt;/code&gt; and the rest, stays silent on &lt;code&gt;.count()&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;.exists()&lt;/code&gt; because the fix would change nothing, and only speaks where it can&lt;br&gt;
prove the prefetch and the accessor are the same object.&lt;br&gt;
&lt;a href="https://github.com/syrian963/django-chainsaw-mcp/blob/main/docs/prefetch.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/prefetch.md&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
has the full table and the blind spots.&lt;/p&gt;

&lt;p&gt;The tool is written largely with Claude; &lt;code&gt;docs/authorship.md&lt;/code&gt; sets out which&lt;br&gt;
parts, and the measuring and the narrowing from 79 to 7 are the parts I did.&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Django's .exclude() does not drop your NULL rows</title>
      <dc:creator>Mohammad Alsakka</dc:creator>
      <pubDate>Thu, 10 Sep 2026 08:29:28 +0000</pubDate>
      <link>https://dev.to/syrian963/djangos-exclude-does-not-drop-your-null-rows-4n6e</link>
      <guid>https://dev.to/syrian963/djangos-exclude-does-not-drop-your-null-rows-4n6e</guid>
      <description>&lt;p&gt;&lt;em&gt;I spent an afternoon measuring a bug that does not exist. Here is the&lt;br&gt;
measurement, and the rule I should have applied an hour earlier.&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;I was adding a check to a static analyser for Django, and I had what felt like&lt;br&gt;
a good candidate.&lt;/p&gt;

&lt;p&gt;Take a nullable column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;null&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the query everybody writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that out loud and it means &lt;em&gt;everything that is not done&lt;/em&gt;. Now think about&lt;br&gt;
what the database does with a row whose &lt;code&gt;status&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt;. In SQL,&lt;br&gt;
&lt;code&gt;NULL = 'done'&lt;/code&gt; is not false. It is &lt;code&gt;NULL&lt;/code&gt;. And &lt;code&gt;NOT NULL&lt;/code&gt; is still &lt;code&gt;NULL&lt;/code&gt;,&lt;br&gt;
which is not true, and a &lt;code&gt;WHERE&lt;/code&gt; clause keeps a row only when its condition is&lt;br&gt;
true.&lt;/p&gt;

&lt;p&gt;So the row disappears. Not excluded because it is done — excluded because the&lt;br&gt;
database does not know whether it is done, and silently declines to guess.&lt;/p&gt;

&lt;p&gt;This is real. It is the classic three-valued-logic trap, it is in every&lt;br&gt;
database textbook, and it bites people in raw SQL constantly. My check would&lt;br&gt;
find every &lt;code&gt;.exclude(field=value)&lt;/code&gt; where &lt;code&gt;field&lt;/code&gt; is nullable and say: &lt;em&gt;these&lt;br&gt;
rows are vanishing and nobody told you.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The prevalence looked excellent
&lt;/h2&gt;

&lt;p&gt;The bar in that repository is that you measure before you build, so I did. I&lt;br&gt;
cloned nine large open-source Django projects — Wagtail, Saleor,&lt;br&gt;
django-oscar, Misago, Weblate, NetBox, pretix, DefectDojo, Read the Docs —&lt;br&gt;
and counted &lt;code&gt;.exclude()&lt;/code&gt; calls with a plain keyword argument, skipping&lt;br&gt;
anything already using &lt;code&gt;__isnull&lt;/code&gt; because that author was clearly thinking&lt;br&gt;
about NULLs already.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;742 call sites. 588 of them outside test code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is a lot. For comparison, a different candidate I had measured that same&lt;br&gt;
morning — a &lt;code&gt;Meta.ordering&lt;/code&gt; that spans a relation, which forces a JOIN on&lt;br&gt;
every query including &lt;code&gt;.count()&lt;/code&gt; and &lt;code&gt;.exists()&lt;/code&gt; — appeared &lt;strong&gt;6 times in 562&lt;br&gt;
orderings&lt;/strong&gt;. I dropped that one for being too rare to be worth a check.&lt;/p&gt;

&lt;p&gt;588 is not too rare. 588 is a check that fires on nearly every project it&lt;br&gt;
touches.&lt;/p&gt;
&lt;h2&gt;
  
  
  Then I checked the prior art, and it disagreed with me
&lt;/h2&gt;

&lt;p&gt;Before writing anything I went looking for whoever had already solved this.&lt;br&gt;
What I found instead was a page asserting, in passing, that Django's ORM "is&lt;br&gt;
generally smart enough to include rows where the status is NULL."&lt;/p&gt;

&lt;p&gt;That is the opposite of my premise.&lt;/p&gt;

&lt;p&gt;One of us was wrong, and I could not tell which from reading. A blog post is&lt;br&gt;
not evidence and neither is my recollection of a textbook. The answer was in&lt;br&gt;
the SQL Django actually generates, which is twenty lines away from anyone who&lt;br&gt;
wants it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Twenty lines
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;django&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.conf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;

&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;INSTALLED_APPS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;django.contrib.contenttypes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;DATABASES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ENGINE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;django.db.backends.sqlite3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                           &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:memory:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;django&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Thing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;null&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;app_label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contenttypes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schema_editor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Thing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;Thing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;Thing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;open&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;Thing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;c&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# the row in question
&lt;/span&gt;
&lt;span class="n"&gt;qs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Thing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;note&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flat&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"contenttypes_thing"&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"contenttypes_thing"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"status"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;
           &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"contenttypes_thing"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"status"&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'b'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'c'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;c&lt;/code&gt; is there. The NULL row survives.&lt;/p&gt;

&lt;p&gt;Look at the &lt;code&gt;WHERE&lt;/code&gt; clause and you can see exactly why. Django did not write&lt;br&gt;
&lt;code&gt;NOT (status = 'done')&lt;/code&gt;. It wrote &lt;code&gt;NOT (status = 'done' AND status IS NOT&lt;br&gt;
NULL)&lt;/code&gt;. For the NULL row the inner expression is &lt;code&gt;NULL AND FALSE&lt;/code&gt;, which is&lt;br&gt;
&lt;code&gt;FALSE&lt;/code&gt;, and &lt;code&gt;NOT FALSE&lt;/code&gt; is &lt;code&gt;TRUE&lt;/code&gt;. The row is kept.&lt;/p&gt;

&lt;p&gt;Django put the guard in. My premise was about SQL, and it was correct about&lt;br&gt;
SQL — but I was not writing SQL, and the ORM had already thought about this.&lt;/p&gt;
&lt;h2&gt;
  
  
  It is not one lucky path
&lt;/h2&gt;

&lt;p&gt;Three shapes, same result:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;keeps the NULL row&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exclude(status="done")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;filter(~Q(status="done"))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exclude(status__in=["done"])&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the guard is not applied blindly. Same model, one nullable field and one&lt;br&gt;
that is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nc"&gt;NOT &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nullable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="n"&gt;AND&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nullable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="n"&gt;IS&lt;/span&gt; &lt;span class="n"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nc"&gt;NOT &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Django adds the &lt;code&gt;IS NOT NULL&lt;/code&gt; only where a NULL is possible. It is not&lt;br&gt;
defensive boilerplate; the ORM knows which columns need it.&lt;/p&gt;
&lt;h2&gt;
  
  
  While I was there: the multi-valued case
&lt;/h2&gt;

&lt;p&gt;There is a neighbouring warning that gets repeated a lot — that &lt;code&gt;filter()&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;exclude()&lt;/code&gt; stop being mirror images once you cross a multi-valued relation.&lt;br&gt;
I had half-expected to find a second check hiding there, so I measured that&lt;br&gt;
too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;Parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;children__tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;red&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with four parents: one with a red child, one with red and blue, one with only&lt;br&gt;
blue, one with no children at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;result:            ['no_children', 'only_blue']
naive expectation: ['no_children', 'only_blue']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical. The generated SQL is a &lt;code&gt;NOT EXISTS&lt;/code&gt; subquery, which expresses&lt;br&gt;
&lt;em&gt;parents with no red child&lt;/em&gt; precisely, including the childless one.&lt;/p&gt;

&lt;p&gt;There is a genuine subtlety in this area about chaining and about what&lt;br&gt;
&lt;code&gt;filter()&lt;/code&gt; does across multiple calls, but the scary version of the warning —&lt;br&gt;
that &lt;code&gt;exclude()&lt;/code&gt; across a relation quietly returns nonsense — is not what&lt;br&gt;
modern Django does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did with the 588
&lt;/h2&gt;

&lt;p&gt;Nothing. There is no check.&lt;/p&gt;

&lt;p&gt;Had I built it on the strength of the prevalence number, it would have opened&lt;br&gt;
&lt;strong&gt;588 findings, every one of them wrong&lt;/strong&gt;, in nine of the most carefully&lt;br&gt;
maintained Django codebases in existence. And the worst part is that it would&lt;br&gt;
have looked authoritative. Each finding would have quoted a real line, named a&lt;br&gt;
real nullable field, and explained a real property of SQL. A reviewer without&lt;br&gt;
a SQLite shell open would have believed every word.&lt;/p&gt;

&lt;p&gt;Static analysis does not fail by crashing. It fails by being confidently&lt;br&gt;
wrong, and a plausible sentence that sends someone in the wrong direction&lt;br&gt;
costs more than no sentence at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;I had two numbers that afternoon: &lt;strong&gt;6 out of 562&lt;/strong&gt;, and &lt;strong&gt;588&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I rejected the first for being too rare, which was the easy call. I nearly&lt;br&gt;
accepted the second because it was large — and prevalence measures how often&lt;br&gt;
your &lt;em&gt;pattern&lt;/em&gt; appears, not whether it is a &lt;em&gt;defect&lt;/em&gt;. They are separate&lt;br&gt;
questions and only one of them was answered.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Prevalence is not permission. Measure that the thing is real before you&lt;br&gt;
measure how often it happens.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Twenty lines and about four minutes. That is the entire cost of finding out.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write and maintain&lt;br&gt;
&lt;a href="https://github.com/syrian963/django-chainsaw-mcp" rel="noopener noreferrer"&gt;django-chainsaw-mcp&lt;/a&gt;, a&lt;br&gt;
static analyser for the Django questions that stop a deploy. The check that&lt;br&gt;
did get built out of that afternoon is in the next post. The tool is written&lt;br&gt;
largely with Claude — &lt;code&gt;docs/authorship.md&lt;/code&gt; sets out which parts, and the&lt;br&gt;
deciding, rejecting and measuring are the parts I do.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>sql</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
