skip to content

In Django, why does Permit.objects.exclude(violations__paid=False, violations__issued_on__year=2026) not mean 'no unpaid 2026 violation', and what is the fix?

level: seniorimportance: should knowfreq 40%

answer

  1. same related row or not
  2. one filter call ties conditions
  3. exclude does not tie them
  4. exclude through __in subquery

basics

~20 s

On multi-valued relations, conditions in one exclude() need not refer to the same related row: Django excludes permits with any unpaid violation and any 2026 violation. To exclude permits with a single unpaid 2026 violation, use exclude(violations__in=Violation.objects.filter(paid=False, issued_on__year=2026)).

solid answer

~40 s

For a reverse ForeignKey or ManyToMany, Django gives `filter()` a special rule: conditions inside **one** `filter()` call must match the **same** related row, while conditions in chained `filter()` calls may match different rows. `exclude()` does not follow that rule; Django's docs say its conditions 'will not necessarily refer to the same item'. So `exclude(violations__paid=False, violations__issued_on__year=2026)` removes permits that have some unpaid violation and some 2026 violation, even if the unpaid one is from 2025, which is broader than intended. The fix is to name the related rows first: `Permit.objects.exclude(violations__in=Violation.objects.filter(paid=False, issued_on__year=2026))`, or `exclude(Exists(...))` with an `OuterRef`. Chained `filter()` calls through multi-valued relations can also duplicate permits, so add `.distinct()` where needed.

code

python · 21 lines
python
from django.db.models import Exists, OuterRef

from permits.models import Permit, Violation

# Too broad: removes permits with ANY unpaid violation AND ANY 2026 violation
loose = Permit.objects.exclude(
    violations__paid=False,
    violations__issued_on__year=2026,
)

# Intended: no single violation that is both unpaid and from 2026
unpaid_2026 = Violation.objects.filter(paid=False, issued_on__year=2026)
clean = Permit.objects.exclude(violations__in=unpaid_2026)

# Same meaning with a correlated EXISTS
clean_too = Permit.objects.exclude(
    Exists(unpaid_2026.filter(permit=OuterRef('pk'))),
)

# filter(): one call ties conditions to the same violation
flagged = Permit.objects.filter(violations__paid=False, violations__issued_on__year=2026).distinct()

go deeper

for a junior

Recall that filtering through a reverse relation like violations tests whether some related row matches, and can repeat the parent row.

for a middle

Explain the one-call versus chained-call rule for filter() and why distinct() is often needed after multi-valued joins.

for a senior

Recognise the documented exclude() exception, rewrite with an __in or Exists subquery, and prove it with a fixture covering the tricky case.

for a principal

Push teams toward explicit subqueries for negative conditions on related data, since silent semantic drift is costlier than slightly longer code.

## Single-valued versus multi-valued paths A lookup path such as `zone__code` follows a **single-valued** relation: each permit has one zone, so there is exactly one value to test. A path such as `violations__paid` follows a **multi-valued** relation, a reverse `ForeignKey` from `Violation` to `Permit` (or a `ManyToManyField`): one permit can have many violations, and a condition is really "some related row satisfies this". Combining several such conditions raises a question: must the **same** violation satisfy all of them? ## How filter() answers Django's documented rule for `filter()`: 1. Conditions in **one** `filter()` call on the same multi-valued relation must be satisfied by the **same related row**. `filter(violations__paid=False, violations__issued_on__year=2026)` finds permits with at least one violation that is both unpaid and from 2026. One join is used. 2. Conditions in **chained** `filter()` calls may be satisfied by **different** related rows. `filter(violations__paid=False).filter(violations__issued_on__year=2026)` finds permits with some unpaid violation and some 2026 violation. Each call adds its own join, so a permit can appear several times; `.distinct()` removes the duplicates. ## How exclude() answers `exclude()` is **not** implemented as the mirror image. The docs state that conditions in a single `exclude()` call "will not necessarily refer to the same item". For each condition on a multi-valued path Django builds a subquery, so `Permit.objects.exclude(violations__paid=False, violations__issued_on__year=2026)` removes permits that have **any** unpaid violation **and any** violation in 2026, possibly two different violations. A permit with an unpaid 2025 fine and a paid 2026 fine is excluded, although it has no unpaid 2026 violation. The query answers a different question from the one it seems to ask, and nothing errors. | Query | Permits returned | |---|---| | `filter(violations__paid=False, violations__issued_on__year=2026)` | with one violation both unpaid and from 2026 | | `filter(violations__paid=False).filter(violations__issued_on__year=2026)` | with some unpaid and some 2026 violation, duplicates possible | | `exclude(violations__paid=False, violations__issued_on__year=2026)` | without the combination "some unpaid" and "some 2026" | | `exclude(violations__in=Violation.objects.filter(paid=False, issued_on__year=2026))` | without any unpaid 2026 violation (the intended meaning) | ## Writing the intended query Describe the related rows you mean, then exclude permits linked to them: - **`__in` subquery.** `Permit.objects.exclude(violations__in=Violation.objects.filter(paid=False, issued_on__year=2026))`. This is the form Django's documentation gives. - **Correlated `Exists`.** `Permit.objects.exclude(Exists(Violation.objects.filter(permit=OuterRef('pk'), paid=False, issued_on__year=2026)))`, which many databases plan efficiently. - **Filter the other side.** `Permit.objects.exclude(pk__in=Violation.objects.filter(paid=False, issued_on__year=2026).values('permit'))`. All three express "no single violation with both properties". ## Why Django behaves this way For `filter()`, the rule "one call, one related row" maps naturally onto SQL: a single join to `violations` with both conditions in the `WHERE` clause. For `exclude()`, a join cannot express "the permit has no related row matching this", because excluding joined rows would just remove some violation rows and leave the permit in the result. Django therefore turns each condition on the multi-valued path into a `NOT IN (subquery)` style test on its own. Tying several conditions to the same related row would require combining them into one subquery, which Django does not do automatically, and the documentation records the difference instead of hiding it. ## Related traps worth naming - **`~Q(...)` inside `filter()`** goes through the same negation machinery as `exclude()`, so the same looseness applies to several negated conditions on one multi-valued path. - **Counting after chained filters**: annotations with `Count` over duplicated join rows inflate the numbers; that is an aggregation concern, but it starts with the extra joins described here. - **Mixing a positive and a negative condition** on the same relation, such as unpaid violations that are not from 2026, is clearest as one subquery of violations with both conditions, then `filter(violations__in=...)`. ## Checking and testing 1. Print `str(qs.query)` and read the subqueries: separate subqueries per condition signal the loose meaning. 2. Build a fixture with the tricky case, one permit with an unpaid 2025 violation and a paid 2026 violation, and assert which query returns it. 3. Remember the NULL side: permits with **no** violations at all are kept by all the `exclude()` forms above, which is usually what a clean-record search wants. ## Interview framing Interviewers ask this because it separates people who have read the query semantics from people who assume `exclude()` is `filter()` with a NOT in front. A strong answer states the rule for one `filter()` call, the different rule for chained calls, the documented `exclude()` exception, and the subquery fix.

  • Why can Permit.objects.filter(violations__paid=False) return the same permit several times?
    Filtering through a reverse ForeignKey joins `Violation`, producing one result row per matching violation. A permit with three unpaid violations appears three times. Add `.distinct()` when you need each permit once, or express the condition with `Exists(...)`, which tests for a match without multiplying rows.
  • Does the same trap apply to exclude() on a forward ForeignKey such as zone?
    No. Each permit has exactly one zone, so `exclude(zone__code='A', zone__city='Leeds')` can only test that one zone row, and the conditions necessarily refer to the same item. The divergence appears only on multi-valued paths: reverse foreign keys and many-to-many relations.

saying these in an interview costs you the question

  • exclude(a, b) always equals filter(a, b) negated
  • Chained filter() calls on a relation must match the same related row
  • Conditions in one exclude() call always refer to one related object
  • Filtering through a reverse ForeignKey never duplicates rows
  • The only fix is raw SQL