Your country

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

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

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

Custom managers, raw SQL and EXPLAIN

Write chainable QuerySet methods and Django managers, run raw SQL with parameters, keep query-string filters to an allowlist, and read plans with explain().

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

What you will learn

  • Write a custom QuerySet whose methods chain, and offer them on the model with Manager.from_queryset() or as_manager()
  • Predict which rows dumpdata, the admin and related objects see when a model's default manager filters rows
  • Run raw SQL with raw() and cursor.execute(), passing every value as a parameter and never inside the SQL string
  • Map query-string filters to lookups through an allowlist instead of passing them to filter() as they come
  • Read a PostgreSQL plan from explain() and explain(analyze=True), and confirm with it that a custom lookup uses an index

Before you start

What you’ll learn · Before you start

What you will learn

  • Write a custom QuerySet whose methods chain, and offer them on the model with Manager.from_queryset() or as_manager()
  • Predict which rows dumpdata, the admin and related objects see when a model's default manager filters rows
  • Run raw SQL with raw() and cursor.execute(), passing every value as a parameter and never inside the SQL string
  • Map query-string filters to lookups through an allowlist instead of passing them to filter() as they come
  • Read a PostgreSQL plan from explain() and explain(analyze=True), and confirm with it that a custom lookup uses an index

Before you start

On this page

Every page of the Nivas notice board asks one question: which notices does this resident see today? Five conditions answer it, and a view that forgets one shows the wrong notices, such as a committee notice to everyone. Written once, as methods of a custom QuerySet, the question reads Notice.objects.active().for_flat(flat).visible_to(resident), and Django still sends a single query.

You will write such methods and the managers that offer them, see why a model’s first manager must not hide rows, run raw SQL with its values as parameters, keep query-string filters to an allowlist, and read the plans PostgreSQL prints for explain(): the login lookup read all 5,000 residents until a custom lookup let it use the index the table already had. The examples ran on Django 6.1.2 with SQLite 3.51.0 and, each on a throwaway server, PostgreSQL 18.6.

A custom QuerySet turns the board’s question into methods that chain

The question has three parts: the notice runs on the day, it is for the resident’s tower or for the whole society, and committee notices go to committee members only. Each part is a method of NoticeQuerySet that returns self.filter(…) or self.exclude(…), a new QuerySet, so the next method can be called on what it returns. NoticeManager.from_queryset(NoticeQuerySet) makes a manager class with the same methods, and Notice.objects is one:

The board's question as three chained methods

nivas/chained.py

"""The notice board's question, what does this resident see today, asked with the methods of NoticeQuerySet in
notices/models.py, chained after Notice.objects. The day is fixed, 1 October 2026, so that the run reads the same on
any day. Run it next to manage.py.
"""

from datetime import date

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 Complaint, Notice, Resident
from seed import where

DAY = date(2026, 10, 1)
asha = Resident.objects.select_related('flat').get(name='Asha K.')
rohan = Resident.objects.select_related('flat').get(name='Rohan M.')

print(f'1. Notice.objects.active(on={DAY}).for_flat({asha.flat}).visible_to({asha})')
board = Notice.objects.active(on=DAY).for_flat(asha.flat).visible_to(asha)
with CaptureQueriesContext(connection) as queries:
    titles = [notice.title for notice in board]
print(f'   {len(queries)} query, {len(titles)} notices: {" | ".join(titles)}')
print('  ', where(queries[0]['sql']))

print(f'2. The same chain for {rohan} ({rohan.flat}, on the committee)')
for notice in Notice.objects.active(on=DAY).for_flat(rohan.flat).visible_to(rohan):
    print('  ', notice.title)

print('3. Which methods are where')
print('   Notice.objects.active:', hasattr(Notice.objects, 'active'), '| Notice.objects.post:',
      hasattr(Notice.objects, 'post'))
print('   Notice.objects.all().active:', hasattr(Notice.objects.all(), 'active'),
      '| Notice.objects.all().post:', hasattr(Notice.objects.all(), 'post'))
print('   Notice.objects.post() created:', Notice.objects.post('Garden tap repaired', by=rohan, category='water'))

print("4. Complaint.objects = ComplaintQuerySet.as_manager()")
print("   Complaint.objects.still_open().in_tower('B').count():", Complaint.objects.still_open().in_tower('B').count())

Output

1. Notice.objects.active(on=2026-10-01).for_flat(B-1203).visible_to(Asha K.)
   1 query, 2 notices: Water cut in tower B, 10:00 to 14:00 | Cleanliness drive on Sunday
   WHERE ((notice.ends_on IS NULL OR notice.ends_on >= '2026-10-01')
     AND notice.published = 1
     AND notice.starts_on <= '2026-10-01'
     AND (notice.tower = '' OR notice.tower = 'B')
     AND NOT (notice.audience = 'committee'))
2. The same chain for Rohan M. (B-1204, on the committee)
   Water cut in tower B, 10:00 to 14:00
   Cleanliness drive on Sunday
   Security guard rota for October
   Tower B committee meeting notes
3. Which methods are where
   Notice.objects.active: True | Notice.objects.post: True
   Notice.objects.all().active: True | Notice.objects.all().post: False
   Notice.objects.post() created: Garden tap repaired
4. Complaint.objects = ComplaintQuerySet.as_manager()
   Complaint.objects.still_open().in_tower('B').count(): 2

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

notices/models.py · nivas/notices/models.py

from django.db import models
from django.db.models import Q
from django.db.models.functions import Lower
from django.utils import timezone


class Flat(models.Model):
    code = models.CharField(max_length=10, unique=True)  # 'B-1203': tower B, flat 1203
    tower = models.CharField(max_length=1)

    def __str__(self):
        return self.code


class Resident(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField()
    phone = models.CharField(max_length=15)
    flat = models.ForeignKey(Flat, on_delete=models.CASCADE, related_name='residents')
    on_committee = models.BooleanField(default=False)

    class Meta:
        constraints = [
            # one account per email, whatever the capital letters: a unique index on LOWER(email)
            models.UniqueConstraint(Lower('email'), name='one_resident_per_email'),
        ]

    def __str__(self):
        return self.name


class NoticeQuerySet(models.QuerySet):
    """The questions the notice board asks, each a method that returns a QuerySet, so they chain."""

    def active(self, on=None):
        """Published, started, and not yet ended on the given day (today if none is given)."""
        on = on or timezone.localdate()
        return self.filter(Q(ends_on__isnull=True) | Q(ends_on__gte=on), published=True, starts_on__lte=on)

    def for_flat(self, flat):
        """Notices for the whole society (no tower) or for the flat's own tower."""
        return self.filter(Q(tower='') | Q(tower=flat.tower))

    def visible_to(self, resident):
        """Committee notices are for committee members only."""
        if resident.on_committee:
            return self
        return self.exclude(audience=Notice.Audience.COMMITTEE)


class NoticeManager(models.Manager):
    def post(self, title, by, **fields):
        """A method of the manager only: there is no QuerySet to start from when a notice is created."""
        return self.create(title=title, posted_by=by, starts_on=timezone.localdate(), **fields)


class Notice(models.Model):
    class Audience(models.TextChoices):
        EVERYONE = 'everyone'
        COMMITTEE = 'committee'

    title = models.CharField(max_length=120)
    category = models.CharField(max_length=20)  # water, lift, security, events …
    tower = models.CharField(max_length=1, blank=True)  # '' means the whole society
    audience = models.CharField(max_length=10, choices=Audience, default=Audience.EVERYONE)
    published = models.BooleanField(default=True)
    starts_on = models.DateField()
    ends_on = models.DateField(null=True, blank=True)
    posted_by = models.ForeignKey(Resident, on_delete=models.PROTECT, related_name='notices')

    objects = NoticeManager.from_queryset(NoticeQuerySet)()

    def __str__(self):
        return self.title


class ComplaintQuerySet(models.QuerySet):
    def still_open(self):
        return self.filter(closed_on__isnull=True)

    def in_tower(self, tower):
        return self.filter(tower=tower)


class Complaint(models.Model):
    tower = models.CharField(max_length=1)
    subject = models.CharField(max_length=120)
    raised_on = models.DateField()
    closed_on = models.DateField(null=True, blank=True)

    objects = ComplaintQuerySet.as_manager()  # the QuerySet's public methods, copied onto a manager

    def __str__(self):
        return self.subject


class PublishedManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(published=True)


class Circular(models.Model):
    """The mistake: the filtered manager is declared first, so it is the default manager."""

    title = models.CharField(max_length=120)
    published = models.BooleanField(default=False)

    objects = PublishedManager()
    every = models.Manager()

    def __str__(self):
        return self.title


class Minutes(models.Model):
    """The fix: a plain manager first, the filtered one under its own name."""

    title = models.CharField(max_length=120)
    published = models.BooleanField(default=False)

    objects = models.Manager()
    published_only = PublishedManager()

    class Meta:
        verbose_name_plural = 'minutes'

    def __str__(self):
        return self.title


class Attachment(models.Model):
    circular = models.ForeignKey(Circular, on_delete=models.CASCADE, related_name='attachments')
    file_name = models.CharField(max_length=100)

    def __str__(self):
        return self.file_name

Asha K. of B-1203 saw 2 of the 7 notices, and the loop sent 1 query: its WHERE holds the conditions of all three methods, joined by AND. Step through how the chain built it:

Interactive · stage by stage Three methods, one query Step through the chain for Asha K. Watch each method add its conditions, and the query run only at the loop.
Three methods, one query, step 5 of 5. Showing Notice.objects, .active(on=DAY), .for_flat(B-1203), .visible_to(Asha K.), for notice in board. In focus: for notice in board.Notice.objectsNoticeManager, no SQL yet.active(on=DAY)+ published, started, open.for_flat(B-1203)+ tower '' or tower B.visible_to(Asha K.)+ not a committee noticefor notice in board1 query, 2 notices

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

Every step as text
  1. The manager. Notice.objects is a NoticeManager, the class that from_queryset(NoticeQuerySet) made. Its get_queryset() gives a NoticeQuerySet of every notice, and no SQL has been sent.
  2. active(). active(on=DAY) returns self.filter(…): a new QuerySet with three conditions, published, started on or before the day, and not ended before it. Still no SQL.
  3. for_flat(). for_flat() is a method of the QuerySet that active() returned, so the chain goes on. It adds tower = '' OR tower = 'B': society-wide notices and those of Asha's tower.
  4. visible_to(). Asha K. is not on the committee, so visible_to() adds NOT (audience = 'committee'). For Rohan M., who is, it returns the QuerySet as it was.
  5. The loop asks for rows. Only the for loop makes Django send SQL: one SELECT whose WHERE joins the conditions of all three methods with AND. It returns 2 of the 7 notices.

Nothing ran until the for loop asked for rows, because 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. More in “Django QuerySets: create, read, update, delete” is lazy: each method only added conditions to the query it was given. visible_to() shows that a method may decide in Python as well. For Rohan M., who is on the committee, it returned the QuerySet unchanged, and he saw 4 notices.

Django’s documentation gives the rules of the copy: the QuerySet’s public methods are copied to the manager, methods whose names start with an underscore are not, and a queryset_only attribute on a method overrides both. Part 3 of the run shows the other direction. post() is a method of NoticeManager itself, because creating a notice starts from no QuerySet, so Notice.objects.post() worked while Notice.objects.all() had no post. When the manager needs no method of its own, QuerySet.as_manager() is shorter: Complaint.objects is ComplaintQuerySet.as_manager().

Key idea

Put each filter a page repeats into a method of a custom QuerySet that returns a QuerySet: the methods chain in any order, also after filter(), and the whole chain is still one query.

Checkpoint

Check: QuerySet methods that chain

  1. Question 1 of 2 board = Notice.objects.active(on=DAY).for_flat(flat).visible_to(asha). When did Django send SQL, and how many queries?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: One query, when the for loop asked for the rows

    chained.py counted 1 query for the board, and its WHERE joined the conditions of active(), for_flat() and visible_to() with AND.

  2. Question 2 of 2 post() is a method of NoticeManager, and Notice.objects = NoticeManager.from_queryset(NoticeQuerySet)(). What does Notice.objects.all().post('Lift working', by=rohan) do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It fails with AttributeError, because post() is on the manager, not on the QuerySet

    Put a method on the manager when it does not start from a QuerySet, such as creating a row; put it on the QuerySet when it narrows or changes a query, so that it chains.

The first manager is the default one, so it must not hide rows

Circular has two managers: objects, which leaves out unpublished circulars, and every, which does not. Because objects is declared first, it is the model’s default manager: The manager Django's own tools read a model through: the one Meta.default_manager_name names, otherwise the first manager the model declares. dumpdata and the admin use it, so it should not leave rows out., and Django’s own tools read the table through it. The program counts what each of them saw of three circulars, one of them a draft:

Who reads the table through the default manager

nivas/default_manager.py

"""Which manager Django's own tools use. Circular declares a manager that hides unpublished rows first, so it is the
default manager; Minutes declares a plain manager first and the filtered one under its own name. Both tables hold
three rows, one of them a draft. Run it next to manage.py.
"""

import io
import json

import seed  # noqa: F401  (starts Django, creates the tables, adds the rows)
from django.contrib import admin
from django.core.management import call_command
from django.test import RequestFactory

from notices.models import Attachment, Circular, Minutes


def dumped(label, *options):
    out = io.StringIO()
    call_command('dumpdata', label, *options, stdout=out)
    return [row['fields']['title'] for row in json.loads(out.getvalue())]


def in_admin(model):
    """The rows the admin's change list starts from: ModelAdmin.get_queryset()."""
    return admin.ModelAdmin(model, admin.site).get_queryset(RequestFactory().get('/admin/')).count()


print('Circular: objects = PublishedManager() comes first')
print('   default manager:', Circular._default_manager.name, '| rows in the table:', Circular.every.count(),
      '| Circular.objects.count():', Circular.objects.count())
print('   dumpdata notices.circular:', dumped('notices.circular'))
print('   dumpdata --all notices.circular:', len(dumped('notices.circular', '--all')), 'circulars | the admin lists',
      in_admin(Circular))
draft = Circular.every.get(published=False)
try:
    Circular.objects.get(pk=draft.pk)
except Circular.DoesNotExist:
    print(f'   Circular.objects.get(pk={draft.pk}): DoesNotExist')
attachment = Attachment.objects.get(file_name='agm-agenda.pdf')
print(f'   attachment.circular: {attachment.circular} (read with the base manager:',
      f'{type(Circular._base_manager).__name__})')

print('Minutes: objects = models.Manager() first, published_only = PublishedManager() second')
print('   default manager:', Minutes._default_manager.name, '| Minutes.published_only.count():',
      Minutes.published_only.count())
print('   dumpdata notices.minutes:', len(dumped('notices.minutes')), 'sets of minutes | the admin lists',
      in_admin(Minutes))

Output

Circular: objects = PublishedManager() comes first
   default manager: objects | rows in the table: 3 | Circular.objects.count(): 2
   dumpdata notices.circular: ['Maintenance dues for 2026-27', 'New visitor parking rules']
   dumpdata --all notices.circular: 3 circulars | the admin lists 2
   Circular.objects.get(pk=1): DoesNotExist
   attachment.circular: AGM agenda (draft) (read with the base manager: Manager)
Minutes: objects = models.Manager() first, published_only = PublishedManager() second
   default manager: objects | Minutes.published_only.count(): 2
   dumpdata notices.minutes: 3 sets of minutes | the admin lists 3

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

notices/models.py · nivas/notices/models.py

from django.db import models
from django.db.models import Q
from django.db.models.functions import Lower
from django.utils import timezone


class Flat(models.Model):
    code = models.CharField(max_length=10, unique=True)  # 'B-1203': tower B, flat 1203
    tower = models.CharField(max_length=1)

    def __str__(self):
        return self.code


class Resident(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField()
    phone = models.CharField(max_length=15)
    flat = models.ForeignKey(Flat, on_delete=models.CASCADE, related_name='residents')
    on_committee = models.BooleanField(default=False)

    class Meta:
        constraints = [
            # one account per email, whatever the capital letters: a unique index on LOWER(email)
            models.UniqueConstraint(Lower('email'), name='one_resident_per_email'),
        ]

    def __str__(self):
        return self.name


class NoticeQuerySet(models.QuerySet):
    """The questions the notice board asks, each a method that returns a QuerySet, so they chain."""

    def active(self, on=None):
        """Published, started, and not yet ended on the given day (today if none is given)."""
        on = on or timezone.localdate()
        return self.filter(Q(ends_on__isnull=True) | Q(ends_on__gte=on), published=True, starts_on__lte=on)

    def for_flat(self, flat):
        """Notices for the whole society (no tower) or for the flat's own tower."""
        return self.filter(Q(tower='') | Q(tower=flat.tower))

    def visible_to(self, resident):
        """Committee notices are for committee members only."""
        if resident.on_committee:
            return self
        return self.exclude(audience=Notice.Audience.COMMITTEE)


class NoticeManager(models.Manager):
    def post(self, title, by, **fields):
        """A method of the manager only: there is no QuerySet to start from when a notice is created."""
        return self.create(title=title, posted_by=by, starts_on=timezone.localdate(), **fields)


class Notice(models.Model):
    class Audience(models.TextChoices):
        EVERYONE = 'everyone'
        COMMITTEE = 'committee'

    title = models.CharField(max_length=120)
    category = models.CharField(max_length=20)  # water, lift, security, events …
    tower = models.CharField(max_length=1, blank=True)  # '' means the whole society
    audience = models.CharField(max_length=10, choices=Audience, default=Audience.EVERYONE)
    published = models.BooleanField(default=True)
    starts_on = models.DateField()
    ends_on = models.DateField(null=True, blank=True)
    posted_by = models.ForeignKey(Resident, on_delete=models.PROTECT, related_name='notices')

    objects = NoticeManager.from_queryset(NoticeQuerySet)()

    def __str__(self):
        return self.title


class ComplaintQuerySet(models.QuerySet):
    def still_open(self):
        return self.filter(closed_on__isnull=True)

    def in_tower(self, tower):
        return self.filter(tower=tower)


class Complaint(models.Model):
    tower = models.CharField(max_length=1)
    subject = models.CharField(max_length=120)
    raised_on = models.DateField()
    closed_on = models.DateField(null=True, blank=True)

    objects = ComplaintQuerySet.as_manager()  # the QuerySet's public methods, copied onto a manager

    def __str__(self):
        return self.subject


class PublishedManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(published=True)


class Circular(models.Model):
    """The mistake: the filtered manager is declared first, so it is the default manager."""

    title = models.CharField(max_length=120)
    published = models.BooleanField(default=False)

    objects = PublishedManager()
    every = models.Manager()

    def __str__(self):
        return self.title


class Minutes(models.Model):
    """The fix: a plain manager first, the filtered one under its own name."""

    title = models.CharField(max_length=120)
    published = models.BooleanField(default=False)

    objects = models.Manager()
    published_only = PublishedManager()

    class Meta:
        verbose_name_plural = 'minutes'

    def __str__(self):
        return self.title


class Attachment(models.Model):
    circular = models.ForeignKey(Circular, on_delete=models.CASCADE, related_name='attachments')
    file_name = models.CharField(max_length=100)

    def __str__(self):
        return self.file_name
Who read Circular Through Saw the draft?
Circular.objects.get(pk=1) in your code the default manager no: DoesNotExist
dumpdata notices.circular the default manager no: 2 of 3 rows saved
the admin’s change list the default manager no: 2 listed
attachment.circular the base manager yes
dumpdata --all notices.circular the base manager yes: 3 rows

A backup made with dumpdata and a staff member looking in the admin both missed the draft, and nothing raised an error. Django’s documentation states the rule: the first manager defined in a class is the default, and several parts of Django, dumpdata among them, use that manager exclusively. Related objects are the exception. attachment.circular goes through the base manager, a plain Manager unless Meta.base_manager_name names another, because Django must be able to fetch a related object even when the default manager would hide it, and the documentation warns against filtering rows out of a base manager for the same reason.

Minutes has the same two kinds of manager the other way round, a plain objects first and published_only second. Every tool saw all 3 sets of minutes, while Minutes.published_only still counted 2. Meta.default_manager_name chooses the default by name when the order of declaration should not decide.

Hiding drafts in objects

objects = PublishedManager() as the first manager looks like a safe default for the public pages, and it removes drafts from backups, from the admin and from every Circular.objects query in the code. Keep objects plain and give the filtered manager a name of its own, published_only = PublishedManager(), or make it a QuerySet method.

Checkpoint

Check: The default manager

  1. Question 1 of 2 Circular declares objects = PublishedManager() first, and its table holds 3 circulars, one of them a draft. How many did dumpdata notices.circular save?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 2 circulars

    dumpdata reads every model through its default manager, the first one declared, which left out the draft. With --all it used the base manager and saved all 3.

  2. Question 2 of 2 Circular.objects.get(pk=1) raised DoesNotExist for the draft, yet attachment.circular returned it. Why?
    Show the answer: Question 2 of 2

    Answer: Following a foreign key uses the model's base manager, which never filters, not its default manager

    The base manager is a plain Manager unless Meta.base_manager_name names another, and its get_queryset() should never filter rows out.

Raw SQL takes its values as parameters, never inside the string

Some questions are easier to ask in SQL. The committee wants the complaints raised in tower B for every month from May to October, months without any included; those months have no rows to group, so the SQL makes them with a recursive WITH. Django runs such SQL in two ways, and part 3 tries one lookup three ways:

raw(), cursor.execute() and one lookup written three ways Python · nivas/safe_raw.py
"""Raw SQL on SQLite, with the values always passed as parameters: Manager.raw() returns model instances,
connection.cursor() returns plain rows. Then one lookup written three ways and checked with a real name that has an
apostrophe in it. Run it next to manage.py.
"""

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 Resident

print('1. Resident.objects.raw(): residents of tower B, with the flat code from a JOIN')
residents = Resident.objects.raw(
    'SELECT r.id, r.name, f.code AS flat_code FROM notices_resident r '
    'JOIN notices_flat f ON f.id = r.flat_id WHERE f.tower = %s ORDER BY r.name',
    ['B'],
)
with CaptureQueriesContext(connection) as queries:
    for resident in residents:
        print(f'   {type(resident).__name__}: {resident.name}, flat_code {resident.flat_code}')
print(f'   queries: {len(queries)}')
with CaptureQueriesContext(connection) as queries:
    emails = [resident.email for resident in residents]
print(f'   reading .email, which the SELECT left out: {len(queries)} more queries')

print('2. connection.cursor(): complaints raised in tower B per month, months with none included')
REPORT = """
    WITH RECURSIVE months(month) AS (
        SELECT %s
        UNION ALL
        SELECT date(month, '+1 month') FROM months WHERE month < %s
    )
    SELECT strftime('%%Y-%%m', month), COUNT(c.id)
    FROM months
    LEFT JOIN notices_complaint c
        ON strftime('%%Y-%%m', c.raised_on) = strftime('%%Y-%%m', month) AND c.tower = %s
    GROUP BY month
    ORDER BY month
"""
with connection.cursor() as cursor:
    cursor.execute(REPORT, ['2026-05-01', '2026-10-01', 'B'])
    for month, complaints in cursor.fetchall():
        print(f'   {month}  {complaints} {"#" * complaints}'.rstrip())

print("3. One lookup written three ways, checked with the name Leena D'Souza")


def formatted(name):
    """Never do this: the name becomes part of the SQL text."""
    with connection.cursor() as cursor:
        cursor.execute(f"SELECT name FROM notices_resident WHERE name = '{name}'")
        return cursor.fetchall()


def quoted_placeholder(name):
    """Never do this either: '%s' in quotes is a piece of text, not a placeholder."""
    with connection.cursor() as cursor:
        cursor.execute("SELECT name FROM notices_resident WHERE name = '%s'", [name])
        return cursor.fetchall()


def parameter(name):
    """The value travels apart from the SQL, and the database never reads it as SQL."""
    with connection.cursor() as cursor:
        cursor.execute('SELECT name FROM notices_resident WHERE name = %s', [name])
        return cursor.fetchall()


for find in (formatted, quoted_placeholder, parameter):
    try:
        rows = find("Leena D'Souza")
        print(f'   {find.__name__:<19} found {len(rows)}: {rows}')
    except Exception as error:  # the first two fail, each in its own way
        print(f'   {find.__name__:<19} {type(error).__name__}: {error}')

Output

1. Resident.objects.raw(): residents of tower B, with the flat code from a JOIN
   Resident: Asha K., flat_code B-1203
   Resident: Rohan M., flat_code B-1204
   queries: 1
   reading .email, which the SELECT left out: 2 more queries
2. connection.cursor(): complaints raised in tower B per month, months with none included
   2026-05  0
   2026-06  2 ##
   2026-07  0
   2026-08  0
   2026-09  2 ##
   2026-10  0
3. One lookup written three ways, checked with the name Leena D'Souza
   formatted           OperationalError: near "Souza": syntax error
   quoted_placeholder  ProgrammingError: Incorrect number of bindings supplied. The current statement uses 0, and there are 1 supplied.
   parameter           found 1: [("Leena D'Souza",)]

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

Resident.objects.raw() returned Resident objects, and the extra column flat_code became an attribute of each. The primary key must be among the columns, or Django raises FieldDoesNotExist, and a field the SELECT left out is loaded when it is first read: reading email cost 2 more queries, one per resident. connection.cursor() returned plain tuples, one per month. Both took every value in the params list, with a bare %s for it in the SQL, which Django’s documentation asks for whatever the database; a literal percent sign is then written %%, as in strftime('%%Y-%%m', …).

Part 3 looked up Leena D’Souza. Step through what the database received each time:

Interactive · message by message One name, sent two ways Step through the lookup for Leena D'Souza, first formatted into the SQL, then as a parameter. Watch where the apostrophe ends up.
One name, sent two ways, step 6 of 6. Messages so far: Your code to sqlite3 driver: one string; sqlite3 driver to SQLite: name = 'Leena D'Souza'; SQLite to Your code: OperationalError; Your code to sqlite3 driver: SQL and [name]; sqlite3 driver to SQLite: name = ?, bound; SQLite to Your code: 1 row.Your codeLeena D'Souzasqlite3 driverSQLitesecond queryone stringname = 'Leena D'Souza'OperationalErrorSQL and [name]name = ?, bound1 row

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

Every step as text
  1. Formatted into the text. The f-string puts the name inside the SQL, between quotes: WHERE name = 'Leena D'Souza'. The driver gets one string and no value to bind.
  2. Read as SQL. SQLite reads the whole text as SQL. The apostrophe after D ends the string 'Leena D', and Souza' is left where SQL should continue.
  3. A syntax error. SQLite stops with near "Souza": syntax error, and Django raises OperationalError. The name had changed the statement; a value written to change it could have run.
  4. As a parameter. This time the SQL holds a bare %s and the name travels in the params list. Django turns %s into SQLite's own placeholder, ?.
  5. Bound, never parsed. The driver hands SQLite the statement with its ? and binds the name to it as a value. SQLite parses the statement alone; the name is never read as SQL.
  6. One row. SQLite compares the column with the bound value, apostrophe and all, and returns one row: Leena D'Souza.

Formatted into the string, the apostrophe ended the SQL text 'Leena D' early, and SQLite failed on what was left: the name had become part of the statement. That gap is SQL injection: A flaw in which a value from outside, such as a name typed into a form, ends up in the text of an SQL statement and can change what the statement does. Passing every value as a parameter prevents it., and a value written to change the statement, instead of an innocent name, would have run as SQL. The placeholder in quotes, '%s', was a piece of text, so the statement had no placeholder at all, and the driver refused the parameter. Passed as a parameter, the value could not change the statement: SQLite’s driver binds it to the statement, and with psycopg, Django’s default cursors merge it into the statement on the client, quoted and escaped by the driver as a value.

An f-string around SQL

cursor.execute(f"… WHERE name = '{name}'") passes every test with plain names, breaks on the first apostrophe, and lets a crafted value change the statement. Write cursor.execute("… WHERE name = %s", [name]), with no quotes around %s, and pass raw() its values the same way.

Checkpoint

Check: Raw SQL with parameters

  1. Question 1 of 2 A cursor.execute() call passes params and its SQL needs SQLite's strftime('%Y-%m', raised_on). How must the SQL write it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: strftime('%%Y-%%m', raised_on)

    Django's documentation says to double percent signs in the SQL when parameters are passed; without parameters the SQL is used as it is.

  2. Question 2 of 2 cursor.execute("SELECT name FROM notices_resident WHERE name = '%s'", [name]) failed. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In quotes, '%s' is a piece of text, so the statement had no placeholder for the parameter

    The documentation says never to quote placeholders: the driver writes the value in its own way, quotes included when they are needed.

Query-string filters pass through an allowlist

A notice board with filters, ?category=water&tower=B, tempts a one-line view: Notice.objects.filter(**request.GET.dict()). The visitor, not the view, then chooses the fields and the lookups. This program sends four query strings to such a view and to one that maps each key the page offers to a lookup, through an allowlist: A list of the only things accepted, such as the query-string keys a page offers and the lookup each one becomes; anything not on the list is refused.:

Four query strings, straight to filter() and through an allowlist Python · nivas/query_string_filters.py
"""The notice board's filters, from the query string. board_from_query() passes the query string straight to filter(),
as filter(**request.GET.dict()) would; board_with_allowlist() maps the keys it knows to lookups and refuses the rest.
Four query strings go to both. An error that escapes a view becomes a 500 page; BadFilter is answered with a 400.
Run it next to manage.py.
"""

from urllib.parse import parse_qsl

import seed  # noqa: F401  (starts Django, creates the tables, adds the rows)
from django.core.exceptions import FieldError

from notices.models import Notice

ALLOWED = {  # query-string key -> the lookup it may become
    'category': 'category',
    'tower': 'tower',
    'from': 'starts_on__gte',
}


class BadFilter(ValueError):
    """A query-string key the page does not offer; the view answers 400 Bad Request."""


def lookups_from(query, allowed):
    unknown = sorted(key for key in query if key not in allowed)
    if unknown:
        raise BadFilter(f'unknown filter: {", ".join(unknown)}')
    return {allowed[key]: value for key, value in query.items()}


def board_from_query(query):
    return Notice.objects.filter(**query)  # what filter(**request.GET.dict()) does


def board_with_allowlist(query):
    return Notice.objects.filter(**lookups_from(query, ALLOWED))


def show(board, query):
    try:
        titles = [notice.title for notice in board(query).order_by('pk')]
    except BadFilter as error:
        return f'400 Bad Request: {error}'
    except (FieldError, TypeError) as error:
        return f'500 Server Error, {type(error).__name__}: {error}'
    found = f'{len(titles)} notice' + ('' if len(titles) == 1 else 's')
    return found + (f': {" | ".join(titles)}' if len(titles) < 3 else '')


for text in ['category=water&tower=B', 'categry=water', 'posted_by__phone__startswith=91',
             'category=water&_connector=OR']:
    query = dict(parse_qsl(text))
    print(f'?{text}')
    print(f'   from the query string  {show(board_from_query, query)}')
    print(f'   through the allowlist  {show(board_with_allowlist, query)}')

Output

?category=water&tower=B
   from the query string  1 notice: Water cut in tower B, 10:00 to 14:00
   through the allowlist  1 notice: Water cut in tower B, 10:00 to 14:00
?categry=water
   from the query string  500 Server Error, FieldError: Cannot resolve keyword 'categry' into field. Choices are: audience, category, ends_on, id, posted_by, posted_by_id, published, starts_on, title, tower
   through the allowlist  400 Bad Request: unknown filter: categry
?posted_by__phone__startswith=91
   from the query string  5 notices
   through the allowlist  400 Bad Request: unknown filter: posted_by__phone__startswith
?category=water&_connector=OR
   from the query string  500 Server Error, TypeError: The following kwargs are invalid: '_connector'
   through the allowlist  400 Bad Request: unknown filter: _connector

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

Query string filter(**query) Through the allowlist
category=water&tower=B 1 notice 1 notice
categry=water 500: a FieldError that lists every field 400: unknown filter
posted_by__phone__startswith=91 5 notices, chosen by phone number 400: unknown filter
category=water&_connector=OR 500: TypeError 400: unknown filter

The first query string is what the page offers, and both views agreed. A typo became a server error whose message named every field of the model. posted_by__phone__startswith followed a relation to a field the page never shows, and the count of notices answered a question about residents’ phone numbers. The last key is no field at all: _connector and _negated are keyword arguments of Q, so a dictionary unpacked into filter() could set them. Before Django 5.2.8, 5.1.14 and 4.2.26, a crafted _connector led to SQL injection, CVE-2025-64459, rated high under Django’s security policy; the fixed releases refuse both keys in filter(), exclude() and get(), as the TypeError of 6.1.2 shows.

The fix closed that hole and left the rest open: the query string still chose fields, relations and lookups. The allowlist decides what each key may become, from as starts_on__gte, and answers any other key with a 400 that names it.

Key idea

Never let a request choose the lookups: map each query-string key the page offers to its lookup, and refuse every other key, whichever Django release runs.

Checkpoint

Check: Filters through an allowlist

  1. Question 1 of 2 On Django 6.1.2, the view Notice.objects.filter(**request.GET.dict()) answered ?posted_by__phone__startswith=91 with 5 notices. What is wrong with that?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The visitor filtered on a field the page never shows, and the count told them about residents' phone numbers

    Only an allowlist decides which fields a request may filter on; it answered this key with a 400.

  2. Question 2 of 2 What did Django 6.1.2 do with ?category=water&_connector=OR passed to filter(**query)?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Raised TypeError: The following kwargs are invalid: '_connector'

    CVE-2025-64459 was SQL injection through a crafted _connector in a dictionary unpacked into filter(), exclude(), get() or Q(); the fix refuses the key.

A plan shows how the database runs a query, and ANALYZE also runs it

Residents log in with their email typed in any case, so the login lookup is email__iexact. On a table of 5,000 residents it found Asha K., and explain() shows how PostgreSQL went about it:

The login lookup, planned and run by PostgreSQL 18

nivas/plan_login.py

"""The login lookup, a resident by email whatever the capital letters, planned by PostgreSQL 18: read with explain(),
then with explain(analyze=True), which runs the query. The table holds 5,000 residents (residents.py) and the unique
index one_resident_per_email on LOWER(email) (notices/models.py); the lookup is Django's iexact. PostgreSQL runs on a
throwaway server of its own (pgserver.py). SQLite plans the same query in the last line.

Times change on every run, so the run prints them as …; everything else is as PostgreSQL printed it.
"""

import re

import pgserver

pgserver.start()

import residents  # noqa: E402  (5,000 residents and fresh statistics)
from django.db import connection  # noqa: E402
from django.test.utils import CaptureQueriesContext  # noqa: E402

from notices.models import Resident  # noqa: E402
from seed import where  # noqa: E402

TIME = re.compile(r'(actual time=)[\d.]+\.\.[\d.]+|(Time: )[\d.]+')


def plan(text):
    return '\n'.join('     ' + line for line in TIME.sub(lambda m: (m.group(1) or m.group(2)) + '…', text).splitlines())


login = Resident.objects.filter(email__iexact='asha.k@example.com')
print(f'{residents.server()} | {Resident.objects.count():,} residents in {residents.PAGES} pages')
with CaptureQueriesContext(connection) as sent:
    print("email__iexact='asha.k@example.com':", [resident.name for resident in login])
print('  ', where(sent[0]['sql']))
print('   explain():')
print(plan(login.explain()))
print('   explain(analyze=True):')
print(plan(login.explain(analyze=True)))
print('SQLite, explain():', login.using('sqlite').explain())

Output

PostgreSQL 18.6 | 5,000 residents in 62 pages
email__iexact='asha.k@example.com': ['Asha K.']
   WHERE UPPER(resident.email::text) = UPPER('asha.k@example.com')
   explain():
     Seq Scan on notices_resident  (cost=0.00..137.00 rows=25 width=67)
       Filter: (upper((email)::text) = 'ASHA.K@EXAMPLE.COM'::text)
   explain(analyze=True):
     Seq Scan on notices_resident  (cost=0.00..137.00 rows=25 width=67) (actual time=… rows=1.00 loops=1)
       Filter: (upper((email)::text) = 'ASHA.K@EXAMPLE.COM'::text)
       Rows Removed by Filter: 4999
       Buffers: shared hit=62
     Planning Time: … ms
     Execution Time: … ms
SQLite, explain(): 2 0 216 SCAN notices_resident

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

nivas/residents.py

"""Adds 4,995 residents to the five of seed.py, in every database the settings name, and gathers PostgreSQL's
statistics with ANALYZE, as autovacuum would. The plan examples import it after pgserver.start().
"""

import seed  # noqa: F401  (starts Django, creates the tables, adds the rows)
from django.db import connection, connections

from notices.models import Flat, Resident

for alias in connections:
    flats = list(Flat.objects.using(alias).order_by('pk'))
    Resident.objects.using(alias).bulk_create([
        Resident(name=f'Resident {n}', email=f'resident{n}@nivas.example', phone=f'90000{n:05}', flat=flats[n % 5])
        for n in range(6, 5001)
    ])
with connection.cursor() as cursor:
    cursor.execute('ANALYZE notices_resident')
    cursor.execute("SELECT relpages, reltuples FROM pg_class WHERE relname = 'notices_resident'")
    PAGES, ROWS = cursor.fetchone()


def server():
    """PostgreSQL 18.6, as the server reports its version: 180006."""
    return f'PostgreSQL {connection.pg_version // 10000}.{connection.pg_version % 10000}'

nivas/pgserver.py

"""A throwaway PostgreSQL 18 server for one example: start() creates a database cluster in a folder of its own, starts
the server listening only on 127.0.0.1, an address no other computer can reach, and tells Django its port through
NIVAS_PG_PORT (config/settings.py reads it). The server is stopped and its folder deleted when the example ends, also
when it fails. Autovacuum is off, so the table statistics the plans use are the ones the example gathers itself, with
ANALYZE, and every run plans the same way.

It needs PostgreSQL's server programs, initdb and pg_ctl, on PATH (or in the mise installation beside this Python),
and the psycopg package, which Django uses to talk to PostgreSQL.
"""

import atexit
import os
import shutil
import socket
import subprocess
import sys
import tempfile
from pathlib import Path


def _postgres_bin():
    on_path = shutil.which('pg_ctl')
    if on_path:
        return Path(on_path).parent
    installs = Path(sys.executable).resolve().parents[3]  # <mise>/installs/python/3.14.8/bin/python3
    found = sorted(installs.glob('postgres/18.*/bin/pg_ctl'))
    if not found:
        sys.exit('PostgreSQL 18 was not found: install it so that pg_ctl is on PATH')
    return found[-1].parent


def start():
    pg = _postgres_bin()
    work = Path(tempfile.mkdtemp(prefix='pg', dir='.')).resolve()
    data = work / 'data'
    subprocess.run([pg / 'initdb', '-D', data, '-U', 'postgres', '-A', 'trust', '-E', 'UTF8', '--no-locale'],
                   check=True, capture_output=True)
    with socket.socket() as probe:  # ask the operating system for a port that nothing uses
        probe.bind(('127.0.0.1', 0))
        port = probe.getsockname()[1]
    with open(data / 'postgresql.conf', 'a') as conf:
        conf.write(f"listen_addresses = '127.0.0.1'\nport = {port}\nunix_socket_directories = ''\nautovacuum = off\n")
    subprocess.run([pg / 'pg_ctl', '-D', data, '-l', work / 'server.log', '-w', 'start'], check=True,
                   capture_output=True)

    def stop():
        subprocess.run([pg / 'pg_ctl', '-D', data, '-m', 'fast', '-w', 'stop'], capture_output=True)
        shutil.rmtree(work, ignore_errors=True)

    atexit.register(stop)
    os.environ['NIVAS_PG_PORT'] = str(port)

A query plan: The steps a database chooses for running a query, printed by EXPLAIN as a tree of nodes: the scans, the indexes they use, and estimated costs and rows; with ANALYZE, also what the run found and how long it took. is a tree of nodes: each node has a line of its own, the indented lines under it give its details, and a child node starts with ->. This one has a single node. Walk through its parts:

Interactive · stage by stage Reading the plan of the login lookup Step through the plan of email__iexact, part by part, in the order PostgreSQL printed it.
Reading the plan of the login lookup, step 8 of 8. Showing Seq Scan, cost=0.00..137.00, rows=25 width=67, Filter: upper(email) = …, actual rows=1.00, Rows Removed: 4999, Buffers: shared hit=62, Planning, Execution Time. In focus: Planning, Execution Time.Seq Scanon notices_residentcost=0.00..137.00first row .. all rowsrows=25 width=67expected rows and bytesFilter: upper(email) = …tested on every row readactual rows=1.00found by the run, loops=1Rows Removed: 4999read and thrown awayBuffers: shared hit=6262 pages from memoryPlanning, Execution Timemilliseconds, every run

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

Every step as text
  1. The node. The first line is the plan's top node, and the indented lines under it are its details; a child node would start with ->. Seq Scan reads the table notices_resident from its first page to its last.
  2. The cost. Two estimates in the planner's own units, by convention disk page fetches: 0.00 before the first row, 137.00 for all of them. 62 pages × 1.0 + 5,000 rows × 0.01 + 5,000 × 2 × 0.0025 = 137.
  3. The estimate. rows=25 is how many rows the planner expected the node to return, and width=67 their average size in bytes. explain() alone stops here: it plans, and runs nothing.
  4. The filter. Filter is the condition tested on every row the scan reads: upper((email)::text) = 'ASHA.K@EXAMPLE.COM', the SQL that iexact sent. Nothing about it narrows which pages are read.
  5. What the run found. With analyze=True the query ran. actual rows=1.00 is what the node returned, once (loops=1). 25 expected against 1 found is the gap to look at first.
  6. Rows thrown away. Rows Removed by Filter: 4999. The scan read every resident to keep one, so its work grows with the table, not with the answer.
  7. Pages read. Buffers: shared hit=62 counts the pages the node read, all 62 of the table, each found in PostgreSQL's memory. PostgreSQL 18 adds this line to every ANALYZE.
  8. The times. Planning Time and Execution Time close the plan, in milliseconds. They change from run to run, which is why the recorded output shows them as …

Seq Scan read the table from start to end. Its cost, 0.00..137.00, is two estimates in the planner’s own units, which by convention are disk page fetches: the cost before the first row and the cost of all of them. PostgreSQL’s documentation shows how the total is computed, and this run’s numbers check out: 62 pages × 1.0, plus 5,000 rows × 0.01, plus 5,000 rows × 2 operations (upper() and =) × 0.0025, is 62 + 50 + 25 = 137. rows=25 was the estimate.

explain(analyze=True) ran the query and added what happened: 1 row found, Rows Removed by Filter: 4999, and Buffers: shared hit=62 for the 62 pages read from memory, a line PostgreSQL 18 adds to every ANALYZE, then the planning and execution times. PostgreSQL’s documentation calls the comparison of estimated and actual rows the thing usually most important to look for; here 25 rows were expected and 1 was found, after reading every page. SQLite’s plan said the same in fewer words: SCAN notices_resident.

analyze=True runs the query

Django’s documentation warns that the ANALYZE flag executes the query, and that a trigger or a function it calls can change data even for a SELECT. Run it on a copy of production, and in psql wrap EXPLAIN ANALYZE of an UPDATE in BEGIN and ROLLBACK, as PostgreSQL’s documentation shows.

Checkpoint

Check: Reading a query plan

  1. Question 1 of 2 The same iexact scan on a table of 10,000 residents in 124 pages, with PostgreSQL's default costs. What total cost would the Seq Scan show?

    Type a number.

    A hint

    Pages × 1.0, rows × 0.01, and rows × 2 operations × 0.0025.

    Show the answer: Question 1 of 2

    Answer: 274 cost units (anything from 273.5 to 274.5 counts)

    124 × 1.0 + 10,000 × 0.01 + 10,000 × 2 × 0.0025 = 124 + 100 + 50 = 274, in the same way as 62 + 50 + 25 = 137 for 5,000 residents in 62 pages.

  2. Question 2 of 2 What does Rows Removed by Filter: 4999 in the plan of the login lookup say?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The scan read 4,999 rows that its Filter then threw away, to keep 1

    Many rows removed by a filter on a large table points at an index the query cannot use, as the next section shows for iexact.

A custom lookup can use the index the table already has

The table has an index for exactly this search: one_resident_per_email, the unique index on LOWER(email) that keeps each email unique whatever its capital letters. The plan did not use it, because on PostgreSQL iexact compares UPPER(email::text), and an index on an expression serves only a query that uses the same expression. A custom lookup: A Lookup subclass registered on a field class and used in filters as field__name. Its as_sql() returns the SQL comparison and its params, such as LOWER(email) = LOWER(%s) for email__ci. can write the comparison the index was built for:

email__ci against email__iexact, on PostgreSQL 18 and SQLite

nivas/ci_lookup.py

"""The ci lookup of notices/lookups.py against Django's iexact: the SQL each sends, and the plan PostgreSQL 18 and
SQLite choose for the login lookup on 5,000 residents (residents.py), with the unique index on LOWER(email). PostgreSQL
runs on a throwaway server of its own (pgserver.py).

Times change on every run, so the run prints them as …; everything else is as PostgreSQL printed it.
"""

import re

import pgserver

pgserver.start()

import residents  # noqa: E402  (5,000 residents and fresh statistics)
from django.db import connection  # noqa: E402
from django.test.utils import CaptureQueriesContext  # noqa: E402

from notices.models import Resident  # noqa: E402
from seed import where  # noqa: E402

TIME = re.compile(r'(actual time=)[\d.]+\.\.[\d.]+|(Time: )[\d.]+')


def plan(text):
    return '\n'.join('     ' + line for line in TIME.sub(lambda m: (m.group(1) or m.group(2)) + '…', text).splitlines())


print(f'{residents.server()} | {Resident.objects.count():,} residents')
for lookup in ['iexact', 'ci']:
    search = Resident.objects.filter(**{f'email__{lookup}': 'Asha.K@example.COM'})
    with CaptureQueriesContext(connection) as sent:
        names = [resident.name for resident in search]
    print(f"email__{lookup}='Asha.K@example.COM': {names}")
    print('  ', where(sent[0]['sql']))
    if lookup == 'ci':
        print('   explain(analyze=True):')
        print(plan(search.explain(analyze=True)))
    print('   SQLite:', search.using('sqlite').explain())

Output

PostgreSQL 18.6 | 5,000 residents
email__iexact='Asha.K@example.COM': ['Asha K.']
   WHERE UPPER(resident.email::text) = UPPER('Asha.K@example.COM')
   SQLite: 2 0 216 SCAN notices_resident
email__ci='Asha.K@example.COM': ['Asha K.']
   WHERE LOWER(resident.email) = LOWER('Asha.K@example.COM')
   explain(analyze=True):
     Index Scan using one_resident_per_email on notices_resident  (cost=0.28..8.30 rows=1 width=67) (actual time=… rows=1.00 loops=1)
       Index Cond: (lower((email)::text) = 'asha.k@example.com'::text)
       Index Searches: 1
       Buffers: shared hit=3
     Planning Time: … ms
     Execution Time: … ms
   SQLite: 3 0 39 SEARCH notices_resident USING INDEX one_resident_per_email (<expr>=?)

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

notices/lookups.py · nivas/notices/lookups.py

"""A case-insensitive equality that a unique index on LOWER(email) can serve: email__ci='Asha.K@Example.com' becomes
LOWER("email") = LOWER(%s). Django's own iexact compares UPPER(…) on PostgreSQL instead, which that index cannot
serve.
"""

from django.db.models import CharField, Lookup


@CharField.register_lookup  # EmailField and SlugField are CharFields, so they get it too
class CaseInsensitiveEqual(Lookup):
    lookup_name = 'ci'

    def as_sql(self, compiler, connection):
        lhs, lhs_params = self.process_lhs(compiler, connection)
        rhs, rhs_params = self.process_rhs(compiler, connection)
        # Since Django 6.0 the params of as_sql() are a tuple.
        return f'LOWER({lhs}) = LOWER({rhs})', (*lhs_params, *rhs_params)

notices/apps.py · nivas/notices/apps.py

from django.apps import AppConfig


class NoticesConfig(AppConfig):
    name = 'notices'

    def ready(self):
        from . import lookups  # noqa: F401  (registers the ci lookup on CharField and its subclasses)

CaseInsensitiveEqual has a lookup_name, ci, and an as_sql() that asks Django for the two sides, process_lhs() for the column and process_rhs() for the placeholder of the value, and returns LOWER(…) = LOWER(…) with their params. Registered on CharField when the app is ready, it works on EmailField, a subclass, as email__ci. Both lookups found Asha K. typed as Asha.K@example.COM, and the plans differed:

email__iexact email__ci
SQL UPPER(email::text) = UPPER(%s) LOWER(email) = LOWER(%s)
PostgreSQL plan Seq Scan, cost 137.00 Index Scan using one_resident_per_email, cost 8.30
Rows read to find 1 5,000, of which 4,999 removed 1, through the Index Cond
Pages read 62 3
SQLite plan SCAN notices_resident SEARCH … USING INDEX one_resident_per_email

With 5,000 residents the difference is 62 pages against 3, and only the scan grows page by page with the table. PostgreSQL also folded LOWER('Asha.K@example.COM') into the constant 'asha.k@example.com' before searching the index. Keep such a lookup small, test it on every database the project runs, and confirm its plan with explain().

Changed in Django 6.0

The params that as_sql() returns should now be a tuple. Django’s release notes suggest building them by unpacking, (*lhs_params, *rhs_params), which works whether the parts are lists or tuples.

Checkpoint

Check: A custom lookup for an index

  1. One question The table has a unique index on LOWER(email). Why did email__iexact not use it on PostgreSQL?

    Choose one answer.

    Show the answer: One question

    Answer: iexact compares UPPER(email::text), and an index on an expression serves only a query that uses that expression

    The fix was not a new index but a lookup that asks the question in the form of the index the table already had.

Practise: allowlist the filters and read the plans

The allowlist and the plan, rebuilt in plain Python: first the function that turns a query string into lookups and refuses the rest, then a reader that takes the scan, its index and its rows from a printed plan. Run the sample tests of each step in the page; a hint waits under each one:

Code lab · 2 steps

Allowlist the filters and read the plans

Map query-string keys to lookups through an allowlist, then read the scan, the index and the rows of a PostgreSQL plan.

  1. Map query-string filters through an allowlist To do
  2. Read the scan, its index and its rows from a plan To do

Exercise · Easy · Python

Map query-string filters through an allowlist

query_string_filters.py passed the notice board's query string to filter() as it came, and a visitor could filter on any field, or crash the page with a key that is no field at all. Write the allowlist that sits in between.

safe_filters(params, allowed) gets the query string as a dictionary, {'category': 'water', 'tower': 'B'}, and the allowlist, a dictionary from each key the page offers to the lookup it may become, such as {'from': 'starts_on__gte'}. It returns the dictionary of lookups to pass to filter().

  • A key the allowlist does not have, whatever it looks like, raises BadFilter. Its message is unknown filter: followed by every refused key, sorted and separated by , .
  • A key with an empty value, which a form field left blank sends, is left out.
  • The dictionary it was given stays as it was.

Starter code · filters.py

class BadFilter(ValueError):
    """A query-string key the page does not offer; the view answers 400 Bad Request."""


def safe_filters(params, allowed):
    """The lookups for filter(), from the query string's params, through the allowlist."""
    return dict(params)
The sample tests · test_filters.py
from filters import BadFilter, safe_filters

ALLOWED = {'category': 'category', 'tower': 'tower', 'from': 'starts_on__gte'}


def refused(params):
    try:
        safe_filters(params, ALLOWED)
    except BadFilter as error:
        return str(error)
    raise AssertionError(f'safe_filters() should have refused {params}')


def test_allowed_keys_pass():
    """the keys the page offers become their lookups"""
    assert safe_filters({'category': 'water', 'tower': 'B'}, ALLOWED) == {'category': 'water', 'tower': 'B'}


def test_key_becomes_its_lookup():
    """'from' is a key of the page; starts_on__gte is the lookup it stands for"""
    assert safe_filters({'from': '2026-10-01'}, ALLOWED) == {'starts_on__gte': '2026-10-01'}


def test_misspelt_key_is_refused():
    """a typo is answered with a 400, not a FieldError"""
    assert refused({'categry': 'water'}) == 'unknown filter: categry'


def test_related_lookup_is_refused():
    """a lookup through posted_by would filter on a field the page never shows"""
    assert refused({'posted_by__phone__startswith': '91'}) == 'unknown filter: posted_by__phone__startswith'


def test_connector_is_refused():
    """_connector is no field at all"""
    assert refused({'category': 'water', '_connector': 'OR'}) == 'unknown filter: _connector'


def test_lookup_added_to_allowed_key_is_refused():
    """keys are matched whole: category__in is not category"""
    assert refused({'category__in': 'water'}) == 'unknown filter: category__in'


def test_every_refused_key_is_named_in_order():
    """the message names all of them, sorted"""
    assert refused({'tower': 'B', 'zone': '1', '_negated': '1'}) == 'unknown filter: _negated, zone'


def test_empty_values_are_left_out():
    """a form field left blank sends key=, which filters nothing"""
    assert safe_filters({'category': '', 'tower': 'B'}, ALLOWED) == {'tower': 'B'}


def test_params_are_not_changed():
    """the request's own dictionary is left as it was"""
    params = {'category': 'water', 'from': '2026-10-01'}
    safe_filters(params, ALLOWED)
    assert params == {'category': 'water', 'from': '2026-10-01'}
A hint

Look up each key whole in allowed: category__in is not category. Collect the refused keys first and raise once, with all of them; only then build the new dictionary, skipping the empty values.

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

Read the scan, its index and its rows from a plan

Read the parts of a PostgreSQL plan that the lesson read by eye. read_plan(text) gets what explain() or explain(analyze=True) printed for a query on one table, and returns a dictionary:

  • node: the kind of scan on the first line, such as 'Seq Scan', 'Index Scan' or 'Bitmap Heap Scan';
  • table: the table it reads;
  • index: the index it uses, or None. A Bitmap Heap Scan names no index on its own line: the index is on the Bitmap Index Scan on … line under it;
  • estimated: the rows= of the estimate, a whole number;
  • actual: the rows= of (actual time=… rows=… loops=…) as a float, or None when the plan was not run;
  • removed: the number on the Rows Removed by Filter: line, or 0 when there is none.

Starter code · plan.py

def read_plan(text):
    """The scan, its table, its index, and its estimated, actual and removed rows."""
    first = text.splitlines()[0]
    node, rest = first.split(' on ', 1)
    return {'node': node, 'table': rest.split()[0], 'index': None, 'estimated': 0, 'actual': None, 'removed': 0}
The sample tests · test_plan.py
from plan import read_plan

SEQ = """Seq Scan on notices_resident  (cost=0.00..137.00 rows=25 width=67)
  Filter: (upper((email)::text) = 'ASHA.K@EXAMPLE.COM'::text)"""

SEQ_RUN = """Seq Scan on notices_resident  (cost=0.00..137.00 rows=25 width=67) (actual time=0.021..0.684 rows=1.00 loops=1)
  Filter: (upper((email)::text) = 'ASHA.K@EXAMPLE.COM'::text)
  Rows Removed by Filter: 4999
  Buffers: shared hit=62
Planning Time: 0.090 ms
Execution Time: 0.703 ms"""

INDEX_RUN = """Index Scan using one_resident_per_email on notices_resident  (cost=0.28..8.30 rows=1 width=67) (actual time=0.017..0.018 rows=1.00 loops=1)
  Index Cond: (lower((email)::text) = 'asha.k@example.com'::text)
  Index Searches: 1
  Buffers: shared hit=3
Planning Time: 0.112 ms
Execution Time: 0.035 ms"""

BITMAP = """Bitmap Heap Scan on notices_notice  (cost=12.30..64.15 rows=96 width=44)
  Recheck Cond: ((category)::text = 'water'::text)
  ->  Bitmap Index Scan on notice_category_idx  (cost=0.00..12.28 rows=96 width=0)
        Index Cond: ((category)::text = 'water'::text)"""


def test_seq_scan_node_and_table():
    """the first line names the scan and the table"""
    found = read_plan(SEQ)
    assert (found['node'], found['table']) == ('Seq Scan', 'notices_resident')


def test_seq_scan_uses_no_index():
    """a sequential scan reads the table itself"""
    assert read_plan(SEQ)['index'] is None


def test_estimate_without_a_run():
    """explain() alone: an estimate, no actual rows"""
    found = read_plan(SEQ)
    assert (found['estimated'], found['actual']) == (25, None)


def test_actual_rows_and_removed_rows():
    """explain(analyze=True): 25 expected, 1 found, 4,999 read and thrown away"""
    found = read_plan(SEQ_RUN)
    assert (found['estimated'], found['actual'], found['removed']) == (25, 1.0, 4999)


def test_index_scan_names_its_index():
    """'using' names the index, 'on' the table"""
    found = read_plan(INDEX_RUN)
    assert (found['node'], found['index'], found['table']) == ('Index Scan', 'one_resident_per_email', 'notices_resident')


def test_index_scan_removes_nothing():
    """the index answered the condition, so no row was thrown away"""
    found = read_plan(INDEX_RUN)
    assert (found['actual'], found['removed']) == (1.0, 0)


def test_bitmap_heap_scan_takes_the_index_from_the_line_under_it():
    """the Bitmap Index Scan under it names the index"""
    found = read_plan(BITMAP)
    assert (found['node'], found['table'], found['index']) == ('Bitmap Heap Scan', 'notices_notice', 'notice_category_idx')


def test_bitmap_estimate_is_the_first_lines():
    """the estimate of the scan that returns the rows is on the first line"""
    assert read_plan(BITMAP)['estimated'] == 96
A hint

The first line holds the node, the index after using, the table after on, and two pairs of brackets: the estimate first, then what the run measured. Read the brackets with one regular expression each, and look for the Rows Removed by Filter: and Bitmap Index Scan on lines among the others.

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 check yourself on the six ideas, from QuerySet methods to custom lookups; the first questions come from the idea you know least so far:

Practice that adapts to you

Practise this lesson

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

  1. Question 1 of 17 NoticeQuerySet has active(), for_flat() and _tower_filter(). Which of them does from_queryset() copy to the manager?

    Choose one answer.

    Show the answer: Question 1 of 17

    Answer: active() and for_flat(), but not _tower_filter()

    Django's documentation lists the rules: public methods are copied, private ones are not, and queryset_only True or False on a method overrides that.

  2. Question 2 of 17 Does Notice.objects.for_flat(flat).active(on=DAY) return the same notices as Notice.objects.active(on=DAY).for_flat(flat)?

    Choose one answer.

    Show the answer: Question 2 of 17

    Answer: Yes; both add their conditions to one WHERE with AND, and AND does not depend on order

    Methods that only filter can be chained in any order; a method that slices or annotates may change what later ones see.

  3. Question 3 of 17 The board also needs notices_due_to_end(days): published notices whose ends_on falls within the next days. Where does it belong?

    Choose one answer.

    Show the answer: Question 3 of 17

    Answer: On NoticeQuerySet, returning self.filter(…), so it chains with for_flat() and visible_to()

    A method that answers which rows belongs on the QuerySet; a method that creates or acts without a QuerySet belongs on the manager.

  4. Question 4 of 17 Which manager of a model is its default manager?

    Choose one answer.

    Show the answer: Question 4 of 17

    Answer: The one Meta.default_manager_name names, otherwise the first one declared in the class

    dumpdata, the admin and other generic code use the default manager, so it should return every row.

  5. Question 5 of 17 The public pages must not show draft circulars, and backups must still contain them. Which change fits?

    Choose one answer.

    Show the answer: Question 5 of 17

    Answer: Declare objects = models.Manager() first and the filtered manager after it, under its own name

    Meta.default_manager_name is the other way to make a plain manager the default when the filtered one must be declared first.

  6. Question 6 of 17 A project's backups have been made with dumpdata, and a model's first manager hides archived rows. What did those backups contain?

    Choose one answer.

    Show the answer: Question 6 of 17

    Answer: Only the rows that manager returns; the archived rows are in none of them

    Check a backup by loading it into an empty database and counting rows; for this model, run dumpdata --all or fix the default manager.

  7. Question 7 of 17 What must the SELECT of a Manager.raw() query always include?

    Choose one answer.

    Show the answer: Question 7 of 17

    Answer: The primary key

    Columns are matched to fields by name, extra columns become attributes, and the primary key is the one column that cannot be left out.

  8. Question 8 of 17 A raw() query selects only id and name of 50 residents, and the template then shows each resident's email. How many queries does the page send for them?

    Type a number.

    Show the answer: Question 8 of 17

    Answer: 51 queries

    1 for the raw query and 1 for each deferred email, as reading email cost 2 more queries for 2 residents in safe_raw.py. Select the columns the page shows.

  9. Question 9 of 17 A report builds its SQL with an f-string, but only from values chosen in a drop-down of tower names. Is that safe?

    Choose one answer.

    Show the answer: Question 9 of 17

    Answer: No; a request can send any value, not only the drop-down's, so pass the tower as a parameter

    Parameters cost nothing and leave no case to reason about; a table or column name, which cannot be a parameter, is chosen from an allowlist in code instead.

  10. Question 10 of 17 What does the allowlist of query_string_filters.py contain?

    Choose one answer.

    Show the answer: Question 10 of 17

    Answer: Each query-string key the page offers, with the lookup it may become

    Naming what is allowed, rather than what is not, is what keeps a new field or a new kind of lookup from being reachable by accident.

  11. Question 11 of 17 The board adds a filter, until, for notices that start by a given day. A visitor sends starts_on__lte with the same day instead. What should happen?

    Choose one answer.

    Show the answer: Question 11 of 17

    Answer: Add until mapped to starts_on__lte to the allowlist; the key starts_on__lte itself is still refused with a 400

    Keys are matched whole, as safe_filters() does: category__in is not category, and starts_on__lte is not until.

  12. Question 12 of 17 In cost=0.00..137.00, what are the two numbers?

    Choose one answer.

    Show the answer: Question 12 of 17

    Answer: The estimated cost before the first row and for all rows, in the planner's units, by convention disk page fetches

    The total is computed from pages and rows with settings such as seq_page_cost, 1.0, and cpu_tuple_cost, 0.01, which is how 62 + 50 + 25 makes 137.

  13. Question 13 of 17 A plan says rows=25 and, with analyze=True, actual rows=1.00. What does PostgreSQL's documentation say to look at first?

    Choose one answer.

    Show the answer: Question 13 of 17

    Answer: Whether the estimated row counts are reasonably close to the actual ones

    Large gaps send you to the statistics the planner uses and to the form of the condition, as for iexact here.

  14. Question 14 of 17 A slow report calls a database function that also writes an audit row. You want explain(analyze=True) of it on production. What do you do?

    Choose one answer.

    Show the answer: Question 14 of 17

    Answer: Run it on a copy of the data, because ANALYZE runs the query and the function writes its row

    For a plan without running anything, plain explain() is safe; for the actual rows and pages, run it where its side effects do not matter, or roll the transaction back.

  15. Question 15 of 17 What SQL did email__ci='Asha.K@example.COM' send?

    Choose one answer.

    Show the answer: Question 15 of 17

    Answer: WHERE LOWER(resident.email) = LOWER('Asha.K@example.COM')

    A lookup's as_sql() returns the SQL and its params; process_lhs() gave the column and process_rhs() the placeholder for the value.

  16. Question 16 of 17 The ci lookup is defined in notices/lookups.py. What makes sure it is registered before the first query uses it?

    Choose one answer.

    Show the answer: Question 16 of 17

    Answer: Importing the module in NoticesConfig.ready(), where @CharField.register_lookup runs

    register_lookup on CharField also makes it work for EmailField and other subclasses, which is why email__ci worked.

  17. Question 17 of 17 A lookup written for Django 5.2 returns lhs_params + rhs_params from as_sql(). What can go wrong on Django 6.0 and later, and how is it written?

    Choose one answer.

    Show the answer: Question 17 of 17

    Answer: One side may be a list and the other a tuple, so + fails; return (*lhs_params, *rhs_params), a tuple

    Adding a list to a tuple raises TypeError in Python, which is why unpacking into a new tuple is the robust form.

Interview questions

Warm-up (fresher): What is the difference between a custom Manager and a custom QuerySet?

A custom QuerySet holds query methods that return QuerySets, such as active() or for_flat(flat), so they chain in any order and also work after filter(). A manager is the object on the model class, Notice.objects, where every query starts. Manager.from_queryset(NoticeQuerySet)() or NoticeQuerySet.as_manager() gives the manager the QuerySet’s public methods, so Notice.objects.active() works too. A method that needs no QuerySet, such as one that creates a notice, belongs on the manager. The first manager a model declares is its default, which dumpdata and the admin read, so it should not filter rows out.

This lesson leads into two design questions of the module: a search page that filters on whatever the query string says, and a login lookup that ignores its index.

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

  1. Mid to senior

    A notice search view runs Notice.objects.filter(**request.GET.dict()). The team says it is safe now that Django has fixed the _connector injection. Review the view: what can a visitor still do with it, how would you rewrite it, and how would you test the rewrite?

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

  2. Mid to senior

    Residents log in with their email typed in any case. On 2 million residents the lookup, email__iexact, takes about 400 ms, although a unique index on LOWER(email) exists. Explain why the index is not used, fix the lookup, and show that the fix worked.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A custom QuerySet’s methods return QuerySets, so they chain; from_queryset() or as_manager() puts them on the model’s manager, and the whole chain is one query.
  • The first manager a model declares is its default, which dumpdata and the admin read, so a filtered first manager hid a draft from both; related objects are fetched through the base manager.
  • raw() returns model instances and cursor.execute() plain rows, and both take values as parameters with a bare %s; a value formatted into the string broke on D’Souza, and that gap is SQL injection.
  • filter(**request.GET.dict()) lets the visitor choose fields, relations and lookups; Django 5.2.8 and later refuse _connector, and only an allowlist refuses every key the page does not offer.
  • explain() prints the plan with its estimated costs and rows, and explain(analyze=True) runs the query and adds the rows found, the pages read and the times.
  • A custom ci lookup compared LOWER(email), the expression of the unique index, so PostgreSQL searched the index and read 3 pages instead of 62.

Terms in this lesson

allowlist
A list of the only things accepted, such as the query-string keys a page offers and the lookup each one becomes; anything not on the list is refused.
custom lookup
A Lookup subclass registered on a field class and used in filters as field__name. Its as_sql() returns the SQL comparison and its params, such as LOWER(email) = LOWER(%s) for email__ci.
default manager
The manager Django's own tools read a model through: the one Meta.default_manager_name names, otherwise the first manager the model declares. dumpdata and the admin use it, so it should not leave rows out.
query plan
The steps a database chooses for running a query, printed by EXPLAIN as a tree of nodes: the scans, the indexes they use, and estimated costs and rows; with ANALYZE, also what the run found and how long it took.
SQL injection
A flaw in which a value from outside, such as a name typed into a form, ends up in the text of an SQL statement and can change what the statement does. Passing every value as a parameter prevents it.

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.