Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Django  Module 4 – The ORM in depth: performance, integrity and PostgreSQL

Aggregation, annotations and expressions

Summarise Django data inside the database with aggregate(), annotate() and values(), and compute with F, Case, Subquery, Exists and window functions.

  • Intermediate
  • 25 minutes
  • Examples run with Python 3.14.8, Django 6.1.2 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Summarise rows with aggregate(), annotate() and values().annotate(), and predict the GROUP BY each one sends
  • Count and sum only the rows you mean with filter=, distinct=True and default=, and spot joins that multiply rows
  • Compute a value for every row with F(), Case and When, and ask about other rows with Subquery, OuterRef and Exists
  • Rank rows inside each group with Window, Rank, DenseRank and RowNumber
  • Replace a Python loop over rows with one annotated query, and measure the queries and the time it saves

Before you start

What you’ll learn · Before you start

What you will learn

  • Summarise rows with aggregate(), annotate() and values().annotate(), and predict the GROUP BY each one sends
  • Count and sum only the rows you mean with filter=, distinct=True and default=, and spot joins that multiply rows
  • Compute a value for every row with F(), Case and When, and ask about other rows with Subquery, OuterRef and Exists
  • Rank rows inside each group with Window, Rank, DenseRank and RowNumber
  • Replace a Python loop over rows with one annotated query, and measure the queries and the time it saves

Before you start

On this page

The Nivas committee’s dues page adds up what the 407 flats of its 23 towers still owe, tower by tower. Written as a Python loop over the rows, the report sent 408 queries to the database; written as one annotated query, it sent 1, gave the same totals, and was more than 20 times faster, which this lesson’s measurement checks on every run.

You will summarise the society’s bills with aggregate() and annotate(), count and sum only the rows you mean, catch the join that multiplies rows, compute a value for every row with F(), Case and When, ask about other rows with Subquery and Exists, rank the flats of each tower with window functions, and measure what moving a loop into the database saves. Every query below was recorded on Django 6.1.2 with SQLite 3.51.0.

aggregate() returns one dictionary, annotate() gives every row a value

The committee’s first questions are totals: how much was billed in all, how many bills each flat has, how much each tower was billed. Each one is an aggregate function: A function that turns many rows into one value, such as SUM, COUNT, AVG, MIN and MAX in SQL, or Sum, Count, Avg, Min and Max in Django. Over no rows every one of them but COUNT gives NULL. worked out by the database, and the call you choose decides the shape of the answer. The rows come from one file that every example imports first: three towers, seven flats, eighteen monthly bills and fourteen complaints.

seed.py: the society's towers, flats, bills and complaints Python · nivas/seed.py
"""The society the examples of this lesson summarise: three towers, seven flats, eighteen maintenance bills and
fourteen complaints, the same on every run.

Importing it starts Django, creates every table with migrate and adds the rows to an empty database; running it as a
script does the same. Tower C was handed over in October: its one flat has no bill yet. Money is in paise.

compact() prints a query's SQL shorter, so that it fits the page: without the double quotes and without the society_
prefix of the table names. The SQL that was sent is the same, with them.
"""

import os
import re
from datetime import date

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.core.management import call_command  # noqa: E402

from society.models import Bill, Complaint, Flat, Tower  # noqa: E402

call_command('migrate', verbosity=0)


def compact(sql):
    """SELECT "society_bill"."month" FROM "society_bill" -> SELECT bill.month FROM bill"""
    return re.sub(r'"society_(\w+)"', r'\1', sql).replace('"', '')


TODAY = date(2026, 10, 20)  # the day the examples treat as today, so that "overdue" is the same on every run
MONTHS = [date(2026, 8, 1), date(2026, 9, 1), date(2026, 10, 1)]  # each bill is due on the 25th of its month

FLATS = [('A', 101, 650), ('A', 102, 650), ('A', 1101, 1150),
         ('B', 101, 800), ('B', 1203, 1150), ('B', 1205, 1400),
         ('C', 101, 900)]
PAID = {  # what each flat has paid of its August, September and October bills: 'all', 'none' or paise
    'A-101': ['all', 'all', 'all'],
    'A-102': ['all', 'none', 'none'],
    'A-1101': ['all', 'all', 100_000],
    'B-101': ['all', 100_000, 'none'],
    'B-1203': ['all', 'all', 'all'],
    'B-1205': ['none', 'none', 'none'],
}
COMPLAINTS = {  # flat: (topic, status), …
    'A-101': [('Lift stuck on floor 3', 'closed')],
    'A-102': [('Seepage in the bathroom', 'open'), ('Doorbell wiring', 'open'), ('Corridor light off', 'open')],
    'A-1101': [('Water pressure low', 'open'), ('Terrace door lock', 'open'), ('Lift noise', 'open')],
    'B-1203': [('Parking slot taken', 'open'), ('Gym treadmill broken', 'closed')],
    'B-1205': [('Seepage in the kitchen', 'open'), ('Water pressure low', 'open'), ('Pest control', 'open'),
               ('Intercom dead', 'open')],
    'C-101': [('Water leak in the lobby', 'open')],
}

if not Tower.objects.exists():
    towers = {name: Tower.objects.create(name=name, floors=floors) for name, floors in [('A', 14), ('B', 12), ('C', 8)]}
    flats = {}
    for tower, number, area in FLATS:
        flats[f'{tower}-{number}'] = Flat.objects.create(tower=towers[tower], number=number, area_sqft=area)
    for code, paid in PAID.items():
        flat = flats[code]
        amount = flat.area_sqft * 250  # 250 paise a square foot a month
        for month, share in zip(MONTHS, paid):
            Bill.objects.create(flat=flat, month=month, amount_paise=amount, due_on=month.replace(day=25),
                                paid_paise={'all': amount, 'none': 0}.get(share, share))
    for code, items in COMPLAINTS.items():
        for topic, status in items:
            Complaint.objects.create(flat=flats[code], topic=topic, status=status)
aggregate(), annotate() and values().annotate(), with their SQL Python · nivas/totals.py
"""aggregate() and annotate() on the society's bills: what each returns, and the SQL it sent.

Run it next to manage.py. seed.py makes db.sqlite3 there with every table and the lesson's rows.
"""

import seed  # noqa: F401  (starts Django, creates the tables, adds the rows)
from django.db import connection
from django.db.models import Count, Sum
from django.test.utils import CaptureQueriesContext

from seed import compact
from society.models import Bill, Flat, Tower

with CaptureQueriesContext(connection) as queries:
    print('1. aggregate():', Bill.objects.aggregate(Sum('amount_paise')))
    print('2. aggregate(), named:', Bill.objects.aggregate(billed=Sum('amount_paise'), paid=Sum('paid_paise'),
                                                          bills=Count('id')))
    try:
        Flat.objects.annotate(bills=Count('bills'))
    except ValueError as error:
        print('   annotate(bills=...) on flats:', error)
    flats = Flat.objects.annotate(bill_count=Count('bills')).order_by('tower__name', 'number')
    print('3. annotate() on flats:', [(f'{tower}-{number}', n) for tower, number, n in
                                      flats.values_list('tower__name', 'number', 'bill_count')])
    by_tower = Bill.objects.values('flat__tower__name').annotate(billed=Sum('amount_paise'))
    print('4. values() then annotate():', list(by_tower.order_by('flat__tower__name')))
    towers = Tower.objects.annotate(billed=Sum('flats__bills__amount_paise')).order_by('name')
    print('5. annotate() on towers:', list(towers.values_list('name', 'billed')))
    towers = Tower.objects.annotate(billed=Sum('flats__bills__amount_paise', default=0)).order_by('name')
    print('6. the same with default=0:', list(towers.values_list('name', 'billed')))
    print('7. values() then annotate(), ordered by flat number:',
          [(row['flat__tower__name'], row['bills'])
           for row in by_tower.annotate(bills=Count('id')).order_by('flat__number')])
print('The SQL of queries 3 to 7:')
for number, query in enumerate(queries, start=1):
    if number >= 3:
        print(f'  {number}.', compact(query['sql']))

Output

1. aggregate(): {'amount_paise__sum': 4350000}
2. aggregate(), named: {'billed': 4350000, 'paid': 2487500, 'bills': 18}
   annotate(bills=...) on flats: The annotation 'bills' conflicts with a field on the model.
3. annotate() on flats: [('A-101', 3), ('A-102', 3), ('A-1101', 3), ('B-101', 3), ('B-1203', 3), ('B-1205', 3), ('C-101', 0)]
4. values() then annotate(): [{'flat__tower__name': 'A', 'billed': 1837500}, {'flat__tower__name': 'B', 'billed': 2512500}]
5. annotate() on towers: [('A', 1837500), ('B', 2512500), ('C', None)]
6. the same with default=0: [('A', 1837500), ('B', 2512500), ('C', 0)]
7. values() then annotate(), ordered by flat number: [('A', 3), ('B', 3), ('A', 3), ('A', 3), ('B', 3), ('B', 3)]
The SQL of queries 3 to 7:
  3. SELECT tower.name AS tower__name, flat.number AS number, COUNT(bill.id) AS bill_count FROM flat LEFT OUTER JOIN bill ON (flat.id = bill.flat_id) INNER JOIN tower ON (flat.tower_id = tower.id) GROUP BY flat.id, flat.tower_id, 2, flat.area_sqft, 1 ORDER BY 1 ASC, 2 ASC
  4. SELECT tower.name AS flat__tower__name, SUM(bill.amount_paise) AS billed FROM bill INNER JOIN flat ON (bill.flat_id = flat.id) INNER JOIN tower ON (flat.tower_id = tower.id) GROUP BY 1 ORDER BY 1 ASC
  5. SELECT tower.name AS name, SUM(bill.amount_paise) AS billed FROM tower LEFT OUTER JOIN flat ON (tower.id = flat.tower_id) LEFT OUTER JOIN bill ON (flat.id = bill.flat_id) GROUP BY tower.id, 1, tower.floors ORDER BY 1 ASC
  6. SELECT tower.name AS name, COALESCE(SUM(bill.amount_paise), 0) AS billed FROM tower LEFT OUTER JOIN flat ON (tower.id = flat.tower_id) LEFT OUTER JOIN bill ON (flat.id = bill.flat_id) GROUP BY tower.id, 1, tower.floors ORDER BY 1 ASC
  7. SELECT tower.name AS flat__tower__name, SUM(bill.amount_paise) AS billed, COUNT(bill.id) AS bills FROM bill INNER JOIN flat ON (bill.flat_id = flat.id) INNER JOIN tower ON (flat.tower_id = tower.id) GROUP BY 1, flat.number ORDER BY flat.number ASC

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 totals.py

aggregate() ends a QuerySet: it sends one query and returns a dictionary, with a key made from the field and the function, amount_paise__sum, unless you name it as line 2 did. annotate() keeps the QuerySet and gives each object an annotation: A value that annotate() adds to every object of a QuerySet, worked out by the database in the same query, such as each flat's number of bills. It is read as an attribute of the object., here bill_count on every flat. Its name must not be one the model already uses: bills is the related name of Bill.flat, so annotate(bills=…) raised a ValueError. The SQL of line 3 grouped by the flat’s columns after a LEFT OUTER JOIN, so C-101, with no bill, came back with 0.

Put values() first, and annotate() groups by the fields values() names instead. Step through how line 4 turned the 18 bills into two rows:

Interactive · stage by stage How values() and annotate() turn 18 bills into 2 rows Step through the query of line 4. Watch the rows become groups, and see what aggregate() does instead.
How values() and annotate() turn 18 bills into 2 rows, step 6 of 6. Showing Bill.objects, values() by tower name, then annotate() (A: 9 bills, B: 9 bills), SUM in each group (4,350,000 for all 18), The result (no row for tower C). In focus: SUM in each group.aggregate(): one groupBill.objects18 bill rowsvalues() by tower nameJOIN flat and towerthen annotate()A: 9 bills, B: 9 billsSUM in each group4,350,000 for all 18The resultno row for tower Call 18 in one

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Eighteen bills. Bill.objects starts from every bill: three months for each of the six flats of towers A and B. Tower C was handed over in October and has none.
  2. Each row gets its tower. values('flat__tower__name') names what to group by. The tower's name is two relations away, so the SQL joins flat and tower, and every bill row carries its tower's letter.
  3. Rows become groups. annotate() after values() groups by the fields values() named: the SQL says GROUP BY 1, the first column, which is the tower name. The 18 rows fall into two groups of 9.
  4. One sum per group. Sum('amount_paise') adds the amounts inside each group: 1,837,500 paise for tower A and 2,512,500 for tower B.
  5. Two dictionaries. One dictionary per group comes back. Tower C has no bill, so no joined row names it, and the result has no line for C; a query that starts from Tower keeps it.
  6. aggregate() instead. aggregate(Sum('amount_paise')) makes the whole QuerySet one group and returns a dictionary, not a QuerySet: {'amount_paise__sum': 4350000}, the 18 bills together.

An order_by() that splits the groups

Line 7 took line 4’s grouped query and ordered it by the flat number. Django adds the fields you order by to the GROUP BY, so the result had six groups of 3 bills, one per flat, instead of two towers. Order such a query by the field you grouped by, or clear the order with order_by() and no arguments.

Line 4 has no row for tower C, because no bill names it. Line 5 starts from Tower and keeps C, with None: SQL’s SUM over no rows is NULL, and of the aggregates only Count gives 0. Sum(…, default=0) wraps the sum in COALESCE(…, 0), which line 6 shows.

Key idea

aggregate() returns one dictionary for the whole QuerySet, annotate() gives each object a value, and values() before annotate() decides what the groups are.

Checkpoint

Check: aggregate() and annotate()

  1. Question 1 of 2 What does Bill.objects.aggregate(Sum('amount_paise')) return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A dictionary: {'amount_paise__sum': 4350000}

    Line 1 of totals.py printed {'amount_paise__sum': 4350000}, from one SELECT SUM(…) over all 18 bills. Line 2 named the keys itself: billed, paid and bills.

  2. Question 2 of 2 Tower.objects.annotate(billed=Sum('flats__bills__amount_paise')) gave tower C None. Why, and what gives 0?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: SQL's SUM over no rows is NULL; Sum(…, default=0) wraps it in COALESCE(…, 0)

    Line 5 printed ('C', None) and line 6, with default=0, ('C', 0). The SQL of line 6 shows the COALESCE that default adds.

Conditional counts and sums make the dues report exact

A dues report needs more than totals: of each tower’s bills, how many are unpaid, and how many flats are behind. Both count some rows only. Count and Sum take a filter, a Q object, which became SQL’s FILTER (WHERE …):

The dues report: one query, conditional counts and sums Python · nivas/dues_report.py
"""The monthly dues report: for each tower, what was billed and paid, what is outstanding, how many bills are unpaid
and how many flats have an overdue bill, in one query.

Run it next to manage.py. seed.py makes db.sqlite3 there with every table and the lesson's rows.
"""

import seed  # noqa: F401  (starts Django, creates the tables, adds the rows)
from django.db import connection
from django.db.models import Count, F, Q, Sum
from django.test.utils import CaptureQueriesContext

from seed import TODAY, compact
from society.models import Tower

unpaid = Q(flats__bills__paid_paise__lt=F('flats__bills__amount_paise'))
overdue = unpaid & Q(flats__bills__due_on__lt=TODAY)

with CaptureQueriesContext(connection) as queries:
    report = (
        Tower.objects.annotate(
            billed=Sum('flats__bills__amount_paise', default=0),
            paid=Sum('flats__bills__paid_paise', default=0),
            unpaid_bills=Count('flats__bills', filter=unpaid),
            overdue_flats=Count('flats', filter=overdue, distinct=True),
        )
        .annotate(outstanding=F('billed') - F('paid'))
        .order_by('name')
    )
    print('tower    billed      paid  outstanding  unpaid bills  overdue flats')
    for tower in report:
        print(f'{tower.name:>5} {tower.billed:>9,} {tower.paid:>9,} {tower.outstanding:>12,}'
              f' {tower.unpaid_bills:>13} {tower.overdue_flats:>14}')
    without_distinct = Tower.objects.annotate(overdue_flats=Count('flats', filter=overdue)).order_by('name')
    print('overdue flats without distinct=True:', list(without_distinct.values_list('name', 'overdue_flats')))
print(len(queries), 'queries. The first one:')
print(compact(queries[0]['sql']))

Output

tower    billed      paid  outstanding  unpaid bills  overdue flats
    A 1,837,500 1,325,000      512,500             3              1
    B 2,512,500 1,162,500    1,350,000             5              2
    C         0         0            0             0              0
overdue flats without distinct=True: [('A', 1), ('B', 3), ('C', 0)]
2 queries. The first one:
SELECT tower.id, tower.name, tower.floors, COALESCE(SUM(bill.amount_paise), 0) AS billed, COALESCE(SUM(bill.paid_paise), 0) AS paid, COUNT(bill.id) FILTER (WHERE bill.paid_paise < (bill.amount_paise)) AS unpaid_bills, COUNT(DISTINCT flat.id) FILTER (WHERE (bill.paid_paise < (bill.amount_paise) AND bill.due_on < '2026-10-20')) AS overdue_flats, (COALESCE(SUM(bill.amount_paise), 0) - COALESCE(SUM(bill.paid_paise), 0)) AS outstanding FROM tower LEFT OUTER JOIN flat ON (tower.id = flat.tower_id) LEFT OUTER JOIN bill ON (flat.id = bill.flat_id) GROUP BY tower.id, tower.name, tower.floors ORDER BY tower.name ASC

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dues_report.py

unpaid_bills counted the bills whose paid_paise is below amount_paise, comparing two columns of the same row with F(), which a later section looks at. overdue_flats counts flats, not bills, so it needs distinct=True: B-1205 owes for August and September, two overdue bills of one flat, and without distinct the last line gave tower B 3 overdue flats instead of 2. outstanding is an annotation built from two others, F('billed') - F('paid'), in the same SELECT.

Argument What it adds to the SQL What it did in the report
filter=Q(…) COUNT(…) FILTER (WHERE …) counted the unpaid bills only, while the sums still saw every bill
distinct=True COUNT(DISTINCT flat.id) counted B-1205 once, though two of its bills are overdue
default=0 COALESCE(SUM(…), 0) gave tower C 0 paise instead of None

Django’s documentation says that databases without FILTER get the same condition as a CASE inside the aggregate, and that the two are equivalent, though FILTER may perform better. A filter() call on the QuerySet would be something else: a WHERE that removes the paid bills from every column, the billed and paid totals too.

Checkpoint

Check: Conditional counts and sums

  1. One question Without distinct=True, Count('flats', filter=overdue) counted joined rows. How many overdue flats did it give tower B?

    Type a number.

    A hint

    B-1205 has two overdue bills, August and September.

    Show the answer: One question

    Answer: 3 flats

    The filter keeps one joined row per overdue bill: B-101's September bill and B-1205's August and September bills. Counting flats in those rows gives 3, although only 2 flats are behind. distinct=True counts each flat id once.

Two relations in one annotate() multiply the rows

The committee also wants each tower’s complaints next to its bills. Two counts over two relations look like one more argument to annotate(), but they are not:

Counts over two relations: wrong, then right, and a sum still wrong Python · nivas/two_joins.py
"""Two aggregates over two different relations in one annotate(): the joins multiply the rows, and both counts come
out wrong. distinct=True puts the counts right, but not a sum next to them.

Run it next to manage.py. seed.py makes db.sqlite3 there with every table and the lesson's rows.
"""

import seed  # noqa: F401  (starts Django, creates the tables, adds the rows)
from django.db import connection
from django.db.models import Count, Sum
from django.test.utils import CaptureQueriesContext

from seed import compact
from society.models import Bill, Complaint, Tower


def show(label, towers, *names):
    print(label)
    for row in towers.order_by('name').values_list('name', *names):
        print('  ', row[0], dict(zip(names, row[1:])))


print('Tower A has', Bill.objects.filter(flat__tower__name='A').count(), 'bills and',
      Complaint.objects.filter(flat__tower__name='A').count(), 'complaints')
with CaptureQueriesContext(connection) as queries:
    show('1. two counts in one annotate():',
         Tower.objects.annotate(bills=Count('flats__bills'), complaints=Count('flats__complaints')),
         'bills', 'complaints')
show('2. the same counts with distinct=True:',
     Tower.objects.annotate(bills=Count('flats__bills', distinct=True),
                            complaints=Count('flats__complaints', distinct=True)), 'bills', 'complaints')
show('3. a sum next to a distinct count:',
     Tower.objects.annotate(billed=Sum('flats__bills__amount_paise', default=0),
                            complaints=Count('flats__complaints', distinct=True)), 'billed', 'complaints')
print('The SQL of 1:')
print(compact(queries[0]['sql']))

Output

Tower A has 9 bills and 7 complaints
1. two counts in one annotate():
   A {'bills': 21, 'complaints': 21}
   B {'bills': 21, 'complaints': 18}
   C {'bills': 0, 'complaints': 1}
2. the same counts with distinct=True:
   A {'bills': 9, 'complaints': 7}
   B {'bills': 9, 'complaints': 6}
   C {'bills': 0, 'complaints': 1}
3. a sum next to a distinct count:
   A {'billed': 4537500, 'complaints': 7}
   B {'billed': 6525000, 'complaints': 6}
   C {'billed': 0, 'complaints': 1}
The SQL of 1:
SELECT tower.name AS name, COUNT(bill.id) AS bills, COUNT(complaint.id) AS complaints FROM tower LEFT OUTER JOIN flat ON (tower.id = flat.tower_id) LEFT OUTER JOIN bill ON (flat.id = bill.flat_id) LEFT OUTER JOIN complaint ON (flat.id = complaint.flat_id) GROUP BY tower.id, 1, tower.floors ORDER BY 1 ASC

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 two_joins.py

Tower A has 9 bills and 7 complaints, and line 1 said 21 and 21. Each relation adds a LEFT OUTER JOIN, and the two joins pair every bill of a flat with every complaint of the same flat. Flat A-102 alone has 3 bills and 3 complaints, which become these 9 rows:

Joined row Bill Complaint
1 August Seepage in the bathroom
2 August Doorbell wiring
3 August Corridor light off
4 September Seepage in the bathroom
5 September Doorbell wiring
6 September Corridor light off
7 October Seepage in the bathroom
8 October Doorbell wiring
9 October Corridor light off

A-101 adds 3 × 1 rows and A-1101 3 × 3, so tower A’s group holds 3 + 9 + 9 = 21 rows, and COUNT counts rows. Django’s documentation warns about exactly this: aggregations over several relations in one annotate() come out wrong, because Django joins the relations where each would need a subquery of its own.

distinct=True puts the counts right, 9 and 7 in line 2, because it counts different ids, and a repeated row repeats the same id. It cannot repair a sum. Line 3 billed tower A 4,537,500 paise instead of 1,837,500: every bill was added once for each complaint of its flat, and DISTINCT on amounts would merge different bills that cost the same, such as A-101’s three bills of 162,500. A sum next to another relation needs a subquery of its own, which the section on Subquery builds.

Key idea

Every relation that one annotate() crosses multiplies the rows of the others: give each count distinct=True, and give a sum or an average its own subquery.

Checkpoint

Check: Joins that multiply rows

  1. Question 1 of 2 Flat B-1205 has 3 bills and 4 complaints. How many rows does it give the query that counts flats__bills and flats__complaints?

    Type a number.

    A hint

    Every bill of the flat is paired with every complaint of the same flat.

    Show the answer: Question 1 of 2

    Answer: 12 rows

    The two LEFT OUTER JOINs pair each of the 3 bills with each of the 4 complaints: 3 × 4 = 12 rows. Tower B's 21 bill rows are 3 from B-101, which has no complaint, 6 from B-1203 and these 12.

  2. Question 2 of 2 Which change gives each tower's correct total billed next to its complaint count, in one query?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Compute the sum in a Subquery that joins only the bills

    Django's documentation warns that aggregations over several relations come out wrong, because the relations are joined where each needs a subquery. distinct=True can repair a Count, never a Sum, so the sum gets its own subquery.

F(), Case and When compute a value for every row in SQL

Some values belong to each row, not to a group: what a bill still owes, whether it is paid, part paid or unpaid, whether it is overdue. F() expression: A reference to a column of the row the database is working on, written F('paid_paise'). Queries and updates use it to compare or compute with columns, as in F('amount_paise') - F('paid_paise'), without reading values into Python. and conditional expressions compute them in the same SELECT, and update() can use them too:

F(), Case and When, a Q for yes or no, F() in update(), and integer division Python · nivas/per_bill.py
"""Values worked out for every row inside the query: F() for columns, Case and When for choices, a Q object for a
yes or no, and F() in update() for a payment. Then the way integer division loses paise.

Run it next to manage.py. seed.py makes db.sqlite3 there with every table and the lesson's rows.
"""

import seed  # noqa: F401  (starts Django, creates the tables, adds the rows)
from django.db import connection
from django.db.models import Case, F, Q, Value, When
from django.test.utils import CaptureQueriesContext

from seed import TODAY, compact
from society.models import Bill

september = (
    Bill.objects.filter(month__month=9)
    .annotate(
        due=F('amount_paise') - F('paid_paise'),
        state=Case(
            When(paid_paise__gte=F('amount_paise'), then=Value('paid')),
            When(paid_paise=0, then=Value('unpaid')),
            default=Value('part paid'),
        ),
        overdue=Q(due_on__lt=TODAY) & Q(paid_paise__lt=F('amount_paise')),
    )
    .order_by('flat__tower__name', 'flat__number')
)
with CaptureQueriesContext(connection) as queries:
    print('flat     amount      paid       due  state      overdue')
    for tower, number, amount, paid, due, state, late in september.values_list(
            'flat__tower__name', 'flat__number', 'amount_paise', 'paid_paise', 'due', 'state', 'overdue'):
        print(f'{tower}-{number:<5} {amount:>8,} {paid:>9,} {due:>9,}  {state:<9}  {late}')
    paid_now = Bill.objects.filter(flat__tower__name='B', flat__number=101, month__month=9).update(
        paid_paise=F('paid_paise') + 100_000)
    print('update() with F() changed', paid_now, 'row; B-101 now owes',
          september.get(flat__tower__name='B', flat__number=101).due, 'for September')
print('The SQL of the report and of the update:')
for query in queries:
    print(' ', compact(query['sql']))

thirds = Bill.objects.filter(month__month=10, flat__tower__name='A', flat__number=101).annotate(
    third=F('amount_paise') / 3, lost=F('amount_paise') - 3 * (F('amount_paise') / 3),
    third_exact=F('amount_paise') / 3.0).values('amount_paise', 'third', 'lost', 'third_exact')
print('Splitting A-101 October bill three ways:', thirds.get())

Output

flat     amount      paid       due  state      overdue
A-101    162,500   162,500         0  paid       False
A-102    162,500         0   162,500  unpaid     True
A-1101   287,500   287,500         0  paid       False
B-101    200,000   100,000   100,000  part paid  True
B-1203   287,500   287,500         0  paid       False
B-1205   350,000         0   350,000  unpaid     True
update() with F() changed 1 row; B-101 now owes 0 for September
The SQL of the report and of the update:
  SELECT tower.name AS flat__tower__name, flat.number AS flat__number, bill.amount_paise AS amount_paise, bill.paid_paise AS paid_paise, (bill.amount_paise - bill.paid_paise) AS due, CASE WHEN bill.paid_paise >= (bill.amount_paise) THEN 'paid' WHEN bill.paid_paise = 0 THEN 'unpaid' ELSE 'part paid' END AS state, (bill.due_on < '2026-10-20' AND bill.paid_paise < (bill.amount_paise)) AS overdue FROM bill INNER JOIN flat ON (bill.flat_id = flat.id) INNER JOIN tower ON (flat.tower_id = tower.id) WHERE django_date_extract('month', bill.month) = 9 ORDER BY 1 ASC, 2 ASC
  UPDATE bill SET paid_paise = (bill.paid_paise + 100000) WHERE bill.id IN (SELECT U0.id FROM bill U0 INNER JOIN flat U1 ON (U0.flat_id = U1.id) INNER JOIN tower U2 ON (U1.tower_id = U2.id) WHERE (U1.number = 101 AND U2.name = 'B' AND django_date_extract('month', U0.month) = 9))
  SELECT bill.id, bill.flat_id, bill.month, bill.amount_paise, bill.paid_paise, bill.due_on, (bill.amount_paise - bill.paid_paise) AS due, CASE WHEN bill.paid_paise >= (bill.amount_paise) THEN 'paid' WHEN bill.paid_paise = 0 THEN 'unpaid' ELSE 'part paid' END AS state, (bill.due_on < '2026-10-20' AND bill.paid_paise < (bill.amount_paise)) AS overdue FROM bill INNER JOIN flat ON (bill.flat_id = flat.id) INNER JOIN tower ON (flat.tower_id = tower.id) WHERE (django_date_extract('month', bill.month) = 9 AND flat.number = 101 AND tower.name = 'B') LIMIT 21
Splitting A-101 October bill three ways: {'amount_paise': 162500, 'third': 54166, 'lost': 2, 'third_exact': 54166.666666666664}

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 per_bill.py

F('amount_paise') - F('paid_paise') became (bill.amount_paise - bill.paid_paise). The Case became CASE WHEN … THEN … ELSE … END, and a Q object given to annotate() became a true-or-false column, overdue. Step through how the CASE decided the state of two September bills:

Interactive · stage by stage How Case and When decide a bill's state Step through the CASE for B-101's September bill, then for A-101's.
How Case and When decide a bill's state, step 6 of 6. Showing B-101, September (A-101: paid in full), WHEN paid ≥ amount (true for A-101), WHEN paid = 0 (false for B-101), ELSE, The row's state (A-101: 'paid'). In focus: The row's state.true: 'paid'true: 'unpaid'B-101, SeptemberA-101: paid in fullWHEN paid ≥ amounttrue for A-101WHEN paid = 0false for B-101ELSE'part paid'The row's stateA-101: 'paid''paid'

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. One row at a time. The database works the CASE out for every row the query reads. B-101's September bill is for 200,000 paise, and 100,000 has been paid.
  2. The first When. When clauses are tried in order. Is 100,000 at least 200,000? No, so the first When does not decide this row.
  3. The second When. Is 100,000 equal to 0? No again, so the second When does not decide either.
  4. The default. No When was true, so the default decides: 'part paid'. Without a default the CASE would give NULL, which Python reads as None.
  5. The state column. The row leaves the database with state 'part paid', a column of the SELECT like any other. Python receives text, and no Python code decided anything.
  6. A row the first When decides. A-101 paid its September bill in full, so the first When is true and the CASE stops there: 'paid'. The later clauses are never tried for that row.

The clauses are tried in order and the first true one decides, as if, elif and else do in Python, so their order is part of the rule. With no default, a row that no When matches gets NULL, which Python reads as None.

update(paid_paise=F('paid_paise') + 100_000) sent one UPDATE that adds the payment to whatever the row holds when the statement runs, without reading the bill into Python first; B-101 then owed nothing for September. The lesson on transactions in this module uses the same F() to stop two bookings overwriting each other’s count of free places.

Integer division drops paise

Splitting A-101’s October bill three ways with F('amount_paise') / 3 gave 54,166 paise: both sides are integers, so SQLite divided as integers and dropped the fraction, and three such shares are 2 paise short of the 162,500 billed. Dividing by 3.0 gave a float, which is no better for money. Divide in whole paise and give the remainder to one of the shares.

Checkpoint

Check: F(), Case and When

  1. Question 1 of 2 A waived month has amount_paise 0 and paid_paise 0. Which state does per_bill.py's Case give it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 'paid'

    Case works like if, elif and else: the first true condition decides, which is why the order of the When clauses is part of the rule.

  2. Question 2 of 2 What did F('amount_paise') / 3 give for A-101's October bill of 162,500 paise?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 54,166, two paise short of a third once multiplied back

    The run printed 'third': 54166 and 'lost': 2. To split money, divide in integers and give the remainder to one of the shares, so the shares add up to the bill.

Subquery and Exists ask a second question about every row

The dues page also shows each flat’s latest bill and whether it has anything overdue: a question about other rows, asked once for every flat. Subquery with OuterRef writes it as a query inside the query, a correlated subquery: A query inside another query that refers to the outer query's current row, written with Subquery() or Exists() and OuterRef() in Django, so that it answers once for every row of the outer query., and Exists asks only whether such a row exists:

Subquery, OuterRef and Exists, and a sum kept apart from a join Python · nivas/subqueries.py
"""Subquery and OuterRef ask a second question about every row: each flat's latest bill. Exists asks whether a row of
another table matches: does the flat have an overdue bill? Last, a subquery keeps a sum away from another join.

Run it next to manage.py. seed.py makes db.sqlite3 there with every table and the lesson's rows.
"""

import seed  # noqa: F401  (starts Django, creates the tables, adds the rows)
from django.db import connection
from django.db.models import Count, Exists, F, OuterRef, Subquery, Sum
from django.db.models.functions import Coalesce
from django.test.utils import CaptureQueriesContext

from seed import TODAY, compact
from society.models import Bill, Flat, Tower

latest = Bill.objects.filter(flat=OuterRef('pk')).order_by('-month')
overdue = Bill.objects.filter(flat=OuterRef('pk'), paid_paise__lt=F('amount_paise'), due_on__lt=TODAY)

with CaptureQueriesContext(connection) as queries:
    flats = Flat.objects.annotate(
        last_month=Subquery(latest.values('month')[:1]),
        last_due=Subquery(latest.annotate(due=F('amount_paise') - F('paid_paise')).values('due')[:1]),
        owes=Exists(overdue),
    ).order_by('tower__name', 'number')
    print('flat     latest bill  still due  has an overdue bill')
    for tower, number, month, due, owes in flats.values_list('tower__name', 'number', 'last_month', 'last_due', 'owes'):
        month = month.strftime('%B') if month else 'none'  # Subquery gives None when a flat has no bill
        due = f'{due:,}' if due is not None else 'None'
        print(f'{tower}-{number:<5}  {month:<11}  {due:>9}  {owes}')
    late = Flat.objects.filter(Exists(overdue)).order_by('tower__name', 'number')
    print('filter(Exists(...)):', [f'{t}-{n}' for t, n in late.values_list('tower__name', 'number')])
joined = Flat.objects.filter(bills__paid_paise__lt=F('bills__amount_paise'), bills__due_on__lt=TODAY)
print('the same through a join:', [f'{t}-{n}' for t, n in joined.order_by('tower__name', 'number')
                                   .values_list('tower__name', 'number')])
billed = (Bill.objects.filter(flat__tower=OuterRef('pk')).values('flat__tower')
          .annotate(total=Sum('amount_paise')).values('total'))
towers = Tower.objects.annotate(billed=Coalesce(Subquery(billed), 0), complaints=Count('flats__complaints'))
print('billed in a subquery, next to the complaints:', list(towers.order_by('name').values_list('name', 'billed',
                                                                                              'complaints')))
print('The SQL of the first query:')
print(compact(queries[0]['sql']))

Output

flat     latest bill  still due  has an overdue bill
A-101    October              0  False
A-102    October        162,500  True
A-1101   October        187,500  False
B-101    October        200,000  True
B-1203   October              0  False
B-1205   October        350,000  True
C-101    none              None  False
filter(Exists(...)): ['A-102', 'B-101', 'B-1205']
the same through a join: ['A-102', 'B-101', 'B-1205', 'B-1205']
billed in a subquery, next to the complaints: [('A', 1837500, 7), ('B', 2512500, 6), ('C', 0, 1)]
The SQL of the first query:
SELECT tower.name AS tower__name, flat.number AS number, (SELECT U0.month AS month FROM bill U0 WHERE U0.flat_id = (flat.id) ORDER BY 1 DESC LIMIT 1) AS last_month, (SELECT (U0.amount_paise - U0.paid_paise) AS due FROM bill U0 WHERE U0.flat_id = (flat.id) ORDER BY U0.month DESC LIMIT 1) AS last_due, EXISTS(SELECT 1 AS a FROM bill U0 WHERE (U0.due_on < '2026-10-20' AND U0.flat_id = (flat.id) AND U0.paid_paise < (U0.amount_paise)) LIMIT 1) AS owes FROM flat INNER JOIN tower ON (flat.tower_id = tower.id) ORDER BY 1 ASC, 2 ASC

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 subqueries.py

OuterRef('pk') stands for the id of the flat the outer query is on, so the subquery’s condition became U0.flat_id = (flat.id). Used as a column, a subquery must return one column and at most one row, which values('month') and [:1] see to. Step through the two subqueries for A-102 and for C-101, the flat without a bill:

Interactive · message by message A subquery for every flat Step through the first query of subqueries.py for two flats. See what OuterRef stands for each time.
A subquery for every flat, step 6 of 6. Messages so far: Flat query to Bill subqueries: flat_id = A-102's id; Bill subqueries to Result row: October, 162,500 due; Bill subqueries to Result row: owes: True; Flat query to Bill subqueries: flat_id = C-101's id.Flat queryone row per flatBill subqueriesno row for C-101Result rowC-101: None, owes Falseflat_id = A-102's idOctober, 162,500 dueowes: Trueflat_id = C-101's id

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The outer query. The outer query reads the flats, A-101 first. For each of them the database works out the columns of the SELECT, and two of them are subqueries.
  2. OuterRef is A-102's id. On A-102's row, OuterRef('pk') stands for A-102's id, so the first subquery reads only A-102's bills, newest month first, and keeps one: LIMIT 1.
  3. One value comes back. The subquery returns one column of one row: October, the month of the latest bill. The second Subquery does the same for what that bill still owes, 162,500 paise.
  4. EXISTS for A-102. EXISTS asks whether any bill of A-102 is unpaid and past its due date. The September bill is, so the answer is true, and the database stops at that first row.
  5. C-101 has no bill. On C-101's row the subquery finds no bill at all. An empty subquery gives NULL, which Python reads as None, and EXISTS gives false.
  6. Each flat once. Every flat gets exactly one result row. The join version of the same question returned B-1205 twice, once for each of its two overdue bills; EXISTS cannot repeat a flat.

Exists became EXISTS(SELECT 1 … LIMIT 1), true or false, and filter(Exists(overdue)) kept each flat with an overdue bill once. Filtering through the join instead, with bills__ lookups, returned B-1205 twice, once for each overdue bill. A subquery that finds nothing gives NULL, as C-101’s None shows; Coalesce(Subquery(…), 0) turns it into 0.

The last line is the repair that the section on joins needed. The tower’s total is a subquery that joins only the bills, grouped by tower, so the complaints join beside it cannot multiply it: tower A is back to 1,837,500 paise, next to its 7 complaints, in one query.

Key idea

A subquery asks a question about other rows once for every row of the outer query: OuterRef names the outer row, a column subquery returns one value, and Exists returns true or false without repeating any row.

Checkpoint

Check: Subquery and Exists

  1. Question 1 of 2 Why does the latest-bill subquery end in .values('month')[:1]?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A Subquery used as a column must return one column and at most one row

    The SQL shows (SELECT U0.month … ORDER BY 1 DESC LIMIT 1) AS last_month. Without [:1] the subquery would return every bill of the flat: PostgreSQL refuses a column subquery with more than one row, and SQLite quietly uses the first, so the code would work on one database and fail on the other.

  2. Question 2 of 2 Filtering flats through bills__ returned B-1205 twice. Which query returns each flat with an overdue bill once?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Flat.objects.filter(Exists(overdue))

    filter(Exists(overdue)) gave A-102, B-101 and B-1205; the join gave B-1205 twice, once per overdue bill. distinct() would also remove the repeat, but EXISTS never makes it.

Window functions rank the flats of each tower without merging them

Which flat of each tower has the most open complaints? GROUP BY tower would merge the flats into one row per tower. A window function: An SQL function such as RANK() or ROW_NUMBER() that gives every row a value computed from the rows of its partition, without merging them as GROUP BY does. Django writes it as Window(Rank(), partition_by=…, order_by=…). keeps every row and gives it a value computed from the other rows of its partition:

RANK, DENSE_RANK and ROW_NUMBER inside each tower Python · nivas/rank_flats.py
"""Window functions rank the flats of each tower by their open complaints, without merging the flats into one row
per tower. RANK, DENSE_RANK and ROW_NUMBER differ only on a tie, and tower A has one.

Run it next to manage.py. seed.py makes db.sqlite3 there with every table and the lesson's rows.
"""

import sqlite3

import seed  # noqa: F401  (starts Django, creates the tables, adds the rows)
from django.db import connection
from django.db.models import Count, F, Q, Window
from django.db.models.functions import DenseRank, Rank, RowNumber
from django.test.utils import CaptureQueriesContext

from seed import compact
from society.models import Flat

by_tower = {'partition_by': F('tower'), 'order_by': F('open').desc()}
ranked = (
    Flat.objects.annotate(open=Count('complaints', filter=Q(complaints__status='open')))
    .annotate(
        rank=Window(Rank(), **by_tower),
        dense_rank=Window(DenseRank(), **by_tower),
        row_number=Window(RowNumber(), partition_by=F('tower'), order_by=[F('open').desc(), F('number')]),
    )
    .order_by('tower__name', 'row_number')
)
with CaptureQueriesContext(connection) as queries:
    print('SQLite', sqlite3.sqlite_version)
    print('flat     open  rank  dense_rank  row_number')
    for tower, number, open_, rank, dense, row in ranked.values_list(
            'tower__name', 'number', 'open', 'rank', 'dense_rank', 'row_number'):
        print(f'{tower}-{number:<5} {open_:>5} {rank:>5} {dense:>11} {row:>11}')
    top = ranked.filter(rank=1)
    print('filter(rank=1):', [f'{t}-{n}' for t, n in top.values_list('tower__name', 'number')])
print('The SQL of filter(rank=1):')
print(compact(queries[-1]['sql']))

Output

SQLite 3.51.0
flat     open  rank  dense_rank  row_number
A-102       3     1           1           1
A-1101      3     1           1           2
A-101       0     3           2           3
B-1205      4     1           1           1
B-1203      1     2           2           2
B-101       0     3           3           3
C-101       1     1           1           1
filter(rank=1): ['A-102', 'A-1101', 'B-1205', 'C-101']
The SQL of filter(rank=1):
SELECT tower__name, number FROM ( SELECT * FROM ( SELECT tower.name AS tower__name, flat.number AS number, RANK() OVER (PARTITION BY flat.tower_id ORDER BY COUNT(complaint.id) FILTER (WHERE (complaint.status = 'open')) DESC) AS qual0, ROW_NUMBER() OVER (PARTITION BY flat.tower_id ORDER BY COUNT(complaint.id) FILTER (WHERE (complaint.status = 'open')) DESC, flat.number) AS qual1 FROM flat LEFT OUTER JOIN complaint ON (flat.id = complaint.flat_id) INNER JOIN tower ON (flat.tower_id = tower.id) GROUP BY flat.id, flat.tower_id, 2, flat.area_sqft, 1 ORDER BY 1 ASC, ROW_NUMBER() OVER (PARTITION BY flat.tower_id ORDER BY COUNT(complaint.id) FILTER (WHERE (complaint.status = 'open')) DESC, flat.number) ASC ) qualify WHERE qual0 = 1 ) qualify_mask ORDER BY 1 ASC, qual1 ASC

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rank_flats.py

Window(Rank(), partition_by=F('tower'), order_by=F('open').desc()) became RANK() OVER (PARTITION BY flat.tower_id ORDER BY … DESC), ordered here by an aggregate, the count of open complaints. The three functions agree until two rows tie, and tower A has a tie:

Interactive · step by step RANK, DENSE_RANK and ROW_NUMBER on a tie Step through the ranks of towers A and B. Tower A has a tie; watch how each function handles it.
RANK, DENSE_RANK and ROW_NUMBER on a tie, step 6 of 6. Showing A-102 (rank 1 · row_number 1), A-1101 (rank 1 · row_number 2), A-101 (rank 3 · row_number 3), B-1205 (4 open · rank 1), B-1203 (1 open · rank 2), B-101 (0 open · rank 3). In focus: A-102, A-1101, B-1205.A-102rank 1 · row_number 1A-1101rank 1 · row_number 2A-101rank 3 · row_number 3B-12054 open · rank 1B-12031 open · rank 2B-1010 open · rank 3

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. One partition per tower. PARTITION BY tower ranks each tower on its own: tower A in the left column, tower B in the right. ORDER BY open DESC puts the most open complaints first.
  2. A tie shares rank 1. A-102 and A-1101 both have 3 open complaints. RANK gives both of them 1, and so does DENSE_RANK.
  3. RANK skips after a tie. Two flats are ahead of A-101, so RANK gives it 3: the rank of a row is 1 plus the number of rows before it with a higher count. DENSE_RANK gives 2, with no gap.
  4. ROW_NUMBER never ties. ROW_NUMBER numbers the rows 1, 2, 3 whatever the counts. On the tie it follows the second ORDER BY of the query, the flat number, so A-102 is 1 and A-1101 is 2.
  5. Tower B starts again. Tower B is a new partition, so its counting starts again at 1. With 4, 1 and 0 open complaints there is no tie, and the three functions agree: 1, 2, 3.
  6. filter(rank=1). Filtering on the rank keeps A-102, A-1101, B-1205 and C-101, the only flat of tower C. Django puts the ranked query inside an outer SELECT and filters there.

RANK gave A-102 and A-1101 rank 1 and A-101 rank 3; DENSE_RANK gave 1, 1 and 2; ROW_NUMBER never ties, so it needs a second order for the tie, here the flat number, or the database decides which tied flat comes first.

Filtering on a window, filter(rank=1), needed an extra step. PostgreSQL’s documentation explains why: window functions are worked out after WHERE, so a WHERE cannot test them. Django put the ranked query inside an outer SELECT and filtered there; its documentation lists one limit, a filter that joins window conditions with OR or XOR in a query that aggregates.

Checkpoint

Check: Window functions

  1. Question 1 of 2 Four flats of a tower have 5, 5, 5 and 2 open complaints. What does RANK() give the flat with 2?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 4

    The three tied flats all get 1, as A-102 and A-1101 did in tower A, and the next rank is 1 plus the number of rows before it: 4. DENSE_RANK would give 2 and ROW_NUMBER 4.

  2. Question 2 of 2 rank_flats.py orders ROW_NUMBER by the open count and then by the flat number. What would go wrong without the flat number?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Which of the tied flats A-102 and A-1101 gets 1 would be up to the database, and could change

    A tie leaves the order of the tied rows to the database, which may change with an index or a plan. Adding a field that is unique within the partition, here the flat number, makes ROW_NUMBER repeatable.

One query instead of a loop: 408 queries became 1

The numbers of the opening come from this program. It adds a second estate of 20 towers with 20 flats each and 12 monthly bills a flat, then works out what every tower is owed twice: in Python over the rows, and in one annotate():

What every tower is owed: a Python loop against one annotated query Python · nivas/loop_vs_query.py
"""The same report twice: what every tower is still owed, worked out by a Python loop over the rows and by one
annotate(). The program counts the queries and the rows each way reads, and times both (the best of 5 runs).

Run it next to manage.py. seed.py makes db.sqlite3 there with the lesson's rows; this program adds a second estate
of 20 towers with 20 flats each and 12 monthly bills a flat, so that there is something to measure.
"""

from datetime import date
from time import perf_counter

import seed  # noqa: F401  (starts Django, creates the tables, adds the rows)
from django.db import connection
from django.db.models import F, Sum
from django.test.utils import CaptureQueriesContext

from society.models import Bill, Flat, Tower

towers = Tower.objects.bulk_create([Tower(name=f'G{i}', floors=20) for i in range(1, 21)])
flats = Flat.objects.bulk_create([Flat(tower=t, number=100 * floor + 1, area_sqft=1000)
                                  for t in towers for floor in range(1, 21)])
months = [date(2025, 11, 1), date(2025, 12, 1)] + [date(2026, m, 1) for m in range(1, 11)]
Bill.objects.bulk_create([Bill(flat=f, month=m, amount_paise=250_000, due_on=m.replace(day=25),
                               paid_paise=0 if (f.pk + m.month) % 4 == 0 else 250_000)
                          for f in flats for m in months])
print(f'{Tower.objects.count()} towers, {Flat.objects.count()} flats, {Bill.objects.count():,} bills')


def in_python():
    """One query for the flats, then one for each flat's bills, and the sums in Python."""
    owed, rows = {}, 0
    for flat in Flat.objects.select_related('tower'):
        rows += 1
        for bill in flat.bills.all():
            rows += 1
            owed[flat.tower.name] = owed.get(flat.tower.name, 0) + bill.amount_paise - bill.paid_paise
        owed.setdefault(flat.tower.name, 0)
    return owed, rows


def in_the_database():
    """One query: the database sums each tower's bills and returns one row per tower."""
    towers = Tower.objects.annotate(
        owed=Sum(F('flats__bills__amount_paise') - F('flats__bills__paid_paise'), default=0))
    owed = {tower.name: tower.owed for tower in towers}
    return owed, len(owed)


results = {}
for way in (in_python, in_the_database):
    best = None
    for _ in range(5):
        with CaptureQueriesContext(connection) as queries:
            start = perf_counter()
            owed, rows = way()
            seconds = perf_counter() - start
        best = seconds if best is None else min(best, seconds)
    results[way.__name__] = (owed, best)
    count = f'{len(queries)} {"query" if len(queries) == 1 else "queries"}'
    print(f'{way.__name__:<16} {count:>11} {rows:>6,} rows read   best of 5: {best * 1000:.1f} ms')
python_owed, python_time = results['in_python']
db_owed, db_time = results['in_the_database']
print('same totals:', python_owed == db_owed, '| owed in all:', f'{sum(db_owed.values()):,}', 'paise')
print('the database was at least 20 times faster in this run:', python_time > 20 * db_time)

Output

23 towers, 407 flats, 4,818 bills
in_python        408 queries  5,225 rows read   best of 5: 163.5 ms
in_the_database      1 query     23 rows read   best of 5: 1.2 ms
same totals: True | owed in all: 301,862,500 paise
the database was at least 20 times faster in this run: True

This output changes from run to run: the times depend on the computer and on what else it is doing; the queries, the rows and the totals do not

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 loop_vs_query.py

in_python() sent 1 query for the flats, with their towers joined in by select_related(), and then 1 for the bills of each of the 407 flats: 408 round trips, and a Python object for each of the 5,225 rows it read. in_the_database() sent one query; the database read the same bills, added them up and sent back 23 rows, one per tower. The totals were the same, 301,862,500 paise, and the last line checks that the query was more than 20 times faster: the round trips and the objects are the cost.

Moving work into the database is right when the result is a summary of many rows. When each row needs Python, a function the database cannot run, keep the loop, but fetch the rows it needs in bulk; the next lesson of this module measures how.

SQL Formatter Paste one of the compact SQL lines from this page to read it one clause at a time.

Checkpoint

Check: A loop moved into the database

  1. One question in_python() sends one query for the flats and one for each flat's bills. How many queries does it send for 1,000 flats?

    Type a number.

    Show the answer: One question

    Answer: 1001 queries

    One query reads the flats, with their towers through select_related, and each flat's bills cost one more: 1 + 1,000. For the 407 flats of the run it sent 408, while the annotated query sent 1 whatever the number of flats.

Practise: the dues report and the ranks by hand

What the database did for the dues report and for RANK(), written in plain Python on rows that are dictionaries and tuples. First the report, with its empty tower and its flats counted once; then the ranks within each tower, ties included. Each step has tests you can run here and a hint:

Code lab · 2 steps

The dues report and the ranks by hand

Sum, count and rank the society's bills and complaints the way the annotated queries of this lesson do, in plain Python.

  1. Write the dues report in plain Python To do
  2. Rank the flats of each tower, ties included To do

Exercise · Medium · Python

Write the dues report in plain Python

The dues report of this lesson is one query: towers LEFT OUTER JOIN their flats and bills, Sum(…, default=0) for the money and Count('flats', filter=…, distinct=True) for the flats with an overdue bill. Write the same report in plain Python, on bills that are dictionaries:

{'flat': 'B-1205', 'amount': 350000, 'paid': 0, 'due_on': date(2026, 9, 25)}

dues_summary(towers, bills, today) returns one dictionary per tower, in the order of towers: {'tower': 'B', 'billed': …, 'paid': …, 'outstanding': …, 'overdue_flats': …}, with the money in paise.

  • A flat's tower is the part of its label before the hyphen.
  • A tower without a single bill is still in the result, with zeros, as default=0 gives.
  • A bill is overdue when it is not fully paid and its due date is before today. A flat with two overdue bills is one overdue flat.

Starter code · dues.py

def dues_summary(towers, bills, today):
    """One dict per tower of `towers`, in that order: billed, paid and outstanding paise, and the overdue flats."""
    report = {}
    for bill in bills:
        tower = bill['flat'].split('-')[0]
        row = report.setdefault(tower, {'tower': tower, 'billed': 0, 'paid': 0, 'outstanding': 0, 'overdue_flats': 0})
        row['billed'] += bill['amount']
        row['paid'] += bill['paid']
        row['outstanding'] += bill['amount'] - bill['paid']
        if bill['paid'] < bill['amount'] and bill['due_on'] < today:
            row['overdue_flats'] += 1
    return list(report.values())
The sample tests · test_dues.py
from datetime import date

from dues import dues_summary

TODAY = date(2026, 10, 20)
AREAS = {'A-101': 650, 'A-102': 650, 'A-1101': 1150, 'B-101': 800, 'B-1203': 1150, 'B-1205': 1400}
PAID = {  # the August, September and October bills of the lesson: 'all', 'none' or paise
    'A-101': ['all', 'all', 'all'], 'A-102': ['all', 'none', 'none'], 'A-1101': ['all', 'all', 100_000],
    'B-101': ['all', 100_000, 'none'], 'B-1203': ['all', 'all', 'all'], 'B-1205': ['none', 'none', 'none'],
}
BILLS = []
for flat, shares in PAID.items():
    amount = AREAS[flat] * 250
    for month, share in zip((8, 9, 10), shares):
        paid = {'all': amount, 'none': 0}.get(share, share)
        BILLS.append({'flat': flat, 'amount': amount, 'paid': paid, 'due_on': date(2026, month, 25)})


def row(tower, billed, paid, overdue):
    return {'tower': tower, 'billed': billed, 'paid': paid, 'outstanding': billed - paid, 'overdue_flats': overdue}


def test_the_lesson_report():
    """the numbers of dues_report.py for towers A and B"""
    assert dues_summary(['A', 'B'], BILLS, TODAY) == [row('A', 1837500, 1325000, 1), row('B', 2512500, 1162500, 2)]


def test_tower_without_bills():
    """tower C has no bill yet: it is in the report, with zeros"""
    assert dues_summary(['A', 'B', 'C'], BILLS, TODAY)[2] == row('C', 0, 0, 0)


def test_order_of_towers():
    """the rows follow the order of towers, not the order of the bills"""
    assert [r['tower'] for r in dues_summary(['C', 'B', 'A'], BILLS, TODAY)] == ['C', 'B', 'A']


def test_flat_counts_once():
    """B-1205 has two overdue bills but is one overdue flat"""
    late = [b for b in BILLS if b['flat'] == 'B-1205']
    assert dues_summary(['B'], late, TODAY) == [row('B', 1050000, 0, 1)]


def test_due_today_is_not_overdue():
    """a bill is overdue only when its due date is before today"""
    bill = {'flat': 'A-102', 'amount': 162500, 'paid': 0, 'due_on': TODAY}
    assert dues_summary(['A'], [bill], TODAY) == [row('A', 162500, 0, 0)]


def test_part_paid_is_unpaid():
    """a bill paid in part is still unpaid, and overdue once its date has passed"""
    bill = {'flat': 'B-101', 'amount': 200000, 'paid': 100000, 'due_on': date(2026, 9, 25)}
    assert dues_summary(['B'], [bill], TODAY) == [row('B', 200000, 100000, 1)]
A hint

Start from the towers, not from the bills: make a row of zeros for every name in towers first, so a tower with no bill keeps its row. For the overdue flats keep a set of flat labels per tower, the way distinct=True counts each flat once, and count the set at the end.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · Python

Rank the flats of each tower, ties included

rank_flats.py asked the database for Window(Rank(), partition_by=F('tower'), order_by=F('open').desc()). Do the same in plain Python.

rank_flats(rows) gets (tower, number, open_complaints) tuples in any order and returns (tower, number, open_complaints, rank) tuples, sorted by tower, then rank, then flat number. The rank works the way SQL's RANK() does:

  • Each tower is ranked on its own: the flat with the most open complaints in a tower has rank 1.
  • Flats with the same number of complaints in a tower share a rank.
  • After a tie the next rank skips, as in 1, 1, 3: the rank of a flat is 1 plus the number of flats of its tower with more complaints.

Starter code · ranks.py

def rank_flats(rows):
    """(tower, number, open, rank) for each flat, ranked within its tower by open complaints, most first."""
    ranked = []
    for tower in sorted({tower for tower, _, _ in rows}):
        flats = sorted((row for row in rows if row[0] == tower), key=lambda row: (-row[2], row[1]))
        for position, (_, number, open_complaints) in enumerate(flats, start=1):
            ranked.append((tower, number, open_complaints, position))
    return ranked
The sample tests · test_ranks.py
from ranks import rank_flats

LESSON = [('A', 101, 0), ('A', 102, 3), ('A', 1101, 3), ('B', 101, 0), ('B', 1203, 1), ('B', 1205, 4), ('C', 101, 1)]


def test_the_lesson_ranks():
    """the RANK column of rank_flats.py"""
    assert rank_flats(LESSON) == [
        ('A', 102, 3, 1), ('A', 1101, 3, 1), ('A', 101, 0, 3),
        ('B', 1205, 4, 1), ('B', 1203, 1, 2), ('B', 101, 0, 3),
        ('C', 101, 1, 1),
    ]


def test_tie_shares_a_rank_and_the_next_skips():
    """two flats with 3 complaints are both 1, and the next one is 3, not 2"""
    ranks = [rank for _, _, _, rank in rank_flats([('A', 101, 0), ('A', 102, 3), ('A', 1101, 3)])]
    assert ranks == [1, 1, 3]


def test_towers_are_ranked_apart():
    """C-101 is first in tower C, though tower A has flats with more complaints"""
    assert ('C', 101, 1, 1) in rank_flats(LESSON)


def test_any_input_order():
    """the input order does not change the result"""
    assert rank_flats(list(reversed(LESSON))) == rank_flats(LESSON)


def test_everyone_tied():
    """when every flat of a tower has the same count, all share rank 1"""
    assert rank_flats([('B', 101, 2), ('B', 1203, 2), ('B', 1205, 2)]) == [
        ('B', 101, 2, 1), ('B', 1203, 2, 1), ('B', 1205, 2, 1)]
A hint

Group the rows by tower in a dictionary first. Within one tower, a flat's rank needs no sorting at all: count the flats of the same tower that have more open complaints, and add 1. Sort the finished tuples at the end with a key of (tower, rank, number).

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then the quiz on the seven ideas of this lesson, from the shape of aggregate() to the loop moved into the database; it starts with the idea where your answers have been weakest:

Practice that adapts to you

Practise this lesson

14 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 14 What does Flat.objects.annotate(bill_count=Count('bills')) return?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: A QuerySet of flats, each with a bill_count attribute

    Line 3 of totals.py listed seven flats with their counts, C-101 with 0. The name bill_count had to differ from bills, the related name: annotate(bills=…) raised "The annotation 'bills' conflicts with a field on the model."

  2. Question 2 of 14 Bill.objects.values('flat__tower__name').annotate(n=Count('id')).order_by('flat__number') gave six rows instead of two. Why?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: The order_by() field joined the GROUP BY, so the groups became tower and flat number

    Django's documentation warns that fields in order_by() are part of the grouping. Order such a query by the grouped field, or call order_by() with no arguments.

  3. Question 3 of 14 What did Count('flats__bills', filter=unpaid) become in the SQL on SQLite?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: COUNT(bill.id) FILTER (WHERE bill.paid_paise < bill.amount_paise)

    On databases without FILTER, Django writes the same condition as a CASE inside the aggregate. Either way the other columns of the report still see every bill.

  4. Question 4 of 14 What does Sum('flats__bills__paid_paise', default=0) add to the SQL?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: COALESCE(SUM(bill.paid_paise), 0)

    Django's documentation says default works by wrapping the aggregate in Coalesce. Count takes no default because it already gives 0 for an empty group.

  5. Question 5 of 14 An annotate() crosses two relations of a flat. Which aggregate can distinct=True make right?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: A Count of ids

    For counts, distinct=True; for sums and averages, a subquery per relation, which keeps each relation's rows out of the other's join.

  6. Question 6 of 14 Tower B's bills were counted as 21. B-101 has 3 bills and no complaint, B-1203 3 bills and 2 complaints. How many rows came from B-1205?

    Type a number.

    A hint

    A flat without complaints still gives one row per bill, with NULL for the complaint.

    Show the answer: Question 6 of 14

    Answer: 12 rows

    B-101 gives 3 rows, B-1203 3 × 2 = 6, and the rest of the 21 is B-1205's 3 bills × 4 complaints = 12.

  7. Question 7 of 14 In per_bill.py, what did overdue=Q(due_on__lt=TODAY) & Q(paid_paise__lt=F('amount_paise')) add to each row?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: A true or false column worked out by the database

    The September rows printed True for A-102, B-101 and B-1205 and False for the bills paid in full.

  8. Question 8 of 14 What does Bill.objects.filter(pk=bill_id).update(paid_paise=F('paid_paise') + 100_000) send?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: One UPDATE that adds 100,000 to the value already in the row

    per_bill.py's update sent UPDATE bill SET paid_paise = (bill.paid_paise + 100000) … and reported 1 row; B-101 then owed 0 for September.

  9. Question 9 of 14 In Bill.objects.filter(flat=OuterRef('pk')), what does OuterRef('pk') stand for?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: The id of the row the outer query is on

    OuterRef cannot be resolved on its own, which is why such a QuerySet is only used inside Subquery() or Exists().

  10. Question 10 of 14 C-101 has no bill. Which value did last_due, a Subquery annotation, give it, and how do you get 0 instead?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: None; wrap it in Coalesce(Subquery(…), 0)

    The last line of subqueries.py used the same Coalesce around the tower's billed subquery, so tower C read 0.

  11. Question 11 of 14 How does a window function differ from GROUP BY with an aggregate?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: It keeps every row and gives each a value computed from the rows of its partition

    PARTITION BY says which rows belong together and ORDER BY how to order them inside each partition; the rows themselves stay.

  12. Question 12 of 14 Five flats have 4, 4, 2, 2 and 1 open complaints. What does DENSE_RANK() give them, most complaints first?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: 1, 1, 2, 2, 3

    In tower A, DENSE_RANK gave A-101 2 after the tie at 3 complaints, where RANK gave 3.

  13. Question 13 of 14 Where did most of the in_python() time go in loop_vs_query.py?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: In 408 round trips to the database and in building a Python object for each of 5,225 rows

    Both ways read every bill. The loop pays for a query per flat and an object per row; the database pays for one query and returns one row per tower.

  14. Question 14 of 14 The loop must stay in Python, because each bill's amount goes through a function. Which change cuts its 408 queries to 2?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: Flat.objects.select_related('tower').prefetch_related('bills')

    When the work must happen in Python, fetch the related rows in bulk; the next lesson of this module measures select_related, prefetch_related and the fetch modes that remove such loops of queries.

Interview questions

Warm-up (fresher): What is the difference between aggregate() and annotate()?

aggregate() computes over the whole QuerySet and returns a dictionary, such as {'amount_paise__sum': 4350000}; it ends the QuerySet, so nothing can be chained after it. annotate() returns a QuerySet in which each object carries the computed value, such as each flat’s number of bills, and it can be filtered, ordered and annotated again. Called after values(), annotate() groups by the fields values() names, which gives one row per group, for example per tower. Both run in the database, as one query.

Two design questions of this module start from this lesson’s queries: a dashboard whose counts come out too high, and a slow report to rewrite as queries.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A society dashboard shows each tower's bills, amount billed and complaints, from one annotate(). Since complaints were added to it, the bill counts and the amounts have both come out about three times too high. Explain the cause, and fix it so that the page still sends one query.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    The monthly dues report loops over 2,000 flats in Python, reads every bill and takes 9 seconds. Rewrite it so that the database does the work: what goes into annotate(), how towers without bills and part-paid bills are handled, and how you prove the new report gives the same numbers.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • aggregate() returns one dictionary for the whole QuerySet, annotate() gives every object a value, and values() before annotate() decides the groups, while a field you order by joins the GROUP BY.
  • filter= counts or sums only matching rows, distinct=True counts each related object once, and default=0 turns the NULL of an empty group into 0.
  • Two relations in one annotate() multiply each other’s rows: counts need distinct=True, and a sum needs a subquery of its own.
  • F(), Case, When and Q compute a value for every row inside the SQL, and F() in update() changes a row from its current value; integer columns divide as integers.
  • Subquery with OuterRef asks one value about other rows for each row, and Exists asks yes or no without repeating rows.
  • Window functions such as RANK() rank rows inside each partition and keep every row; one annotated query replaced a loop of 408 queries with the same totals.

Terms in this lesson

aggregate function
A function that turns many rows into one value, such as SUM, COUNT, AVG, MIN and MAX in SQL, or Sum, Count, Avg, Min and Max in Django. Over no rows every one of them but COUNT gives NULL.
annotation
A value that annotate() adds to every object of a QuerySet, worked out by the database in the same query, such as each flat's number of bills. It is read as an attribute of the object.
correlated subquery
A query inside another query that refers to the outer query's current row, written with Subquery() or Exists() and OuterRef() in Django, so that it answers once for every row of the outer query.
F() expression
A reference to a column of the row the database is working on, written F('paid_paise'). Queries and updates use it to compare or compute with columns, as in F('amount_paise') - F('paid_paise'), without reading values into Python.
window function
An SQL function such as RANK() or ROW_NUMBER() that gives every row a value computed from the rows of its partition, without merging them as GROUP BY does. Django writes it as Window(Rank(), partition_by=…, order_by=…).

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.