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 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.

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

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:

seed.py: six notices and eight flats Python · nivas/seed.py
"""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:

manage.py shell imports the models Python · nivas/shell_session.py
"""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:

Interactive · message by message Create, read, update and delete, one statement each Step through the calls of crud.py. The message on each arrow is the SQL Django sent, cut short.
Create, read, update and delete, one statement each, step 7 of 7. Messages so far: Django to SQLite: INSERT … RETURNING id; Django to SQLite: SELECT … LIMIT 21; Django to SQLite: UPDATE every column; Django to SQLite: UPDATE "pinned" only; Django to SQLite: UPDATE … WHERE category; Django to SQLite: DELETE … WHERE title; Django to SQLite: SELECT … then DELETE.Your code2 rows matchedDjangoNotice.objects and save()SQLitenotices_noticeINSERT … RETURNING idSELECT … LIMIT 21UPDATE every columnUPDATE "pinned" onlyUPDATE … WHERE categoryDELETE … WHERE titleSELECT … then DELETE

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. save(update_fields). save(update_fields=['pinned']) sends an UPDATE of that one column, so it cannot overwrite the others.
  5. 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.
  6. delete() of a QuerySet. filter(title__startswith='Water').delete() is one DELETE when nothing listens for deletions, and returns (2, {'notices.Notice': 2}).
  7. 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, with the SQL of each call Python · nivas/crud.py
"""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

  1. Question 1 of 2 Rina and Arun both open notice 7. Rina changes its title and saves. Arun, whose copy still has the old title, pins it and calls save(). What does the row hold afterwards?

    Choose one answer.

    A hint

    Look at the UPDATE that save() sent in crud.py.

    Show the answer: Question 1 of 2

    Answer: The pin, with the old title

    save() of an existing object sends UPDATE … SET with every column. Two copies of the same row saved one after the other leave the second copy's values everywhere, and the first person's change is lost without an error.

  2. Question 2 of 2 Which of these calls sends no pre_save signal?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Notice.objects.filter(category='water').update(pinned=True)

    The run collected three pre_save signals, for create(), save() and save(update_fields), and none for update(), which matched 2 rows. Code that relies on save() or its signals does not run for update().

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:

Counting queries while a QuerySet is built and read Python · nivas/lazy.py
"""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:

Interactive · stage by stage Build a QuerySet, then read it Step through lazy.py. Watch the query count stay at 0 while the QuerySet is built.
Build a QuerySet, then read it, step 5 of 5. Showing filter(category='water') (count() is query 2), exclude(pinned=True), order_by('-posted_at') (0 queries so far), a for loop reads it (1 query), the result cache (only for the same QuerySet).filter(category='water')count() is query 2exclude(pinned=True)a new QuerySetorder_by('-posted_at')0 queries so fara for loop reads it1 querythe result cacheonly for the same QuerySeta new QuerySet starts with no cache

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

Every step as text
  1. 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.
  2. 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.
  3. Kept in the cache. The rows go into newest's cache. len(), bool() and a second read of newest used it: still 1 query.
  4. 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(*).
  5. 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

  1. Question 1 of 2 qs = Notice.objects.filter(category='water'), then qs = qs.exclude(pinned=True), then print(len(qs)) and print(list(qs)). How many queries does this send?

    Type a number.

    A hint

    Which line reads the rows first, and where does the second line find them?

    Show the answer: Question 1 of 2

    Answer: 1 queries

    filter() and exclude() send nothing. len() reads the QuerySet, with one SELECT that has both conditions, and fills its cache; list() then reads the cache. That is 1 query, as step 3 of lazy.py showed.

  2. Question 2 of 2 Which code reads the notices from the database twice?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: [n.title for n in Notice.objects.all()] and then [n.pinned for n in Notice.objects.all()]

    Django's documentation shows the same trap: two QuerySets built the same way are evaluated separately. Keep a QuerySet in a name when the same rows are needed twice.

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:

Eight filters and the WHERE clause of each Python · nivas/lookups.py
"""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.

SQLite Online (SQL Playground) Run a WHERE clause from this page against your own table in the browser.

Checkpoint

Check: Field lookups

  1. Question 1 of 2 Which filter finds 'Power cut in tower B' when the search text is 'TOWER'?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: title__icontains='TOWER'

    A lookup is field__type=value. icontains is the case-insensitive containment test, and it found the two notices with tower in their titles.

  2. Question 2 of 2 posted_at__range ran from 00:00 on the 6th to 00:00 on the 12th, UTC. Why did the festival notice, posted at 18:00 on the 12th, not come back?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The range includes its end, but its end is 00:00 on the 12th, before 18:00 that day

    For whole days, compare dates: posted_at__date__range=(date(…, 6), date(…, 12)) found the festival notice too. Or use posted_at__gte with the first day and posted_at__lt with the day after the last.

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(), filter(), first(), exists() and count(), with their SQL Python · nivas/one_or_many.py
"""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

  1. Question 1 of 2 What does Notice.objects.get(category='water') do when two notices match?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It raises Notice.MultipleObjectsReturned

    get() sent its SELECT with LIMIT 21, found 2 rows and raised MultipleObjectsReturned. Use get() when anything but one row is a bug, such as a lookup by primary key.

  2. Question 2 of 2 Which SQL did filter(category='water').exists() send?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: SELECT 1 AS "a" … LIMIT 1

    exists() and count() answer inside the database. Django's documentation adds that when the rows will be read anyway, reading them first and testing the QuerySet saves the extra query.

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:

Ties, total orders, first() without an order, and values_list() Python · nivas/ordering.py
"""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

  1. Question 1 of 2 Which ordering is total for Notice, so that every run returns the rows in the same order?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: order_by('-posted_at', '-pk')

    Django's documentation says a total order needs a field, or a set of fields, that is unique and not nullable. Adding the primary key last is the usual way to get one.

  2. Question 2 of 2 In Django 6.1, what does Notice.objects.order_by().first() send?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A SELECT with LIMIT 1 and no ORDER BY, so any notice may come back

    The run printed the end of the SQL: FROM "notices_notice" LIMIT 1. Give first() an order you mean, such as order_by('-posted_at', '-pk').

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.

  1. Decide whether a row matches a field lookup To do
  2. 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, icontains ignoring 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: True matches a field that is None, False one 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.

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 new Query with the conditions added. Each condition is field=value and means equal. Nothing is evaluated.
  • Iterating a Query evaluates it: it goes through the rows once, keeps those that pass every condition, in their order, and adds 1 to the class attribute Query.runs. Later iterations and len() 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.

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.

  1. Question 1 of 10 Notice.objects.filter(category='water').update(pinned=True) matched the 2 water notices. What does the call return?

    Type a number.

    Show the answer: Question 1 of 10

    Answer: 2 rows

    update() returns the number of rows matched, which Django's documentation notes may differ from the number of rows whose values changed. The run printed 2.

  2. Question 2 of 10 What does Notice.objects.filter(title__startswith='Water').delete() return when two notices match?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: (2, {'notices.Notice': 2})

    The tuple tells what a cascade deleted too: rows of other models that pointed at the deleted ones would appear in the dictionary under their own model's name.

  3. Question 3 of 10 What is Notice.objects.order_by('-posted_at')[:2] before anything reads it?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: A QuerySet; reading it sends one SELECT with LIMIT 2

    lazy.py showed both cases: the slice of an unread QuerySet was a QuerySet, and the slice of one already read was a list, taken from its cache.

  4. Question 4 of 10 str(newest.query) printed category = water, without quotes. What does that tell you?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: It is a rendering of the query for people; the SQL that was sent quoted the value, which CaptureQueriesContext showed

    For the SQL Django really sends, capture it: CaptureQueriesContext in a program, or connection.queries with DEBUG on. str(qs.query) is good for a quick look only.

  5. Question 5 of 10 Which filter keeps the flats lived in by an owner or a tenant?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: Flat.objects.filter(occupancy__in=['owner', 'tenant'])

    in is the lookup for "one of these". With area_sqft__gte=1150 in the same filter(), both conditions had to hold, and three flats were found.

  6. Question 6 of 10 How do you ask for the notices that are power cuts or are pinned?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: Notice.objects.filter(Q(category='power') | Q(pinned=True))

    The run printed ("notices_notice"."category" = 'power' OR "notices_notice"."pinned" = 1) and found 3 notices: the power cut and the two pinned ones.

  7. Question 7 of 10 What does Notice.objects.filter(category='lift').first() return when no notice matches?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: None

    first() suits optional rows: test the result with if notice is None. get() suits rows that must exist.

  8. Question 8 of 10 A badge on the notice board shows how many water notices there are. Which call should it use?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: Notice.objects.filter(category='water').count()

    Django's documentation says to prefer count() over loading the records and calling len(), unless the objects are needed anyway, in which case len() of the read QuerySet needs no extra query.

  9. Question 9 of 10 Is Notice's Meta.ordering, ['-posted_at'], a total order?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: No, two notices posted in the same second can come back in either order

    With order_by('-posted_at') the run listed the power cut before the lift notice, and with -pk added the lift notice came first. Without the tie-breaker the database decides.

  10. Question 10 of 10 Which call gives a plain list of the pinned notices' titles without the deprecation warning of Django 6.1?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: Notice.objects.filter(pinned=True).values_list('title', flat=True)

    values() returns dictionaries and values_list() tuples, or single values with flat=True and one field. Both skip building model objects, which suits data for a report or an API.

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.

  1. 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.

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

  2. 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?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • create() sends an INSERT and get() a SELECT with LIMIT 21; save() of an existing object rewrites every column unless update_fields names them, and update() and delete() change many rows in one statement, without save().
  • 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, __in and __isnull become WHERE conditions, joined with AND; Q objects with | give OR, and whole days are compared as dates.
  • get() raises unless exactly one row matches, first() returns None for none, and exists() and count() answer in the database.
  • Only a total order, such as order_by('-posted_at', '-pk'), makes first(), last() and pages repeatable, which totally_ordered checks; 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

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.