The recording for this lesson has not been published yet.
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:
| Query | Means |
|---|---|
.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.
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.
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)
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)
post.view_count in memory still holds the old number, and if you assign
F("view_count") + 1 to an attribute it stays a lazy expression until you
save. Call post.refresh_from_db() before you display the value.
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"),
)
)
A QuerySet is a description of a query, not its result. Nothing is sent to the database until something forces evaluation.
These force evaluation:
for post in qslen(qs), list(qs), bool(qs),
repr(qs)qs[::2]pickle, and rendering the QuerySet in a template loopThese do not:
filter, exclude, annotate,
order_by, select_related, valuesLIMIT/OFFSETOnce 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?"
In the shell, print(qs.query) shows the SQL a QuerySet would run, and
django.db.connection.queries lists everything executed so far when
DEBUG is on. Read the SQL whenever a query surprises you.
view_count field and increment it in post_detail using
F(). Verify the SQL contains no SELECT of the old value.qs.query and find the GROUP BY.len(connection.queries) before and after your first for loop to
prove the laziness.