Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • filter, exclude and chaining
  • Q objects for OR and negation
  • F expressions for field-to-field comparisons
  • annotate and aggregate for counts and averages
  • Laziness: when a QuerySet hits the database

Lesson notes

1. filter, exclude and chaining

In the tutorial you wrote one query and it was enough: Post.objects.filter(published_date__lte=timezone.now()). Now that posts have categories, tags and comments, the questions get harder - and every one of them should still be a single trip to the database.

Every filter() and exclude() returns a new QuerySet, so you can build a query in pieces:

from django.utils import timezone

qs = Post.objects.all()
qs = qs.filter(published_date__lte=timezone.now())
qs = qs.exclude(category__slug="drafts")
qs = qs.order_by("-published_date")[:10]

Keyword arguments inside one filter() are combined with AND. That is fine for a foreign key, but across a to-many relation the two forms below are not the same question:

QueryMeans
.filter(comments__approved=True, comments__author_name="Ada") one comment that is both approved and by Ada
.filter(comments__approved=True).filter(comments__author_name="Ada") an approved comment, and (possibly another) comment by Ada

The first produces one join, the second produces two. Chained filters on a to-many relation are almost always the bug, not the feature.

2. The lookups worth remembering

Post.objects.filter(title__icontains="orm")          # case-insensitive LIKE
Post.objects.filter(title__startswith="How")
Post.objects.filter(published_date__year=2026)
Post.objects.filter(published_date__isnull=True)      # the drafts
Post.objects.filter(category__slug__in=["django", "python"])
Post.objects.filter(published_date__range=(start, end))
Comment.objects.filter(body__regex=r"https?://")

A lookup is the part after the last double underscore; everything before it is a field or a relation. That is the whole grammar.

3. Q objects for OR and NOT

Keyword arguments cannot express OR, because a dict cannot hold the same key twice and there is nowhere to put the operator. Q objects can:

from django.db.models import Q

# posts in the django category OR tagged "orm"
Post.objects.filter(Q(category__slug="django") | Q(tags__slug="orm")).distinct()

# published, and either featured or written by the current user
Post.objects.filter(
    Q(published_date__lte=timezone.now())
    & (Q(featured=True) | Q(author=request.user))
)

# NOT: everything except drafts
Post.objects.filter(~Q(published_date=None))

| is OR, & is AND, ~ is NOT. Positional Q arguments must come before keyword arguments:

Post.objects.filter(Q(title__icontains=term) | Q(text__icontains=term), author=user)

This is exactly how you build a search box, combining terms in a loop:

import operator
from functools import reduce

terms = request.GET.get("q", "").split()
if terms:
    condition = reduce(
        operator.and_,
        (Q(title__icontains=t) | Q(text__icontains=t) for t in terms),
    )
    qs = qs.filter(condition)

4. F expressions: let the database do the arithmetic

F refers to a column inside the query, so you can compare two fields or update a value without reading it into Python first. Add a view counter to Post and look at the difference:

# Wrong: read, add, write. Two concurrent requests lose one view.
post.view_count = post.view_count + 1
post.save()

# Right: one atomic UPDATE, no race condition.
from django.db.models import F

Post.objects.filter(pk=post.pk).update(view_count=F("view_count") + 1)
UPDATE "blog_post" SET "view_count" = "blog_post"."view_count" + 1
WHERE "blog_post"."id" = 7;

Field-to-field comparisons are the other half of the story:

# posts edited after publication
Post.objects.filter(updated_at__gt=F("published_date"))

# comments written by the post's own author
Comment.objects.filter(author_name=F("post__author__username"))

# arithmetic works too
Post.objects.filter(view_count__gt=F("comment_count") * 10)

5. annotate and aggregate

These two are constantly confused, so fix the distinction now: aggregate() collapses the whole QuerySet into one dict, while annotate() adds a computed attribute to every row and gives you back a QuerySet.

from django.db.models import Avg, Count, Max

Post.objects.aggregate(total=Count("id"), latest=Max("published_date"))
# {'total': 42, 'latest': datetime.datetime(2026, 3, 1, 9, 30, tzinfo=...)}
posts = Post.objects.annotate(num_comments=Count("comments"))
for post in posts:
    print(post.title, post.num_comments)      # no extra query per post
SELECT "blog_post"."id", "blog_post"."title", COUNT("blog_comment"."id") AS "num_comments"
FROM "blog_post"
LEFT OUTER JOIN "blog_comment" ON ("blog_post"."id" = "blog_comment"."post_id")
GROUP BY "blog_post"."id";

Because an annotation is a real column in the query, you can filter and order by it:

Post.objects.annotate(num_comments=Count("comments")).filter(num_comments__gte=5)
Category.objects.annotate(num_posts=Count("posts")).order_by("-num_posts")

Counting only some related rows needs a filter argument, and combining two aggregates over different relations needs distinct=True or you will get inflated numbers from the double join:

Post.objects.annotate(
    approved_comments=Count("comments", filter=Q(comments__approved=True)),
    num_tags=Count("tags", distinct=True),
)

Case/When and Value cover the rest of what you would otherwise write as an if in Python:

from django.db.models import BooleanField, Case, Value, When

Post.objects.annotate(
    state=Case(
        When(published_date__isnull=True, then=Value("draft")),
        When(published_date__gt=timezone.now(), then=Value("scheduled")),
        default=Value("live"),
    )
)

6. Laziness: when the query actually runs

A QuerySet is a description of a query, not its result. Nothing is sent to the database until something forces evaluation.

.all() no SQL .filter(...) no SQL .order_by(...) no SQL [:10] no SQL (LIMIT) for p in qs 1 query, cached building the query - cheap, nothing leaves Python evaluation

These force evaluation:

  • iterating: for post in qs
  • len(qs), list(qs), bool(qs), repr(qs)
  • slicing with a step, e.g. qs[::2]
  • pickle, and rendering the QuerySet in a template loop

These do not:

  • filter, exclude, annotate, order_by, select_related, values
  • plain slicing, which becomes LIMIT/OFFSET

Once evaluated, the rows are cached on the QuerySet object. So call it once and reuse it, and prefer the cheap method for the question you are asking:

qs.count()      # SELECT COUNT(*) - use when you only need the number
len(qs)         # fetches every row - fine if you are about to loop anyway
qs.exists()     # SELECT 1 ... LIMIT 1 - the cheapest "is there anything?"

7. Exercise

  1. Write one query returning published posts that are in the "django" category or carry the "orm" tag, newest first, without duplicates.
  2. Add a view_count field and increment it in post_detail using F(). Verify the SQL contains no SELECT of the old value.
  3. Produce a list of categories with their post count, sorted by count descending, in a single query. Print qs.query and find the GROUP BY.
  4. Annotate posts with the number of approved comments only, and list those with more than two.
  5. In the shell, build a QuerySet without evaluating it, then check len(connection.queries) before and after your first for loop to prove the laziness.

Further reading