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.

Rule of thumb: a variable holding the result of .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.

LookupMeaningExample
__exactExact match (the default — often omitted)title__exact='Fix bug'
__containsSubstring match, case-sensitivetitle__contains='report'
__icontainsSubstring match, case-insensitivetitle__icontains='report'
__gt / __gteGreater than / greater than or equalpriority__gte=2
__lt / __lteLess than / less than or equaldue_date__lt=today
__inMatches any value in a given listpriority__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:

MethodUse 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
Note: This is correct, real Django ORM code — but this website's own project doesn't run a Django server, so you won't see these queries actually execute on this page. Try them against your own TaskFlow project's shell.
Next up: Every filter so far has combined conditions with an implicit AND. Q Objects & Chaining covers how to express OR conditions and more complex logic that plain keyword arguments can't reach.