Django Module 3 – Models and the ORM
Django QuerySets: create, read, update, delete
Query a Django database with QuerySets: create, read, update and delete rows, filter with field lookups, keep queries lazy, and order results so pages repeat.
What you will learn
- Create, read, update and delete rows with the ORM, and predict the SQL each call sends
- Filter with field lookups and Q objects, and chain QuerySets that stay lazy until they are read
- Choose get(), filter(), first(), exists() or count() for each question you ask the database
- Order results so that every run returns them in the same order, and read plain values with values_list()
Before you start
What you’ll learn · Before you start
What you will learn
- Create, read, update and delete rows with the ORM, and predict the SQL each call sends
- Filter with field lookups and Q objects, and chain QuerySets that stay lazy until they are read
- Choose get(), filter(), first(), exists() or count() for each question you ask the database
- Order results so that every run returns them in the same order, and read plain values with values_list()
Before you start
On this page
The Nivas notice board asks the database one question per page: the water notices, the pinned ones, the newest
twenty. Each question is a QuerySet, and two lines that look the same can cost very different work: a badge that
shows how many notices there are can load every notice into memory, or send one SELECT COUNT(*).
You will create, read, update and delete rows through the models of this module and read the SQL each call sends, watch a QuerySet stay lazy until its rows are read, filter with field lookups, pick the right call for one row or many, and order results so that a page shows the same rows every time. Every SQL statement below was recorded on Django 6.1.2 with SQLite, against six notices and eight flats.
create(), get(), save(), update() and delete() each send one statement
Every model has a manager: The object through which a Django model makes its queries, called objects unless the model names another. Its methods, such as all(), filter() and create(), start every QuerySet of the model. called objects, the start of every query. The rows the
examples of this lesson use come from one file, seed.py, which every example imports first:
"""The rows the examples of this lesson query: six notices and eight flats, 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. Two notices were posted in the same second, 09:15 UTC, on purpose.
"""
import os
from datetime import UTC, date, datetime
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django # noqa: E402
django.setup()
from django.core.management import call_command # noqa: E402
from notices.models import Notice # noqa: E402
from society.models import Flat # noqa: E402
call_command('migrate', verbosity=0)
NOTICES = [ # title, category, posted_at (UTC), pinned, expires_on
('Water tank cleaning on Sunday', 'water', datetime(2026, 9, 27, 6, 0, tzinfo=UTC), False, date(2026, 9, 28)),
('Annual general meeting', 'meeting', datetime(2026, 9, 28, 12, 0, tzinfo=UTC), True, None),
('Water supply off 2 to 4 pm', 'water', datetime(2026, 9, 30, 5, 30, tzinfo=UTC), False, date(2026, 9, 30)),
('Power cut in tower B', 'power', datetime(2026, 10, 6, 9, 15, tzinfo=UTC), False, date(2026, 10, 6)),
('Lift maintenance in tower A', 'general', datetime(2026, 10, 6, 9, 15, tzinfo=UTC), False, None),
('Festival lights on the terrace', 'general', datetime(2026, 10, 12, 18, 0, tzinfo=UTC), True, date(2026, 10, 25)),
]
FLATS = [ # tower, number, floor, area_sqft, occupancy, owner_name
('A', 101, 1, 650, 'owner', 'Asha K.'),
('A', 102, 1, 650, 'tenant', 'Vikram B.'),
('A', 1101, 11, 1150, 'owner', 'Meera P.'),
('A', 1102, 11, 1150, 'vacant', ''),
('B', 101, 1, 800, 'owner', 'Imran Q.'),
('B', 1203, 12, 1150, 'tenant', 'Neha D.'),
('B', 1204, 12, 1400, 'vacant', ''),
('B', 1205, 12, 1400, 'owner', 'Rohan M.'),
]
if not Notice.objects.exists():
for title, category, posted_at, pinned, expires_on in NOTICES:
Notice.objects.create(title=title, category=category, posted_at=posted_at, pinned=pinned, expires_on=expires_on)
for tower, number, floor, area, occupancy, owner in FLATS:
Flat.objects.create(tower=tower, number=number, floor=floor, area_sqft=area, maintenance_paise=area * 250,
occupancy=occupancy, owner_name=owner) The shell of manage.py is the quickest place to try a query. It imports every model of the project for you, which
-v 2 lists:
"""The shell of manage.py, with the rows of seed.py: it imports every model of the project by itself."""
from session import run
run(
"""
python seed.py
python manage.py shell -v 2 -c "print(Notice.objects.count(), 'notices and', Flat.objects.count(), 'flats')"
"""
) Output
$ python seed.py $ python manage.py shell -v 2 -c "print(Notice.objects.count(), 'notices and', Flat.objects.count(), 'flats')" 15 objects imported automatically: from django.conf import settings from django.db import connection, models, reset_queries from django.db.models import functions from django.utils import timezone from notices.models import Notice from society.models import Flat, Society from django.contrib.sessions.models import Session from django.contrib.contenttypes.models import ContentType from django.contrib.auth.models import User, Group, Permission from django.contrib.admin.models import LogEntry 6 notices and 8 flats
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shell_session.py
The program below makes one notice, reads it, changes it, changes many notices at once and deletes some, printing the SQL of each call. Step through the calls first:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- create(). Notice.objects.create(title='Gym closed for repairs', …) builds the object and saves it at once: one INSERT, which ends with RETURNING id and posted_at.
- get(). get(pk=7) sends a SELECT with LIMIT 21. One row is the answer; the limit lets Django report how many it found when there are too many.
- save() writes every column. After notice.title is changed, save() sends an UPDATE of every column of the row, title, body, category, pinned, posted_at and expires_on, whatever changed.
- save(update_fields). save(update_fields=['pinned']) sends an UPDATE of that one column, so it cannot overwrite the others.
- update(). filter(category='water').update(pinned=True) is one UPDATE for every matching row. It returned 2, the rows matched, and no save() or pre_save signal ran.
- delete() of a QuerySet. filter(title__startswith='Water').delete() is one DELETE when nothing listens for deletions, and returns (2, {'notices.Notice': 2}).
- delete() with a receiver. With a pre_delete receiver connected, delete() first SELECTs the rows to send each one its signal, then deletes them by id.
"""Create, read, update and delete one notice, and print the SQL each call sent to SQLite.
Run it next to manage.py. seed.py makes db.sqlite3 there with every table and the lesson's rows.
"""
from datetime import UTC, datetime
import seed # noqa: F401 (starts Django, creates the tables, adds the rows)
from django.db import connection
from django.db.models.signals import pre_delete, pre_save
from django.test.utils import CaptureQueriesContext
from notices.models import Notice
signals = []
pre_save.connect(lambda sender, instance, **kwargs: signals.append(f'pre_save {instance.title!r}'), sender=Notice)
def show(label, call):
"""Run call(), then print what it returned and every SQL statement it sent."""
with CaptureQueriesContext(connection) as queries:
result = call()
print(f'{label} -> {result!r}')
for query in queries:
print(' ', query['sql'])
return result
notice = show('create()', lambda: Notice.objects.create(
title='Gym closed for repairs', category='general', posted_at=datetime(2026, 10, 14, 7, 0, tzinfo=UTC)))
show('get()', lambda: Notice.objects.get(pk=notice.pk))
def rename():
notice.title = 'Gym closed until Friday'
notice.save()
def pin():
notice.pinned = True
notice.save(update_fields=['pinned'])
show('save()', rename)
show('save(update_fields)', pin)
show('update()', lambda: Notice.objects.filter(category='water').update(pinned=True))
print('pre_save signals so far:', signals)
show('delete() of a QuerySet', lambda: Notice.objects.filter(title__startswith='Water').delete())
pre_delete.connect(lambda sender, instance, **kwargs: signals.append(f'pre_delete {instance.title!r}'), sender=Notice)
show('delete() with a pre_delete receiver', lambda: Notice.objects.filter(pk=notice.pk).delete())
print('signals:', signals) Output
create() -> <Notice: Gym closed for repairs>
INSERT INTO "notices_notice" ("title", "body", "category", "pinned", "posted_at", "expires_on") VALUES ('Gym closed for repairs', '', 'general', 0, '2026-10-14 07:00:00', NULL) RETURNING "notices_notice"."id", "notices_notice"."posted_at"
get() -> <Notice: Gym closed for repairs>
SELECT "notices_notice"."id", "notices_notice"."title", "notices_notice"."body", "notices_notice"."category", "notices_notice"."pinned", "notices_notice"."posted_at", "notices_notice"."expires_on" FROM "notices_notice" WHERE "notices_notice"."id" = 7 LIMIT 21
save() -> None
UPDATE "notices_notice" SET "title" = 'Gym closed until Friday', "body" = '', "category" = 'general', "pinned" = 0, "posted_at" = '2026-10-14 07:00:00', "expires_on" = NULL WHERE "notices_notice"."id" = 7
save(update_fields) -> None
UPDATE "notices_notice" SET "pinned" = 1 WHERE "notices_notice"."id" = 7
update() -> 2
UPDATE "notices_notice" SET "pinned" = 1 WHERE "notices_notice"."category" = 'water'
pre_save signals so far: ["pre_save 'Gym closed for repairs'", "pre_save 'Gym closed until Friday'", "pre_save 'Gym closed until Friday'"]
delete() of a QuerySet -> (2, {'notices.Notice': 2})
BEGIN
DELETE FROM "notices_notice" WHERE "notices_notice"."title" LIKE 'Water%' ESCAPE '\'
COMMIT
delete() with a pre_delete receiver -> (1, {'notices.Notice': 1})
SELECT "notices_notice"."id", "notices_notice"."title", "notices_notice"."body", "notices_notice"."category", "notices_notice"."pinned", "notices_notice"."posted_at", "notices_notice"."expires_on" FROM "notices_notice" WHERE "notices_notice"."id" = 7
BEGIN
DELETE FROM "notices_notice" WHERE "notices_notice"."id" IN (7)
COMMIT
signals: ["pre_save 'Gym closed for repairs'", "pre_save 'Gym closed until Friday'", "pre_save 'Gym closed until Friday'", "pre_delete 'Gym closed until Friday'"]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 crud.py
Two calls in that output deserve a second look. save() of an object that already exists sent an UPDATE of every
column, title, body, category, pinned, posted_at and expires_on, although only the title had changed. If someone
else changed the row after this object was read, their change is overwritten without an error.
save(update_fields=['pinned']) writes only the columns it names. And update() on a QuerySet is a single UPDATE
for every matching row: it returned 2, the rows it matched, and the list of signals shows that it never called
save(), so no pre_save signal ran. delete() returned the number of objects deleted and the count per model, and
sent one DELETE, until a pre_delete receiver was connected: then Django first read the rows, to send each one its
signal.
Loading an object only to change one column
notice = Notice.objects.get(pk=7), notice.pinned = True, notice.save() costs two queries and rewrites
every column. To pin a notice without reading it, Notice.objects.filter(pk=7).update(pinned=True) is one UPDATE
of one column; to keep save() and its signals, use save(update_fields=['pinned']).
Checkpoint
Check: Create, read, update, delete
A QuerySet runs no SQL until you read its rows
Notice.objects.filter(category='water') sends nothing to the database. A QuerySet: A query on one model, built with methods such as filter(), exclude() and order_by(). It is lazy: Django sends its SQL only when the rows are needed, and then keeps them in the QuerySet's cache.
describes a query, and Django’s documentation is plain about it: “QuerySets are lazy”. Each filter(), exclude()
or order_by() returns a new QuerySet with one more refinement, and the SQL runs only when something needs the rows:
a loop, list(), len(), bool(), or a slice with a step. This program counts the queries at every step:
"""When a QuerySet runs its SQL: count the queries while one is built, read, read again and sliced.
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.test.utils import CaptureQueriesContext
from notices.models import Notice
with CaptureQueriesContext(connection) as queries:
water = Notice.objects.filter(category='water')
unpinned = water.exclude(pinned=True)
newest = unpinned.order_by('-posted_at')
print('1. three QuerySets built:', len(queries), 'queries')
print(' str(newest.query) gives the WHERE as', str(newest.query).split(' WHERE ')[1].split(' ORDER BY ')[0])
print('2. read:', [notice.title for notice in newest], '|', len(queries), 'query')
print('3. len():', len(newest), '| bool():', bool(newest), '| read again:', len(list(newest)), '|', len(queries), 'query')
print('4. water.count(), a new query for a new QuerySet:', water.count(), '|', len(queries), 'queries')
latest_two = Notice.objects.order_by('-posted_at')[:2]
print('5. a slice of an unread QuerySet is a', type(latest_two).__name__, '|', len(queries), 'queries')
print(' read it:', [notice.title for notice in latest_two], '|', len(queries), 'queries')
print('6. a slice of a QuerySet already read is a', type(newest[:1]).__name__, '|', len(queries), 'queries')
print('7. Notice.objects.all() read twice:', len(list(Notice.objects.all())), len(list(Notice.objects.all())),
'|', len(queries), 'queries')
print('The SQL of each query:')
for number, query in enumerate(queries, start=1):
print(f' {number}.', query['sql']) Output
1. three QuerySets built: 0 queries
str(newest.query) gives the WHERE as ("notices_notice"."category" = water AND NOT ("notices_notice"."pinned" = True))
2. read: ['Water supply off 2 to 4 pm', 'Water tank cleaning on Sunday'] | 1 query
3. len(): 2 | bool(): True | read again: 2 | 1 query
4. water.count(), a new query for a new QuerySet: 2 | 2 queries
5. a slice of an unread QuerySet is a QuerySet | 2 queries
read it: ['Festival lights on the terrace', 'Power cut in tower B'] | 3 queries
6. a slice of a QuerySet already read is a list | 3 queries
7. Notice.objects.all() read twice: 6 6 | 5 queries
The SQL of each query:
1. SELECT "notices_notice"."id", "notices_notice"."title", "notices_notice"."body", "notices_notice"."category", "notices_notice"."pinned", "notices_notice"."posted_at", "notices_notice"."expires_on" FROM "notices_notice" WHERE ("notices_notice"."category" = 'water' AND NOT ("notices_notice"."pinned" = 1)) ORDER BY "notices_notice"."posted_at" DESC
2. SELECT COUNT(*) AS "__count" FROM "notices_notice" WHERE "notices_notice"."category" = 'water'
3. SELECT "notices_notice"."id", "notices_notice"."title", "notices_notice"."body", "notices_notice"."category", "notices_notice"."pinned", "notices_notice"."posted_at", "notices_notice"."expires_on" FROM "notices_notice" ORDER BY "notices_notice"."posted_at" DESC LIMIT 2
4. SELECT "notices_notice"."id", "notices_notice"."title", "notices_notice"."body", "notices_notice"."category", "notices_notice"."pinned", "notices_notice"."posted_at", "notices_notice"."expires_on" FROM "notices_notice" ORDER BY "notices_notice"."posted_at" DESC
5. SELECT "notices_notice"."id", "notices_notice"."title", "notices_notice"."body", "notices_notice"."category", "notices_notice"."pinned", "notices_notice"."posted_at", "notices_notice"."expires_on" FROM "notices_notice" ORDER BY "notices_notice"."posted_at" DESC
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lazy.py
Step through what happened to newest, the QuerySet built in three steps:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Nothing has run. filter(), exclude() and order_by() each return a new QuerySet that only describes a query. After all three, the run had counted 0 queries.
- Read once. The list comprehension reads newest, and that is when Django sends the one SELECT that holds all three refinements: WHERE, NOT and ORDER BY.
- Kept in the cache. The rows go into newest's cache. len(), bool() and a second read of newest used it: still 1 query.
- water is unchanged. exclude() did not change water: it is still its own QuerySet, never read, so water.count() sends a query of its own, a SELECT COUNT(*).
- Two reads, two queries. Notice.objects.all() written twice is two new QuerySets, each with an empty cache, so reading both sent two SELECTs. Keep one QuerySet in a name to read it twice.
Three QuerySets were built with 0 queries. Reading newest sent one SELECT that holds all three refinements, the
WHERE, the NOT and the ORDER BY. After that, len(), bool() and a second read used the QuerySet’s cache: still one
query. water.count() was a query of its own, because exclude() had made a new QuerySet and left water as it was,
never read. A slice of an unread QuerySet stayed a QuerySet and became LIMIT 2 when it was read; a slice of
newest, already read, was a list taken from the cache. And the last line shows the trap Django’s documentation
warns about: Notice.objects.all() written twice is two QuerySets with two empty caches, so it sent two SELECTs.
The first line of the output shows one more thing: str(newest.query) prints the SQL with water unquoted. It is a
rendering for people; the SQL that was sent, captured below the output, has 'water'.
Key idea
Building a QuerySet sends nothing; reading it sends one query and fills its cache, so keep a QuerySet in a name when you need its rows twice.
Checkpoint
Check: Lazy QuerySets
Field lookups turn keyword arguments into a WHERE clause
filter() and exclude() take field lookup: A keyword argument of filter(), exclude() or get() that names a field and a test, joined by two underscores, such as title__icontains='tower'. Without a test it means exact.: a field name, two underscores, and a
lookup type, as in title__icontains='tower'. Without a type the lookup is exact. This program runs eight filters
and prints the WHERE clause each one became, with what it found:
"""Field lookups: each keyword argument of filter() becomes a condition of the WHERE clause.
Run it next to manage.py. seed.py makes db.sqlite3 there with every table and the lesson's rows.
"""
from datetime import UTC, date, datetime
import seed # noqa: F401 (starts Django, creates the tables, adds the rows)
from django.db import connection
from django.db.models import Q
from django.test.utils import CaptureQueriesContext
from notices.models import Notice
from society.models import Flat
def where(queryset):
"""Run the query and print the WHERE clause of the SQL it sent, then what it found."""
with CaptureQueriesContext(connection) as queries:
found = [str(row) for row in queryset]
sql = queries[0]['sql']
clause = sql[sql.index(' WHERE ') + 7:sql.index(' ORDER BY ')] if ' WHERE ' in sql else '(no WHERE)'
print(' ', clause)
print(' ', len(found), 'found:', found)
print("1. Notice.objects.filter(category='water')")
where(Notice.objects.filter(category='water'))
print("2. Notice.objects.filter(title__icontains='tower')")
where(Notice.objects.filter(title__icontains='tower'))
print('3. Flat.objects.filter(area_sqft__gte=1150, occupancy__in=[owner, tenant])')
where(Flat.objects.filter(area_sqft__gte=1150, occupancy__in=['owner', 'tenant']))
print('4. Notice.objects.filter(expires_on__isnull=True)')
where(Notice.objects.filter(expires_on__isnull=True))
print('5. Notice.objects.filter(posted_at__date=date(2026, 10, 6))')
where(Notice.objects.filter(posted_at__date=date(2026, 10, 6)))
print("6. Notice.objects.filter(Q(category='power') | Q(pinned=True))")
where(Notice.objects.filter(Q(category='power') | Q(pinned=True)))
print('7. Notice.objects.exclude(category=general)')
where(Notice.objects.exclude(category='general'))
print('8. Notice.objects.filter(posted_at__range=(start of 6 Oct UTC, start of 12 Oct UTC))')
where(Notice.objects.filter(posted_at__range=(datetime(2026, 10, 6, tzinfo=UTC), datetime(2026, 10, 12, tzinfo=UTC))))
print(' the range ends at 00:00 on 12 Oct, so the festival notice of 18:00 that day is left out; this one is not:')
where(Notice.objects.filter(posted_at__date__range=(date(2026, 10, 6), date(2026, 10, 12)))) Output
1. Notice.objects.filter(category='water')
"notices_notice"."category" = 'water'
2 found: ['Water supply off 2 to 4 pm', 'Water tank cleaning on Sunday']
2. Notice.objects.filter(title__icontains='tower')
"notices_notice"."title" LIKE '%tower%' ESCAPE '\'
2 found: ['Power cut in tower B', 'Lift maintenance in tower A']
3. Flat.objects.filter(area_sqft__gte=1150, occupancy__in=[owner, tenant])
("society_flat"."area_sqft" >= 1150 AND "society_flat"."occupancy" IN ('owner', 'tenant'))
3 found: ['A-1101', 'B-1203', 'B-1205']
4. Notice.objects.filter(expires_on__isnull=True)
"notices_notice"."expires_on" IS NULL
2 found: ['Lift maintenance in tower A', 'Annual general meeting']
5. Notice.objects.filter(posted_at__date=date(2026, 10, 6))
django_datetime_cast_date("notices_notice"."posted_at", 'UTC', 'UTC') = '2026-10-06'
2 found: ['Power cut in tower B', 'Lift maintenance in tower A']
6. Notice.objects.filter(Q(category='power') | Q(pinned=True))
("notices_notice"."category" = 'power' OR "notices_notice"."pinned" = 1)
3 found: ['Festival lights on the terrace', 'Power cut in tower B', 'Annual general meeting']
7. Notice.objects.exclude(category=general)
NOT ("notices_notice"."category" = 'general')
4 found: ['Power cut in tower B', 'Water supply off 2 to 4 pm', 'Annual general meeting', 'Water tank cleaning on Sunday']
8. Notice.objects.filter(posted_at__range=(start of 6 Oct UTC, start of 12 Oct UTC))
"notices_notice"."posted_at" BETWEEN '2026-10-06 00:00:00' AND '2026-10-12 00:00:00'
2 found: ['Power cut in tower B', 'Lift maintenance in tower A']
the range ends at 00:00 on 12 Oct, so the festival notice of 18:00 that day is left out; this one is not:
django_datetime_cast_date("notices_notice"."posted_at", 'UTC', 'UTC') BETWEEN '2026-10-06' AND '2026-10-12'
3 found: ['Festival lights on the terrace', 'Power cut in tower B', 'Lift maintenance in tower A']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lookups.py
| Lookup | Means | WHERE on SQLite |
|---|---|---|
category='water' |
equal | "category" = 'water' |
title__icontains='tower' |
contains, ignoring case | "title" LIKE '%tower%' |
area_sqft__gte=1150 |
greater than or equal; also gt, lt, lte |
"area_sqft" >= 1150 |
occupancy__in=['owner', 'tenant'] |
one of the list | "occupancy" IN ('owner', 'tenant') |
expires_on__isnull=True |
NULL | "expires_on" IS NULL |
posted_at__date=date(…) |
the date part of a datetime | django_datetime_cast_date(…) = '…' |
The table leaves out the table name before each column, and the ESCAPE '\' that the run shows after every LIKE:
Django escapes % and _ in the text you search for, so that title__contains='50%' matches a real percent sign.
Several keyword arguments in one filter() are joined with AND, as in the third filter. For OR, wrap each condition
in a Q object and join them with |, as the sixth one did; exclude() puts NOT around its conditions.
The eighth filter shows a mistake that loses rows without an error. posted_at__range compares moments, and its end
was 00:00 on the last day, so the festival notice posted at 18:00 that day was left out. For whole days, compare
dates, with posted_at__date__range, or use __gte with the first day and __lt with the day after the last.
Checkpoint
Check: Field lookups
get() wants exactly one row, and first(), exists() and count() never raise
Some questions have one answer, the notice with id 7; others have any number, the water notices. Django has a call for each, and they behave differently when there are no rows or too many:
"""get() against filter(), first(), exists() and count(): what each returns, raises and sends to the database.
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.test.utils import CaptureQueriesContext
from notices.models import Notice
def show(label, call):
"""Print what call() returned or raised, and the SQL it sent."""
with CaptureQueriesContext(connection) as queries:
try:
result = repr(call())
except (Notice.DoesNotExist, Notice.MultipleObjectsReturned) as e:
result = f'raises {type(e).__name__}: {e}'
print(f'{label}\n -> {result}')
for query in queries:
print(' ', query['sql'])
show("get(category='meeting')", lambda: Notice.objects.get(category='meeting'))
show("get(category='water')", lambda: Notice.objects.get(category='water'))
show("get(category='lift')", lambda: Notice.objects.get(category='lift'))
show("filter(category='lift')", lambda: list(Notice.objects.filter(category='lift')))
show("filter(category='water').first()", lambda: Notice.objects.filter(category='water').first())
show("filter(category='lift').first()", lambda: Notice.objects.filter(category='lift').first())
show("filter(category='water').exists()", lambda: Notice.objects.filter(category='water').exists())
show("filter(category='water').count()", lambda: Notice.objects.filter(category='water').count()) Output
get(category='meeting')
-> <Notice: Annual general meeting>
SELECT "notices_notice"."id", "notices_notice"."title", "notices_notice"."body", "notices_notice"."category", "notices_notice"."pinned", "notices_notice"."posted_at", "notices_notice"."expires_on" FROM "notices_notice" WHERE "notices_notice"."category" = 'meeting' LIMIT 21
get(category='water')
-> raises MultipleObjectsReturned: get() returned more than one Notice -- it returned 2!
SELECT "notices_notice"."id", "notices_notice"."title", "notices_notice"."body", "notices_notice"."category", "notices_notice"."pinned", "notices_notice"."posted_at", "notices_notice"."expires_on" FROM "notices_notice" WHERE "notices_notice"."category" = 'water' LIMIT 21
get(category='lift')
-> raises DoesNotExist: Notice matching query does not exist.
SELECT "notices_notice"."id", "notices_notice"."title", "notices_notice"."body", "notices_notice"."category", "notices_notice"."pinned", "notices_notice"."posted_at", "notices_notice"."expires_on" FROM "notices_notice" WHERE "notices_notice"."category" = 'lift' LIMIT 21
filter(category='lift')
-> []
SELECT "notices_notice"."id", "notices_notice"."title", "notices_notice"."body", "notices_notice"."category", "notices_notice"."pinned", "notices_notice"."posted_at", "notices_notice"."expires_on" FROM "notices_notice" WHERE "notices_notice"."category" = 'lift' ORDER BY "notices_notice"."posted_at" DESC
filter(category='water').first()
-> <Notice: Water supply off 2 to 4 pm>
SELECT "notices_notice"."id", "notices_notice"."title", "notices_notice"."body", "notices_notice"."category", "notices_notice"."pinned", "notices_notice"."posted_at", "notices_notice"."expires_on" FROM "notices_notice" WHERE "notices_notice"."category" = 'water' ORDER BY "notices_notice"."posted_at" DESC LIMIT 1
filter(category='lift').first()
-> None
SELECT "notices_notice"."id", "notices_notice"."title", "notices_notice"."body", "notices_notice"."category", "notices_notice"."pinned", "notices_notice"."posted_at", "notices_notice"."expires_on" FROM "notices_notice" WHERE "notices_notice"."category" = 'lift' ORDER BY "notices_notice"."posted_at" DESC LIMIT 1
filter(category='water').exists()
-> True
SELECT 1 AS "a" FROM "notices_notice" WHERE "notices_notice"."category" = 'water' LIMIT 1
filter(category='water').count()
-> 2
SELECT COUNT(*) AS "__count" FROM "notices_notice" WHERE "notices_notice"."category" = 'water'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 one_or_many.py
| Call | No row matches | Two rows match | SQL it sent |
|---|---|---|---|
get(…) |
raises DoesNotExist |
raises MultipleObjectsReturned |
the columns, LIMIT 21 |
filter(…) |
an empty QuerySet | both rows | the columns, ordered |
filter(…).first() |
None |
the first, by the order | the columns, ordered, LIMIT 1 |
filter(…).exists() |
False |
True |
SELECT 1 … LIMIT 1 |
filter(…).count() |
0 |
2 |
SELECT COUNT(*) |
get() sent LIMIT 21, not LIMIT 2: the extra rows let its message say how many it found, as in “it returned
2!”. Each model has its own exception classes, Notice.DoesNotExist and
Notice.MultipleObjectsReturned, so code can catch exactly the one it expects.
Ask the question you mean
get() when anything but one row is a bug, such as a lookup by primary key; first() when a row may be missing;
exists() for yes or no and count() for how many, both answered in the database. Django’s documentation adds
that when you will read the rows anyway, read them first: then len() and if queryset: need no extra query.
Checkpoint
Check: One row or many
Only a total order makes first() and pages repeatable
A list of notices that comes back in a different order each time shows the same notice on two pages and another on
none. The order is only fixed when it is total: when no two rows can tie. Two notices of the seed data were posted in
the same second, 09:15 UTC, which is exactly where Notice’s Meta.ordering of ['-posted_at'] stops deciding:
"""Ordering: ties between equal values, which orders are total, first() without any order, and values().
Run it next to manage.py. seed.py makes db.sqlite3 there with every table and the lesson's rows.
"""
import warnings
import seed # noqa: F401 (starts Django, creates the tables, adds the rows)
from django.db import connection
from django.test.utils import CaptureQueriesContext
from notices.models import Notice
def titles(queryset):
return [notice.title for notice in queryset]
print('Posted in the same second:', titles(Notice.objects.filter(posted_at__hour=9, posted_at__minute=15)))
print("order_by('-posted_at') :", titles(Notice.objects.order_by('-posted_at')[:3]))
print("order_by('-posted_at', '-pk'):", titles(Notice.objects.order_by('-posted_at', '-pk')[:3]))
print()
for label, queryset in [
('Meta.ordering, -posted_at', Notice.objects.all()),
("order_by('-posted_at', '-pk')", Notice.objects.order_by('-posted_at', '-pk')),
("order_by('title')", Notice.objects.order_by('title')),
('order_by(), no order', Notice.objects.order_by()),
]:
print(f'{label:<30} ordered={queryset.ordered!s:<5} totally_ordered={queryset.totally_ordered}')
print()
with CaptureQueriesContext(connection) as queries:
Notice.objects.order_by().first()
print('order_by().first() sends:', queries[0]['sql'].split(' FROM ')[1])
print('values():', list(Notice.objects.filter(pinned=True).order_by('title').values('title', 'category')))
print("values_list('title', flat=True):", list(Notice.objects.filter(pinned=True).order_by('title').values_list('title', flat=True)))
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter('always')
list(Notice.objects.values_list(flat=True))
print('values_list(flat=True) with no field name:', caught[0].category.__name__) Output
Posted in the same second: ['Power cut in tower B', 'Lift maintenance in tower A']
order_by('-posted_at') : ['Festival lights on the terrace', 'Power cut in tower B', 'Lift maintenance in tower A']
order_by('-posted_at', '-pk'): ['Festival lights on the terrace', 'Lift maintenance in tower A', 'Power cut in tower B']
Meta.ordering, -posted_at ordered=True totally_ordered=False
order_by('-posted_at', '-pk') ordered=True totally_ordered=True
order_by('title') ordered=True totally_ordered=False
order_by(), no order ordered=False totally_ordered=False
order_by().first() sends: "notices_notice" LIMIT 1
values(): [{'title': 'Annual general meeting', 'category': 'meeting'}, {'title': 'Festival lights on the terrace', 'category': 'general'}]
values_list('title', flat=True): ['Annual general meeting', 'Festival lights on the terrace']
values_list(flat=True) with no field name: RemovedInDjango2028Warning
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ordering.py
With order_by('-posted_at') the database put the power cut before the lift notice; with -pk added, the lift
notice, saved later, came first. Without the tie-breaker the database may pick either order, and another database, an
index or a different plan may choose the other way. Django 6.1 added QuerySet.totally_ordered, which says whether
an order is deterministic: it requires a field, or a set of fields, that is unique and not nullable. It was False
for the Meta ordering and for order_by('title'), and True once -pk was added.
first() and last() order by the primary key when a QuerySet has no order. Django 6.1 changed one case: after
order_by() with no arguments, which clears every order, first() no longer adds one, and the run’s SQL ended in
LIMIT 1 with no ORDER BY at all.
values() returns a dictionary per row and values_list() a tuple, without building model objects; with one field
and flat=True, plain values. Name that field: values_list(flat=True) with no field name is deprecated in 6.1, and
the run caught its RemovedInDjango2028Warning.
Checkpoint
Check: Ordering and values
Practise: a lazy query of your own
Two small pieces of a QuerySet, rebuilt in plain Python on rows that are dictionaries. First decide whether one row
passes a field lookup such as title__icontains; then build a query that filters lazily, leaves the query it came
from alone and reads its rows once. Each step has tests you can run here and a hint:
Code lab · 2 steps
Build a small QuerySet
Match rows against field lookups, then write a query that is lazy, chains without side effects and caches its result.
- Decide whether a row matches a field lookup To do
- Build a lazy query that runs once and remembers To do
Exercise · Easy · Python
Decide whether a row matches a field lookup
Write matches(row, lookup, value), which says whether a row, a dictionary such as {'title': 'Power cut in tower B', 'category': 'power', 'expires_on': None}, passes one keyword argument of filter(): matches(row, 'title__icontains', 'tower') is True.
The lookup is a field name, then optionally __ and the lookup type. Support these types, as Django's documentation defines them:
- none, or
exact: equal;iexact: equal, ignoring case; contains,icontains,startswith: the text test,icontainsignoring case;gt,gte,lt,lte: greater than, greater or equal, less than, less or equal;in: the value is one of the items of a list;isnull:Truematches a field that isNone,Falseone that is not.
Raise ValueError for any other lookup type. Comparisons other than isnull never match a None field, as SQL's comparisons never match NULL.
Starter code · lookup.py
def matches(row, lookup, value):
"""True when the dictionary row passes one keyword argument of filter(), such as title__icontains='tower'."""
return row.get(lookup) == value The sample tests · test_lookup.py
from lookup import matches
POWER = {'title': 'Power cut in tower B', 'category': 'power', 'area_sqft': None, 'expires_on': None}
FLAT = {'title': 'B-1203', 'category': 'tenant', 'area_sqft': 1150, 'expires_on': '2026-10-06'}
def test_exact():
"""no lookup type means exact"""
assert matches(POWER, 'category', 'power')
assert matches(POWER, 'category__exact', 'power')
assert not matches(POWER, 'category', 'water')
def test_text():
"""contains is case-sensitive, icontains and iexact are not"""
assert matches(POWER, 'title__icontains', 'TOWER')
assert not matches(POWER, 'title__contains', 'TOWER')
assert matches(POWER, 'title__contains', 'tower')
assert matches(POWER, 'category__iexact', 'POWER')
assert matches(POWER, 'title__startswith', 'Power')
def test_comparisons():
"""gt, gte, lt and lte compare, and the edges count for gte and lte"""
assert matches(FLAT, 'area_sqft__gte', 1150)
assert not matches(FLAT, 'area_sqft__gt', 1150)
assert matches(FLAT, 'area_sqft__lt', 1400)
assert matches(FLAT, 'area_sqft__lte', 1150)
def test_in():
"""in matches one of the listed values"""
assert matches(FLAT, 'category__in', ['owner', 'tenant'])
assert not matches(POWER, 'category__in', ['owner', 'tenant'])
def test_isnull_and_none():
"""isnull tests for None, and comparisons never match a None field"""
assert matches(POWER, 'expires_on__isnull', True)
assert not matches(FLAT, 'expires_on__isnull', True)
assert matches(FLAT, 'expires_on__isnull', False)
assert not matches(POWER, 'area_sqft__gte', 0)
def test_unknown_lookup():
"""a lookup type that is not supported is an error, not a quiet False"""
try:
matches(POWER, 'title__regex', 'cut')
except ValueError:
return
raise AssertionError('title__regex must raise ValueError') A hint
Split the lookup once, at its first __: field, _, kind = lookup.partition('__') works for these lookups, because a field name never contains two underscores in a row. An empty kind means exact. Handle isnull before you look at the field's value, then return False for a None field, then compare.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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
Build a lazy query that runs once and remembers
Write a class Query that filters a list of rows (dictionaries) the way a QuerySet does: lazily, with chaining that never changes the query it starts from, and with a cache.
Query(rows)keeps the rows and no conditions.filter(**conditions)returns a newQuerywith the conditions added. Each condition isfield=valueand means equal. Nothing is evaluated.- Iterating a
Queryevaluates it: it goes through the rows once, keeps those that pass every condition, in their order, and adds 1 to the class attributeQuery.runs. Later iterations andlen()reuse that result. len(query)is the number of matching rows; it evaluates the query only if it has not been evaluated.
The starter filters at once, inside filter(), so it runs where a QuerySet would not.
Starter code · lazy.py
class Query:
"""Rows filtered like a QuerySet: lazily, without changing the query it came from, evaluated once."""
runs = 0 # how many times a Query has gone through its rows
def __init__(self, rows):
self.rows = list(rows)
def filter(self, **conditions):
Query.runs += 1
kept = [row for row in self.rows if all(row.get(k) == v for k, v in conditions.items())]
return Query(kept)
def __iter__(self):
return iter(self.rows)
def __len__(self):
return len(self.rows) The sample tests · test_lazy.py
from lazy import Query
NOTICES = [
{'title': 'Water tank cleaning on Sunday', 'category': 'water', 'pinned': False},
{'title': 'Annual general meeting', 'category': 'meeting', 'pinned': True},
{'title': 'Water supply off 2 to 4 pm', 'category': 'water', 'pinned': False},
{'title': 'Power cut in tower B', 'category': 'power', 'pinned': False},
]
def titles(query):
return [row['title'] for row in query]
def test_building_runs_nothing():
"""filter() only builds a new query, as a QuerySet does"""
Query.runs = 0
Query(NOTICES).filter(category='water').filter(pinned=False)
assert Query.runs == 0
def test_iterating_runs_once():
"""the first iteration evaluates; later ones and len() reuse the result"""
Query.runs = 0
water = Query(NOTICES).filter(category='water')
assert titles(water) == ['Water tank cleaning on Sunday', 'Water supply off 2 to 4 pm']
assert Query.runs == 1
assert titles(water) == ['Water tank cleaning on Sunday', 'Water supply off 2 to 4 pm']
assert len(water) == 2
assert Query.runs == 1
def test_len_evaluates_once():
"""len() of a query that was never read evaluates it once"""
Query.runs = 0
pinned = Query(NOTICES).filter(pinned=True)
assert len(pinned) == 1
assert len(pinned) == 1
assert Query.runs == 1
def test_chaining_leaves_the_first_query_alone():
"""a new filter makes a new query; the one it came from keeps its own conditions"""
Query.runs = 0
everything = Query(NOTICES)
water = everything.filter(category='water')
assert len(everything) == 4
assert len(water) == 2
assert titles(water.filter(title='Water supply off 2 to 4 pm')) == ['Water supply off 2 to 4 pm']
assert len(water) == 2
def test_every_condition_counts():
"""two conditions in one filter() must both hold"""
assert titles(Query(NOTICES).filter(category='water', title='Annual general meeting')) == [] A hint
Keep three things on each Query: the rows, a tuple of conditions and a cache that starts as None. filter() returns a new Query with a longer tuple and leaves self as it is. __iter__ fills the cache only when it is None, and that is the moment to add 1 to Query.runs. Careful in __len__: list(self) asks len(self) first, so count the rows of iter(self) instead.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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 try the quiz on the five ideas of QuerySets, from the SQL of each call to total orders; it opens with the idea where your answers have been weakest:
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): When does a Django QuerySet send its SQL to the database?
Only when something needs its rows. Building one with filter(), exclude() or order_by() sends nothing, because
each returns a new QuerySet that only describes the query. The SQL runs when the QuerySet is iterated, turned into a
list, given to len(), repr() or bool(), pickled, or sliced with a step. The rows are then kept in its cache, so
reading the same QuerySet again sends nothing, while a new QuerySet, even one built the same way, queries again.
Methods such as count(), exists(), get(), update() and delete() send their own query at once.
Two design questions of this module start from this lesson’s queries: notice edits by several people at once that must never overwrite each other, and a notice board that reads every row to show twenty.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Five committee members edit notices in the same screen at the same time. An edit must never silently overwrite another one, and a member whose edit conflicts must see what changed. Design the save path, and say where save(update_fields=...), a version column and select_for_update() each fit.
-
Fresher to mid
The notice board view runs if notices:, then notices.count(), then loops over notices[:20]. With 50,000 notices the page is slow, yet the database log shows a single query. Why, and how do you rewrite the view?
Key takeaways
create()sends an INSERT andget()a SELECT withLIMIT 21;save()of an existing object rewrites every column unlessupdate_fieldsnames them, andupdate()anddelete()change many rows in one statement, withoutsave().- A QuerySet is lazy: building it sends nothing, reading it sends one query and fills its cache, and a new QuerySet starts with an empty one.
- Field lookups such as
__icontains,__gte,__inand__isnullbecome WHERE conditions, joined with AND;Qobjects with|give OR, and whole days are compared as dates. get()raises unless exactly one row matches,first()returnsNonefor none, andexists()andcount()answer in the database.- Only a total order, such as
order_by('-posted_at', '-pk'), makesfirst(),last()and pages repeatable, whichtotally_orderedchecks;values_list()with a named field gives plain values.
Terms in this lesson
- field lookup
- A keyword argument of filter(), exclude() or get() that names a field and a test, joined by two underscores, such as title__icontains='tower'. Without a test it means exact.
- manager
- The object through which a Django model makes its queries, called objects unless the model names another. Its methods, such as all(), filter() and create(), start every QuerySet of the model.
- QuerySet
- A query on one model, built with methods such as filter(), exclude() and order_by(). It is lazy: Django sends its SQL only when the rows are needed, and then keeps them in the QuerySet's cache.
References
- Django documentation: Making queries (Django Software Foundation)
- Django documentation: QuerySet API reference (Django Software Foundation)
- Django documentation: Model instance reference (saving objects) (Django Software Foundation)
- django-admin and manage.py: shell (Django Software Foundation)
- Django 6.1 release notes (Django Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress