Skip to content

Filtering with Q and F

Queries beyond keyword arguments: Q objects for OR and negation, F for column references, Case/When for conditionals, Subquery, and raw SQL fragments.

from tortoise.expressions import Q, F, Case, When, Subquery, RawSQL

Keyword arguments to filter() are AND-ed. Everything else needs these.

await Post.filter(Q(status="published") | Q(status="featured"))
await Post.filter(Q(status="published") & Q(author_id=7))
await Post.filter(~Q(status="archived"))
OperatorMeaning
|OR
&AND
~NOT

A Q takes the same lookups as filter:

Q(title__icontains="async")
Q(created_at__gte=cutoff)
Q(author__name="Ada")

Positional Q arguments come first, keywords after, and the two are AND-ed:

await Post.filter(
Q(title__icontains="async") | Q(body__icontains="async"),
status="published",
)

That is “(title or body mentions async) and status is published”, which is almost always what you meant. Putting the keyword inside the Q chain instead would OR it in and quietly return every published post.

Parentheses work as they do in Python, because these are real operators:

await Post.filter(
(Q(status="published") | Q(status="featured"))
& Q(created_at__gte=cutoff)
)

Q objects are values, so a filter can be assembled conditionally:

query = Q()
if term:
query &= Q(title__icontains=term) | Q(body__icontains=term)
if author_id:
query &= Q(author_id=author_id)
if not include_archived:
query &= ~Q(status="archived")
posts = await Post.filter(query).order_by("-created_at")

An empty Q() matches everything, which is what makes it a safe starting point. This is the shape for a search endpoint with optional parameters. Much easier to read than branching over queryset variables.

def search(term: str, *fields: str) -> Q:
query = Q()
for field in fields:
query |= Q(**{f"{field}__icontains": term})
return query
await Post.filter(search("async", "title", "body", "author__name"))
from tortoise.expressions import F
await Post.filter(id=1).update(views=F("views") + 1)

F names a column, so the arithmetic happens in the database. The alternative loses concurrent updates:

post = await Post.get(id=1)
post.views += 1 # read
await post.save() # write — another request's increment is gone

Two requests both read 100, both write 101. With F the statement is SET views = views + 1 and the database serialises them.

Comparing two columns:

await Invoice.filter(paid_amount__lt=F("total_amount"))
await Post.filter(updated_at__gt=F("created_at"))

Which cannot be expressed with keyword arguments at all, the right-hand side of a lookup is always a value.

Arithmetic across columns:

await Product.annotate(margin=F("price") - F("cost")).filter(margin__lt=0)
from tortoise.expressions import Case, When
await Post.annotate(
weight=Case(
When(status="featured", then=3),
When(status="published", then=2),
default=1,
)
).order_by("-weight", "-created_at")

When conditions are evaluated in order; default applies when none matches.

This is how you sort by something that is not a column (a priority order over a status, a bucket over a numeric range) without loading every row and sorting in Python.

Also useful for conditional aggregation:

from tortoise.functions import Count, Sum
await Author.annotate(
published=Sum(Case(When(posts__status="published", then=1), default=0)),
total=Count("posts"),
)

One query for both numbers, instead of two.

from tortoise.expressions import Subquery
recent_authors = Post.filter(created_at__gte=cutoff).values_list("author_id", flat=True)
await Author.filter(id__in=Subquery(recent_authors))

The subquery is evaluated by the database, so the ids never travel to Python. The alternative (awaiting the inner query and passing a list) round-trips potentially thousands of ids and then sends them all back in an IN clause.

A correlated subquery in an annotation:

await Author.annotate(
latest_post=Subquery(
Post.filter(author_id=F("id")).order_by("-created_at").limit(1).values("title")
)
)

Correlated subqueries run once per outer row. For a handful of rows that is fine; for a large result set, a join or a prefetch is usually faster. Check with explain().

from tortoise.expressions import RawSQL
await Post.annotate(
rank=RawSQL("ts_rank(search_vector, plainto_tsquery('english', 'async'))")
).order_by("-rank")

For expressions the ORM cannot build: a full-text rank, a PostGIS distance, a window function.

RawSQL also ties the query to one backend’s dialect. Fine when you have one; worth a comment saying so.

You needUse
AND of simple conditionskeyword arguments
OR, or negation of a groupQ
Compare or update using a columnF
A conditional valueCase / When
A set from another querySubquery
An expression the ORM lacksRawSQL
A whole query the ORM lacksraw SQL

Reach down the list only when the row above cannot express it. Each step costs some portability and some readability, and the ones at the bottom cost the safety of parameterisation.