skip to content

In Django's ORM, how do you express OR and NOT conditions with Q objects, and how do they combine with keyword arguments in filter()?

level: middleimportance: must knowfreq 65%

answer

  1. keyword arguments only AND
  2. pipe, ampersand, tilde, caret
  3. positional before keyword
  4. Python operator precedence applies

basics

~20 s

Keyword arguments in filter() are always ANDed, so OR and NOT need Q objects: | for OR, ~ for NOT, & for AND, ^ for XOR. Q objects are passed positionally, before any keyword arguments, and are ANDed with them.

solid answer

~40 s

A `Q` object wraps lookups so they can be combined: `Q(zone__code='A') | Q(zone__code='B')` is an OR, `~Q(status='expired')` is a NOT, `&` is AND and `^` is XOR. You pass them positionally to `filter()`, `exclude()` or `get()`, and they are ANDed with each other and with any keyword arguments, which must come after them because of Python's call syntax: `Permit.objects.filter(Q(valid_until__isnull=True) | Q(valid_until__gte=today), status='active')`. Python's operator precedence applies, `~` before `&` before `^` before `|`, so parenthesise mixed expressions. Negation keeps Python-like NULL handling: `~Q(valid_until__lt=today)` also returns permits whose `valid_until` is NULL. For a list of alternatives on one field, `__in` is simpler than chained ORs.

code

python · 19 lines
python
from django.db.models import Q
from django.utils import timezone

from permits.models import Permit

today = timezone.localdate()

valid_in_a_or_b = Permit.objects.filter(
    Q(zone__code='A') | Q(zone__code='B'),
    Q(valid_until__isnull=True) | Q(valid_until__gte=today),
    status=Permit.Status.ACTIVE,        # keyword arguments come last
)

not_expired = Permit.objects.filter(~Q(status=Permit.Status.EXPIRED))

# Precedence: a OR (b AND c)
mixed = Q(zone__code='A') | Q(zone__code='B') & Q(status='active')
# Intended: (a OR b) AND c
clear = (Q(zone__code='A') | Q(zone__code='B')) & Q(status='active')

go deeper

for a junior

Recall that filter() keywords are ANDed and that Q objects with |, & and ~ are how you write OR and NOT.

for a middle

Explain positional-before-keyword ordering, operator precedence and the IS NOT NULL Django adds when negating nullable lookups.

for a senior

Review complex Q trees for precedence bugs and NULL semantics, and prefer __in or separate queries when they are clearer or cheaper.

for a principal

Decide how far ad-hoc Q composition should spread before search logic deserves a dedicated query layer or search engine.

## Why Q exists In Django's ORM, keyword arguments to `filter()` express only conjunction: `filter(status='active', zone__code='A')` means `status = 'active' AND zone.code = 'A'`. Chaining `.filter()` calls also ANDs. To say **OR** or **NOT** at arbitrary places, Django provides `django.db.models.Q`, an object that holds one or more lookups and can be combined with operators into a tree that becomes the `WHERE` clause. ## The operators | Operator | Meaning | Example | |---|---|---| | `\|` | OR | `Q(zone__code='A') \| Q(zone__code='B')` | | `&` | AND | `Q(status='active') & Q(valid_until__isnull=True)` | | `~` | NOT | `~Q(status='expired')` | | `^` | XOR | `Q(paid=True) ^ Q(waived=True)` | Each operator returns a **new** `Q`; the operands are not modified. Inside one `Q(a=1, b=2)`, the keyword arguments are ANDed, just as in `filter()`. `^` arrived in Django 4.1. MariaDB and MySQL support `XOR` natively; on other databases Django emulates it, and since Django 5.0 the emulation means "an odd number of the conditions are true", matching the native behaviour for more than two operands. ## Passing Q objects to filter() 1. Q objects are **positional** arguments: `filter(Q(...), Q(...))`. Several positional Q objects are ANDed together. 2. Keyword arguments may follow and are ANDed with the Q objects: `filter(Q(...) | Q(...), status='active')`. 3. Positional arguments must come **before** keyword arguments; `filter(status='active', Q(...))` is a Python `SyntaxError`, not a Django error. 4. The same applies to `exclude()` and `get()`. ## Precedence and grouping The operators keep Python's precedence: `~` binds tightest, then `&`, then `^`, then `|`. So - `Q(a) | Q(b) & Q(c)` means `a OR (b AND c)`; - `(Q(a) | Q(b)) & Q(c)` means `(a OR b) AND c`. Always add parentheses when mixing operators; reviewers should not need to recall the table. ## Negation and NULL SQL's three-valued logic makes `NOT (valid_until < '2026-09-26')` **unknown** for a NULL `valid_until`, which would drop open-ended permits. Django compensates: when a negated lookup touches a nullable column, it adds `valid_until IS NOT NULL` inside the `NOT`, producing `NOT (valid_until < '2026-09-26' AND valid_until IS NOT NULL)`. The result matches Python intuition: a permit with no end date is "not expiring before today" and is returned. `exclude(valid_until__lt=today)` behaves the same way. ## Q versus alternatives - **Same field, several values**: `zone__code__in=['A', 'B']` is shorter and clearer than two ORed `Q` objects. - **Negating a whole condition**: `exclude(...)` and `filter(~Q(...))` are equivalent for single-valued fields; on multi-valued relations they can differ subtly, so test such queries. - **Combining whole QuerySets**: `qs1 | qs2` and `qs1 & qs2` combine the `WHERE` clauses of two QuerySets on the same model; `Q` is usually clearer because the logic stays in one `filter()`. ## Reading the generated SQL The fastest way to check a complex `Q` tree is to print the query: 1. Build the QuerySet without evaluating it. 2. Run `print(qs.query)` and read the `WHERE` clause; Django adds parentheses exactly where the `Q` tree has nodes. 3. Compare it with the sentence you meant, especially around `NOT` and nullable columns. For `filter(~Q(status='expired'))` on a non-nullable `status`, the clause is simply `NOT (status = 'expired')`. For a nullable column the extra `IS NOT NULL` appears inside the `NOT`, which is the documented NULL handling at work. ## Mistakes that pass tests - **Missing parentheses** around ORed alternatives before an `&`; tests that only use one zone never notice. - **Using `~Q` where `exclude()` on a multi-valued relation was meant**, or the reverse, without a fixture that separates the two meanings. - **ORing on different relations** such as `Q(violations__paid=False) | Q(zone__code='A')`: the join to violations can turn an inner join into an outer join and duplicate permits, so check whether `.distinct()` is needed. ## A worked permit search "Active permits in zone A or zone B that are either open-ended or valid through today": - zones: `Q(zone__code='A') | Q(zone__code='B')`, or `zone__code__in=['A', 'B']`; - validity: `Q(valid_until__isnull=True) | Q(valid_until__gte=today)`; - status as a keyword argument after the Q objects. Two positional Q objects plus one keyword argument give `(zone OR zone) AND (open-ended OR not yet expired) AND status = 'active'`.

  • Does ~Q(valid_until__lt=today) return permits whose valid_until is NULL?
    Yes. For a nullable column Django adds `IS NOT NULL` inside the negation, producing `NOT (valid_until < today AND valid_until IS NOT NULL)`, so NULL rows satisfy the negated condition. Without that, SQL's three-valued logic would silently drop open-ended permits. `exclude(valid_until__lt=today)` produces the same SQL.
  • What is the difference between filter(Q(a) | Q(b)) and filter(a).union(filter(b))?
    `Q(a) | Q(b)` is one query with an `OR` in the `WHERE` clause, and the result is a normal QuerySet you can keep filtering. `union()` runs a SQL `UNION` of two queries, removes duplicates by default, and returns a combined QuerySet on which further `filter()` calls are not supported. For conditions on one model, the `Q` form is simpler and usually cheaper.

saying these in an interview costs you the question

  • Keyword arguments can be ORed by passing or_=True
  • Q objects may follow keyword arguments in filter()
  • Q(a) | Q(b) & Q(c) means (a OR b) AND c
  • ~Q(valid_until__lt=today) drops permits with NULL valid_until
  • Q objects are evaluated in Python after fetching rows