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().
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:
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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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
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:
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
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 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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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, ?.
- 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.
- 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
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.:
"""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
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:
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:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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:
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
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.
- Map query-string filters through an allowlist To do
- 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 isunknown 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · Python
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, orNone. ABitmap Heap Scannames no index on its own line: the index is on theBitmap Index Scan on …line under it;estimated: therows=of the estimate, a whole number;actual: therows=of(actual time=… rows=… loops=…)as a float, orNonewhen the plan was not run;removed: the number on theRows Removed by Filter:line, or0when 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then 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.
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.
-
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?
-
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.
Key takeaways
- A custom QuerySet’s methods return QuerySets, so they chain;
from_queryset()oras_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
dumpdataand 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 andcursor.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, andexplain(analyze=True)runs the query and adds the rows found, the pages read and the times.- A custom
cilookup comparedLOWER(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
- Django documentation: Managers (Django Software Foundation)
- Django documentation: Performing raw SQL queries (Django Software Foundation)
- Django documentation: QuerySet API reference, explain() (Django Software Foundation)
- Django documentation: How to write custom lookups (Django Software Foundation)
- Django documentation: django-admin dumpdata (Django Software Foundation)
- Django 5.2.8 release notes (Django Software Foundation)
- Django security releases issued: 5.2.8, 5.1.14, and 4.2.26 (Django Software Foundation)
- Django 6.0 release notes (Django Software Foundation)
- Django documentation: Databases, PostgreSQL notes (Django Software Foundation)
- PostgreSQL 18 documentation: Using EXPLAIN (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: EXPLAIN (The PostgreSQL Global Development Group)
- PostgreSQL 18 release notes (The PostgreSQL Global Development Group)
- Psycopg 3 documentation: Passing parameters to SQL queries (The Psycopg Team)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress