QuerySets & Filtering
The previous lesson used .filter() and .get() without stopping to ask
what exactly gets returned when you call them. The answer — a QuerySet — is
one of the most important concepts in the entire Django ORM, and understanding it properly will
save you from a whole category of confusing bugs later. This lesson covers what a QuerySet
really is, the vocabulary Django gives you for expressing conditions (field lookups), and how
to combine several QuerySet operations into a single, readable query.
QuerySets Are Lazy
Writing this line does not query the database:
pending_tasks = Task.objects.filter(completed=False)
At this point, pending_tasks is a QuerySet — a Python object that describes
a database query, but hasn't actually asked the database anything yet. Django waits until the
QuerySet is actually evaluated before it runs any SQL. Evaluation happens the
first time you do something that needs the actual results — looping over it, calling
list() on it, printing it, or indexing into it:
pending_tasks = Task.objects.filter(completed=False) # no query yet
for task in pending_tasks: # the query runs here, on the first iteration
print(task.title)
Why this matters in practice: you can build up a QuerySet across several lines, or even pass it into a function that adds more filtering, without wasting a single database round trip until something actually needs the results. It's also why a QuerySet that's evaluated twice (looped over, then looped over again later) may hit the database twice — Django caches results after the first evaluation, but only for that specific QuerySet object.
.filter(), .exclude(), or .order_by() is a query waiting to happen, not a query that already happened.Field Lookups: A Reference Table
Field lookups are the vocabulary you use inside .filter() to express conditions
more specific than plain equality. They're written as
field__lookup=value — a double underscore joining the field name to the lookup
type.
| Lookup | Meaning | Example |
|---|---|---|
__exact | Exact match (the default — often omitted) | title__exact='Fix bug' |
__contains | Substring match, case-sensitive | title__contains='report' |
__icontains | Substring match, case-insensitive | title__icontains='report' |
__gt / __gte | Greater than / greater than or equal | priority__gte=2 |
__lt / __lte | Less than / less than or equal | due_date__lt=today |
__in | Matches any value in a given list | priority__in=[2, 3] |
Full Worked Example: Filtering TaskFlow Tasks
Finding overdue tasks means comparing a date field against today's date:
from django.utils import timezone
today = timezone.now().date()
overdue_tasks = Task.objects.filter(due_date__lt=today, completed=False)
Notice two conditions passed to the same .filter() call, separated by a comma —
Django combines multiple keyword arguments with an implicit AND, so this reads
as "due before today and not completed."
Searching tasks by a keyword typed into a search box, regardless of capitalization:
search_term = 'report'
matching_tasks = Task.objects.filter(title__icontains=search_term)
Ordering Results with order_by()
By default, the order rows come back in is not guaranteed — don't rely on it. To control the
order explicitly, chain .order_by() onto a QuerySet, passing the field name to sort
by:
tasks_by_due_date = Task.objects.all().order_by('due_date')
That sorts ascending — earliest due date first. Prefix the field name with a minus sign to sort descending instead:
tasks_by_priority_desc = Task.objects.all().order_by('-priority')
That returns high-priority tasks (priority 3) first, descending down to low
priority (1).
exclude(): The Inverse of filter()
Where .filter() keeps rows that match a condition, .exclude() keeps
everything that doesn't match — the inverse.
# Every task except the ones marked completed
active_tasks = Task.objects.exclude(completed=True)
In this particular example, Task.objects.exclude(completed=True) and
Task.objects.filter(completed=False) return the same rows — but .exclude()
reads more naturally when the condition you want to describe is fundamentally about what to
leave out, rather than what to keep.
Chaining QuerySet Methods Together
Because .filter(), .exclude(), and .order_by() all return
a new QuerySet rather than modifying anything in place, you can call one right after
another on the same line — each one narrows or reshapes the query further.
# The 10 highest-priority, incomplete tasks, most urgent first
urgent_tasks = (
Task.objects
.filter(completed=False)
.order_by('-priority')[:10]
)
Reading it left to right: start with every task, keep only the incomplete ones, sort by
priority descending, then slice to the first 10. This works because every step in the chain —
.filter(), then .order_by() — hands back a fresh QuerySet that the
next method (or the final slice) can be applied to. And thanks to the lazy evaluation covered
earlier, none of this touches the database until the sliced result is actually used — Django
is smart enough to turn that final [:10] into a LIMIT 10 in the actual
SQL, rather than fetching every row and slicing in Python.
Convenient Shortcuts: count(), exists(), first()
A few QuerySet methods are worth knowing simply because they're more efficient than the obvious alternative:
| Method | Use it instead of |
|---|---|
.count() | len(Task.objects.filter(...)) — counts in the database, without fetching every row |
.exists() | Checking if queryset: — asks the database a cheap yes/no question |
.first() | queryset[0] — returns the first result, or None if there isn't one, without raising an exception |