Advanced ORM: Subqueries, Bulk & Raw SQL
QuerySets & Relationships taught the everyday ORM: lookups, Q, F, annotate() and the N+1 fix. Real reports ask harder questions. "Who borrowed each book last?" "Which members have an overdue loan?" "Label every book as in stock, low or out." "Give every English book a 10% Diwali discount without 500 separate UPDATE statements." A developer who answers these with Python loops ends up with slow pages; one who knows the ORM's advanced tools answers them in one or two queries.
The examples use the library from earlier lessons, now with a loans app (Member, and Loan with book, member, borrowed_on, due_on and returned_on), as set up in Structuring Large Django Projects. Ten loans exist, some returned and some still out. "Today" is 2 November 2026.
Subquery, OuterRef and Exists
A subquery is a query inside another query. OuterRef("pk") means "the primary key of the row in the outer query", so the inner query runs once per outer row, inside the database:
from django.db.models import Exists, OuterRef, Subquery
latest = Loan.objects.filter(book=OuterRef("pk")).order_by("-borrowed_on")
books = Book.objects.annotate(
last_borrower=Subquery(latest.values("member__name")[:1]),
last_borrowed=Subquery(latest.values("borrowed_on")[:1]),
)
for b in books:
print(f"{b.title:24} {b.last_borrower or '-':6} {b.last_borrowed or ''}")
overdue = Loan.objects.filter(member=OuterRef("pk"), returned_on__isnull=True,
due_on__lt=date(2026, 11, 2))
print(Member.objects.filter(Exists(overdue)).order_by("name").values_list("name", flat=True))
print(Member.objects.filter(~Exists(overdue)).values_list("name", flat=True))Gitanjali - Godaan Isha 2026-10-25 Malgudi Days - Nirmala Isha 2026-10-06 Swami and Friends Rohan 2026-10-29 The Blue Umbrella Meera 2026-10-30 The Home and the World - <QuerySet ['Aarav', 'Isha', 'Meera']> <QuerySet ['Rohan']>
A Subquery must return one column (values("member__name")) and at most one row ([:1]). Books never borrowed get None. Exists() asks only "is there at least one matching row?", which the database can answer quickly, and ~Exists() gives the opposite. This is usually faster and clearer than filter(loans__...) with distinct(), which returns a member once per matching loan unless you add distinct().
Conditional values with Case and When
Case and When are the ORM's version of SQL CASE: the first When that matches wins. Aggregates also accept a filter= argument, so one query can count different things:
from django.db.models import Case, Count, Q, Value, When
books = Book.objects.annotate(
stock=Case(
When(available=0, then=Value("Out")),
When(available__lte=1, then=Value("Low")),
default=Value("OK"),
),
times_borrowed=Count("loans"),
still_out=Count("loans", filter=Q(loans__returned_on__isnull=True)),
).order_by("-times_borrowed", "title")
for b in books[:4]:
print(f"{b.title:18} {b.available}/{b.copies} {b.stock:3} borrowed {b.times_borrowed}, out {b.still_out}")Swami and Friends 2/5 OK borrowed 4, out 3 Godaan 1/3 Low borrowed 3, out 2 Nirmala 0/2 Out borrowed 2, out 2 The Blue Umbrella 3/4 OK borrowed 1, out 1
Without filter=, you would need two queries or a loop in Python. You can also filter and sort on the annotation, for example .filter(stock="Out") for a restock report.
Prefetch objects and loading less
prefetch_related("loans") loads every loan of each book. A Prefetch object lets you choose which related rows to load, how to order them, and which attribute to put them in:
from django.db.models import Prefetch
open_loans = (Loan.objects.filter(returned_on__isnull=True)
.select_related("member").order_by("due_on"))
hindi = Book.objects.filter(language="hi").prefetch_related(
Prefetch("loans", queryset=open_loans, to_attr="open_loans"))
with CaptureQueriesContext(connection) as ctx: # counts queries, as in the N+1 lesson
for book in hindi:
print(book.title, [(loan.member.name, str(loan.due_on)) for loan in book.open_loans])
print(len(ctx.captured_queries), "queries")Godaan [('Meera', '2026-11-03'), ('Isha', '2026-11-08')]
Nirmala [('Aarav', '2026-10-19'), ('Isha', '2026-10-20')]
2 queriesTwo queries in total, whatever the number of books: one for the books, one for their open loans with members joined in. to_attr stores a plain list on each book, so book.loans.all() still means "all loans".
When a table has large columns you do not need, only("title", "price") loads just those fields (plus the primary key), and defer("summary") loads everything except the named ones. Touching a deferred field later costs one extra query per object, so use these only when you are sure what the page reads.
Bulk writes and custom QuerySets
Saving 500 objects in a loop means 500 queries. The bulk methods do the work in a few:
from decimal import Decimal
with CaptureQueriesContext(connection) as ctx:
english = list(Book.objects.filter(language="en"))
for b in english:
b.price = (b.price * Decimal("0.90")).quantize(Decimal("0.01")) # Diwali: 10% off
print(Book.objects.bulk_update(english, ["price"]), "rows updated")
Author.objects.bulk_create([Author(name=n) for n in ["Kamala Das", "Mahasweta Devi"]])
print([q["sql"].split()[0] for q in ctx.captured_queries])4 rows updated ['SELECT', 'BEGIN', 'UPDATE', 'COMMIT', 'INSERT']
The second line lists the SQL statements Django sent: one SELECT, one UPDATE for all four books (inside a transaction) and one INSERT for both authors. Remember the trade-off from the last lesson: bulk methods do not call save() and do not send pre_save/post_save signals. When the change is a simple formula, filter(...).update(price=F("price") * Decimal("0.90")) is even better, with no SELECT at all.
Filters that you repeat across views belong on a custom QuerySet. Its methods chain like built-in ones:
# catalog/models.py
class BookQuerySet(models.QuerySet):
def on_shelf(self):
return self.filter(available__gt=0)
def in_language(self, code):
return self.filter(language=code)
def with_loan_counts(self):
return self.annotate(times_borrowed=models.Count("loans"))
class Book(models.Model):
# ... fields as before
objects = BookQuerySet.as_manager()
print(Book.objects.on_shelf().in_language("en").with_loan_counts()
.order_by("title").values_list("title", "times_borrowed"))
print(Book.objects.filter(year__lt=1940).on_shelf().count())<BookQuerySet [('Malgudi Days', 0), ('Swami and Friends', 4), ('The Blue Umbrella', 1), ('The Home and the World', 0)]>
4The explicit order_by("title") matters: since Django 3.1, a model's Meta.ordering is ignored in queries that use GROUP BY, such as this one with Count.
Indexes and constraints
Rules that must never be broken belong in the database, not only in forms. Add them in Meta and create a migration:
class Meta:
ordering = ["title"]
indexes = [models.Index(fields=["language", "year"])]
constraints = [
models.CheckConstraint(
condition=models.Q(available__lte=models.F("copies")),
name="available_not_more_than_copies",
),
]IntegrityError: CHECK constraint failed: available_not_more_than_copies ['Constraint “available_not_more_than_copies” is violated.']
The first line came from update(available=10) on Godaan: the database refused it, whatever code sent it. The second came from book.full_clean(), because model validation (and therefore ModelForms) checks constraints too and shows a friendly error. The index speeds up filters such as filter(language="en", year__gte=1930); QuerySet.explain() prints the database's plan so you can confirm an index is used.
Raw SQL, safely
The ORM covers most needs, but sometimes you need SQL directly: a database feature the ORM lacks, or a hand-tuned report. Django gives you Manager.raw(), which returns model objects, and connection.cursor() for anything else. In both, pass values as parameters, never inside the string:
from django.db import connection
for b in Book.objects.raw("SELECT id, title, year FROM catalog_book WHERE language = %s ORDER BY year", ["en"]):
print(b.title, b.year)
with connection.cursor() as cursor:
cursor.execute(
"""SELECT m.name, COUNT(*) FROM loans_loan AS l
JOIN loans_member AS m ON m.id = l.member_id
WHERE l.returned_on IS NULL
GROUP BY m.name HAVING COUNT(*) >= %s ORDER BY m.name""",
[2],
)
print(cursor.fetchall())
attack = "en' OR '1'='1"
safe = Book.objects.raw("SELECT id, title FROM catalog_book WHERE language = %s", [attack])
unsafe = Book.objects.raw(f"SELECT id, title FROM catalog_book WHERE language = '{attack}'")
print(len(list(safe)), len(list(unsafe)))The Home and the World 1916
Swami and Friends 1935
Malgudi Days 1943
The Blue Umbrella 1980
[('Aarav', 2), ('Isha', 2), ('Meera', 3)]
0 7The last line is the whole lesson of SQL injection: with a parameter, the attack text is treated as a strange language code and matches nothing. Pasted into an f-string, it rewrote the query and returned all seven books. Write %s without quotes around it, whatever the database; the driver adds the quoting.
Common mistakes
- A
Subquerythat returns several rows or columns. Always usevalues("one_field")and[:1], or the database raises an error. - Sorting a
GROUP BYquery byMeta.ordering. It is ignored there; addorder_by()yourself. - Using
only()and then reading other fields in the template. Each deferred field read is a new query per object, which brings back N+1. - Expecting
bulk_update()orupdate()to runsave()logic. They do not; put such logic in a service that also handles bulk cases. - Building raw SQL with f-strings or
%formatting. Always pass a parameter list.
Key points
SubquerywithOuterRefputs a per-row query inside one SQL statement;Existstests for related rows efficiently.Case/Whenproduce conditional values, andCount(..., filter=Q(...))counts subsets in the same query.Prefetch(queryset=..., to_attr=...)controls which related rows are loaded;only()anddefer()load fewer columns.bulk_create(),bulk_update()andupdate()replace loops ofsave(), but skipsave()and signals.- Custom QuerySets with
as_manager()give reusable, chainable filters;CheckConstraintandIndexbelong inMeta. - Raw SQL is fine when needed, with values passed as parameters, never formatted into the string.
Exercise
Write one query that lists every member with three annotations: open_loans (loans not yet returned), overdue (open loans with due_on before 2 November 2026) and last_title (title of the book they borrowed most recently). Sort by overdue descending, then name, and print one line per member.
Show solution
from datetime import date
from django.db.models import Count, OuterRef, Q, Subquery
today = date(2026, 11, 2)
latest = Loan.objects.filter(member=OuterRef("pk")).order_by("-borrowed_on")
members = Member.objects.annotate(
open_loans=Count("loans", filter=Q(loans__returned_on__isnull=True)),
overdue=Count("loans", filter=Q(loans__returned_on__isnull=True, loans__due_on__lt=today)),
last_title=Subquery(latest.values("book__title")[:1]),
).order_by("-overdue", "name")
for m in members:
print(m.name, m.open_loans, m.overdue, m.last_title)Aarav 2 1 Swami and Friends Isha 2 1 Godaan Meera 3 1 The Blue Umbrella Rohan 1 0 Swami and Friends
Both counts use the same join with different filter= conditions, and the subquery adds the latest title, all in one SQL statement.
Quick Quiz
1. What does OuterRef("pk") refer to inside a Subquery?
OuterRef links the inner query to each row of the outer query.
2. You need books labelled "Out", "Low" or "OK" based on available. Which tool fits?
Case/When computes the label in SQL, so you can also filter and sort on it.
3. Which statement about bulk_update() is true?
Bulk methods skip save() and its signals in exchange for speed.
4. Which raw query is safe against SQL injection?
Passing values as parameters lets the database driver quote them safely.
Learn Django & Flask with a mentor
Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
