Django Module 2 – URLs, views, templates and static files
Templates: variables, tags, filters and inheritance
Render Django templates with variables, filters and tags, build every page on one base layout, keep auto-escaping on and keep filters in pagination links.
What you will learn
- Render context data with variables, filters and tags, and predict what a dotted variable finds
- Build pages on one base layout with extends, block and include
- Explain auto-escaping, and why the safe filter on text a user typed opens a hole
- Write pagination links with the querystring tag, and find which file a template name loads
Before you start
What you’ll learn · Before you start
What you will learn
- Render context data with variables, filters and tags, and predict what a dotted variable finds
- Build pages on one base layout with extends, block and include
- Explain auto-escaping, and why the safe filter on text a user typed opens a hole
- Write pagination links with the querystring tag, and find which file a template name loads
Before you start
On this page
A resident types <b>Asha</b> says the lift is fixed into a notice, and the list page has to show exactly those 34
characters, angle brackets and all, without turning them into bold text or anything worse. Django’s template language
does that by default, for every value, and it builds the rest of the page around them: one layout for every page,
loops over the notices, and links that keep the filters of the page.
This lesson builds the notices list of Nivas as templates. You will render values with variables, filters and tags,
put every page on one base layout with {% extends %}, {% block %} and {% include %}, see auto-escaping hold and
the safe filter break it, write pagination links with {% querystring %}, and see which file a template name
loads.
A variable looks its name up, one dot at a time
A view passes a template its context: In Django templates, the names a view passes to a template, such as {'notices': notices}, together with the names that context processors add to every template rendered with a request, such as request., a dictionary such as
{'notices': notices}, and {{ notices }} in the template prints a value from it. A dot goes one step further:
{{ notice.title }} looks for title on notice. Django does not know in advance what notice is, so for each dot it
tries three lookups, in a fixed order, and calls what it finds if it is a method. Step through five variables:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Split at the dots. Django reads {{ counts.items }} as the name counts, then one lookup, items. For each dot it tries the same lookups, in a fixed order.
- A dictionary comes first. counts is a dictionary with a key named items, so counts['items'] answers at the first try, 'the key named items', and the dictionary's own items() method is never reached.
- Then an attribute or a method. For {{ notice.title }}, notice is not a dictionary, so the key lookup fails and Django tries the attribute: notice.title is 'Water tank cleaning on Sunday'.
- Then a list index. For {{ notices.0 }}, a list has no key or attribute named 0, so Django tries the index, notices[0], the first notice.
- Methods get no arguments. For {{ notice.mark_read }}, the attribute is a method. Django calls it with no arguments; mark_read needs one, so the call fails and the variable renders as ''.
- Nothing found renders as empty. For {{ resident.name }}, no name resident is in the context at all. Django raises no error: the variable renders as the engine's string_if_invalid, the empty string by default.
The same variables, and a few more, rendered by Django:
"""What a template variable finds. A dot tries a dictionary key, then an attribute or a method, then a list index;
anything it cannot find renders as an empty string."""
import warnings
import django
from django.conf import settings
settings.configure(TEMPLATES=[{'BACKEND': 'django.template.backends.django.DjangoTemplates'}])
django.setup()
from django.template import Context, Template # noqa: E402
class Notice:
def __init__(self, title, replies):
self.title = title
self.replies = replies
def summary(self):
return self.title.split(' on ')[0]
def mark_read(self, resident):
return f'read by {resident}'
notice = Notice('Water tank cleaning on Sunday', ['Thanks', 'Noted'])
context = Context({
'notice': notice,
'notices': [notice],
'counts': {'title': 'a dictionary value', 'items': 'the key named items'},
})
for source in ['{{ notice.title }}', '{{ counts.title }}', '{{ notices.0.title }}', '{{ notice.replies.1 }}',
'{{ counts.items }}', '{{ notice.summary }}', '{{ notice.mark_read }}', '{{ notice.author }}',
'{{ resident.name }}']:
print(f'{source:24} -> {Template(source).render(context)!r}')
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter('always')
Template('{{ notice..title }}')
print(f'{{{{ notice..title }}}} -> {caught[0].category.__name__}:')
print(' ', str(caught[0].message).splitlines()[0]) Output
{{ notice.title }} -> 'Water tank cleaning on Sunday'
{{ counts.title }} -> 'a dictionary value'
{{ notices.0.title }} -> 'Water tank cleaning on Sunday'
{{ notice.replies.1 }} -> 'Noted'
{{ counts.items }} -> 'the key named items'
{{ notice.summary }} -> 'Water tank cleaning'
{{ notice.mark_read }} -> ''
{{ notice.author }} -> ''
{{ resident.name }} -> ''
{{ notice..title }} -> RemovedInDjango2028Warning:
Support for double-dot lookups '..' which maps to a lookup of the empty string is deprecated.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lookups.py
Read the run against the figure. counts.items found the dictionary’s key items and never reached the dictionary’s
items() method, because the key lookup comes first. notices.0.title used the list index, then the attribute.
notice.summary, a method without arguments, was called. notice.mark_read needs an argument, and a template cannot
pass one, so it rendered as an empty string, as did notice.author and resident.name, which do not exist: a name
that is not found renders as the engine’s string_if_invalid, the empty string by default, without an error.
Version note
Django 6.1 deprecates double-dot lookups, such as {{ notice..title }}, which look up an empty name. The last lines
of the run show the warning, RemovedInDjango2028Warning, raised when the template is read.
A template can call a method only without arguments, so anything that depends on who is asking, or on a value the template would have to pass, is computed before the template runs:
Calling a method that needs an argument
{{ notice.is_read_by user }} is not valid, and {{ notice.mark_read }} renders as nothing. Do work that needs an
argument in the view, and pass the result in the context, for example a list of the notices this resident has not
read.
Filters change a value, and tags add the logic
A filter: In Django templates, a function applied to a value with a pipe, such as {{ notices|length }} or {{ flat|default:"none" }}. Filters can be chained, and each takes at most one argument. changes a value on its way to the page: {{ notices|length }} prints the
number of notices, and filters chain, one after the other, left to right. A template tag: In Django templates, an instruction inside {% %}, such as for, if, url, include or extends, that runs logic, loads another template or prints something a variable alone cannot.
in {% %} does what a value cannot: loop, branch, build a URL or load another template. This program renders the
filters and tags the notices pages use:
"""Filters change a value; tags run logic. The same notices, through the filters and tags this lesson uses."""
import django
from django.conf import settings
settings.configure(TEMPLATES=[{'BACKEND': 'django.template.backends.django.DjangoTemplates'}])
django.setup()
from django.template import Context, Template # noqa: E402
context = Context({
'notices': ['Water tank cleaning on Sunday, 7 to 11 in the morning', 'Lift in block B under repair'],
'none_yet': [],
'flat': '',
'body': 'Tank cleaning.\nWater off in block A.',
})
for source in [
'{{ notices|length }} notice{{ notices|length|pluralize }}',
'{{ none_yet|length }} notice{{ none_yet|length|pluralize }}',
'{{ notices.0|truncatewords:4 }}',
'{{ flat|default:"no flat given" }}',
'{{ notices.1|upper }}',
'{{ body|linebreaksbr }}',
'{% for n in notices %}{{ forloop.counter }}/{{ forloop.length }} {% endfor %}',
'{% for n in none_yet %}{{ n }}{% empty %}No notices yet.{% endfor %}',
'{% if notices|length > 1 %}several{% else %}one or none{% endif %}',
'{# a comment renders as nothing #}done',
]:
print(source)
print(' ->', repr(Template(source).render(context))) Output
{{ notices|length }} notice{{ notices|length|pluralize }}
-> '2 notices'
{{ none_yet|length }} notice{{ none_yet|length|pluralize }}
-> '0 notices'
{{ notices.0|truncatewords:4 }}
-> 'Water tank cleaning on …'
{{ flat|default:"no flat given" }}
-> 'no flat given'
{{ notices.1|upper }}
-> 'LIFT IN BLOCK B UNDER REPAIR'
{{ body|linebreaksbr }}
-> 'Tank cleaning.<br>Water off in block A.'
{% for n in notices %}{{ forloop.counter }}/{{ forloop.length }} {% endfor %}
-> '1/2 2/2 '
{% for n in none_yet %}{{ n }}{% empty %}No notices yet.{% endfor %}
-> 'No notices yet.'
{% if notices|length > 1 %}several{% else %}one or none{% endif %}
-> 'several'
{# a comment renders as nothing #}done
-> 'done'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 filters_and_tags.py
The filters each do one small job: length counts, pluralize adds an s for every count but 1, so 0 is
“0 notices”; truncatewords:4 keeps four words and adds an ellipsis; default replaces an empty value;
linebreaksbr turns line breaks into <br>. The for tag repeats its block for each item and gives it forloop:
forloop.counter counts from 1, and forloop.length, new in Django 6.0, is the size of the list, which printed
1/2 2/2. Its {% empty %} clause covers a list with nothing in it. {% if %} takes comparisons and filters, and
{# … #} is a comment that renders as nothing.
Keep the logic in the view and the formatting in the template: a template can loop, compare and format, but it cannot query the database for one resident or call a method with an argument. In the 2026 Django Developers Survey, 80 % of respondents used Django’s own template language and 17 % Jinja2; this track uses Django’s.
Key idea
A variable prints a value from the context, a filter changes it on the way out, and a tag runs logic; work that needs arguments or queries stays in the view.
Checkpoint
Check: Variables and dot lookups · Filters and tags
Auto-escaping shows what a resident typed as text
Every value a template prints goes through auto-escaping: Django's default of replacing <, >, &, ' and " in the value of every template variable with HTML entities, so that what a user typed is shown as text and never runs as markup. first: Django replaces the five characters that can change the meaning of HTML.
| Character | Becomes |
|---|---|
< |
< |
> |
> |
' |
' |
" |
" |
& |
& |
A browser shows <b> as the text <b>, so a title a resident typed appears exactly as typed and never becomes
markup. This program renders a title and a link that a resident typed into a notice, with escaping and without it:
"""Auto-escaping: what a resident types is shown as text, never run as markup, unless a template marks it safe."""
import django
from django.conf import settings
settings.configure(TEMPLATES=[{'BACKEND': 'django.template.backends.django.DjangoTemplates'}])
django.setup()
from django.template import Context, Template # noqa: E402
from django.utils.safestring import mark_safe # noqa: E402
title = '<b>Asha</b> says "the lift is fixed" & thanks'
link = '<a href="https://pay.example/">Pay your dues here</a>' # typed into a notice by someone who is not staff
context = Context({'title': title, 'link': link, 'marked': mark_safe(link), 'flats': {'B-1203': '</script><b>'}})
for source in ['{{ title }}', '{{ title|safe }}', '{{ link }}', '{{ link|safe }}', '{{ marked }}',
'{% autoescape off %}{{ link }}{% endautoescape %}', '{{ missing|default:"<i>none</i>" }}',
'{{ flats|json_script:"flats-data" }}']:
html = Template(source).render(context)
live = '<a href' in html
print(source)
print(f' -> {html}')
if source.startswith(('{{ link', '{{ marked', '{% autoescape')):
print(f' the typed link is {"a live link: a hole" if live else "shown as text"}') Output
{{ title }}
-> <b>Asha</b> says "the lift is fixed" & thanks
{{ title|safe }}
-> <b>Asha</b> says "the lift is fixed" & thanks
{{ link }}
-> <a href="https://pay.example/">Pay your dues here</a>
the typed link is shown as text
{{ link|safe }}
-> <a href="https://pay.example/">Pay your dues here</a>
the typed link is a live link: a hole
{{ marked }}
-> <a href="https://pay.example/">Pay your dues here</a>
the typed link is a live link: a hole
{% autoescape off %}{{ link }}{% endautoescape %}
-> <a href="https://pay.example/">Pay your dues here</a>
the typed link is a live link: a hole
{{ missing|default:"<i>none</i>" }}
-> <i>none</i>
{{ flats|json_script:"flats-data" }}
-> <script id="flats-data" type="application/json">{"B-1203": "\u003C/script\u003E\u003Cb\u003E"}</script>
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 escaping.py
With escaping, the typed link stayed text. With |safe, mark_safe() or {% autoescape off %}, the same text
became a live link to a site Nivas does not control, shown to every resident who opens the notice. A script would have
run the same way. That is cross-site scripting. OWASP’s Cross Site Scripting Prevention Cheat Sheet describes output
encoding, which auto-escaping is, as turning untrusted input into a form that is shown as data, and asks for HTML
sanitisation where users must write HTML. Two more lines of the run matter. A string written in
the template itself, such as the default "<i>none</i>", is not escaped, because the template’s author wrote it.
And json_script turned a dictionary into JSON inside a <script type="application/json"> element, writing < as
\u003C, so that no value can end the element early.
safe is for markup your own code built
{{ notice.body|safe }} lets anyone who can write a notice put links and scripts into every resident’s page.
Leave text that users typed escaped; to allow formatting, store plain text and render it with a filter such as
linebreaksbr, or with a sanitiser that keeps a fixed list of tags. Pass data to JavaScript with json_script.
Checkpoint
Check: Auto-escaping and safe
One base template gives every page its layout
Every page of Nivas has the same <head>, header and <main>. template inheritance: Building pages from a base template whose block tags a child template replaces, the child starting with {% extends %}, so that the layout shared by every page is written once. writes them once: templates/base.html holds the layout with named, empty {% block %} tags, and
each page’s template starts with {% extends "base.html" %} and fills those blocks. A fragment used in several places,
such as one row of the list, goes in a template of its own that {% include %} renders:
templates/base.html · nivas/templates/base.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}Nivas{% endblock %}</title>
</head>
<body>
<header><a href="{% url 'notices:list' %}">Nivas</a></header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html> nivas/notices/templates/notices/notice_list.html
{% extends "base.html" %}
{% block title %}Notices · {{ block.super }}{% endblock %}
{% block content %}
<h1>Notices</h1>
<ul>
{% for notice in page_obj %}
{% include "notices/notice_row.html" %}
{% empty %}
<li>No notices match these filters.</li>
{% endfor %}
</ul>
<nav aria-label="Pages">
{% if page_obj.has_previous %}<a href="{% querystring page=page_obj.previous_page_number %}">Newer</a>{% endif %}
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
{% if page_obj.has_next %}<a href="{% querystring page=page_obj.next_page_number %}">Older</a>{% endif %}
</nav>
{% endblock %} nivas/notices/templates/notices/notice_row.html
<li>{{ forloop.counter }} of {{ forloop.length }}: {{ notice.title }} <small>{{ notice.category|upper }}</small></li> Step through how the three files make one page:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The view's template. The view renders notices/notice_list.html. Its first tag is {% extends "base.html" %}, so Django loads the parent template, and the child only fills in its blocks.
- The parent holds the page. base.html has the html, head, header and main elements of every page of Nivas, and two blocks, title and content, with default text for the child to replace.
- A block replaced, with super. The child's title block is 'Notices · {{ block.super }}'. block.super inserts the parent's own text, Nivas, so the page's title is Notices · Nivas.
- The content block. The child's content block fills main: the heading, the list and the pagination links. Text in a child template outside every block is never rendered.
- A fragment for each notice. Inside the for loop, {% include "notices/notice_row.html" %} renders the row template with the loop's own context, so forloop.counter and forloop.length work there too.
This program requests the list through the test client with five notices in a test database, two of them per page, and prints the HTML each request got:
"""Render the notices list of Nivas the way a browser would get it, through the test client, and print the HTML.
Run it next to manage.py. It makes a test database in memory with five notices, one of them with markup in its title.
"""
import logging
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django # noqa: E402
django.setup()
from django.test import Client # noqa: E402
from django.test.utils import setup_databases, setup_test_environment # noqa: E402
setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
logging.getLogger('django.request').setLevel(logging.CRITICAL)
from notices.models import Notice # noqa: E402
for pk, title, category in [(7, 'Water tank cleaning on Sunday', 'water'),
(8, 'Lift in block B under repair', 'lift'),
(9, 'Visitors park in P2 this week', 'parking'),
(10, '<b>Asha</b> says the lift is fixed', 'lift'),
(11, 'Water off from 2 to 4 pm', 'water')]:
Notice.objects.create(pk=pk, title=title, category=category)
for url in ['/notices/?category=water&category=lift', '/notices/?category=water&category=lift&page=2',
'/notices/?category=security']:
response = Client().get(url)
print(f'GET {url} -> {response.status_code}')
wrapper = {'<!doctype html>', '<html lang="en">', '<head>', '<meta charset="utf-8">', '</head>', '<body>', '<main>',
'</main>', '</body>', '</html>'}
for line in response.text.splitlines():
if line.strip() and line.strip() not in wrapper:
print(' ' + line.strip()) Output
GET /notices/?category=water&category=lift -> 200
<title>Notices · Nivas</title>
<header><a href="/notices/">Nivas</a></header>
<h1>Notices</h1>
<ul>
<li>1 of 2: Water tank cleaning on Sunday <small>WATER</small></li>
<li>2 of 2: Lift in block B under repair <small>LIFT</small></li>
</ul>
<nav aria-label="Pages">
Page 1 of 2
<a href="?category=water&category=lift&page=2">Older</a>
</nav>
GET /notices/?category=water&category=lift&page=2 -> 200
<title>Notices · Nivas</title>
<header><a href="/notices/">Nivas</a></header>
<h1>Notices</h1>
<ul>
<li>1 of 2: <b>Asha</b> says the lift is fixed <small>LIFT</small></li>
<li>2 of 2: Water off from 2 to 4 pm <small>WATER</small></li>
</ul>
<nav aria-label="Pages">
<a href="?category=water&category=lift&page=1">Newer</a>
Page 2 of 2
</nav>
GET /notices/?category=security -> 200
<title>Notices · Nivas</title>
<header><a href="/notices/">Nivas</a></header>
<h1>Notices</h1>
<ul>
<li>No notices match these filters.</li>
</ul>
<nav aria-label="Pages">
Page 1 of 1
</nav>
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 render_list.py
The title is Notices · Nivas: the child’s title block replaced the parent’s and {{ block.super }} put the parent’s
text back inside it. Each row shows 1 of 2 and 2 of 2, because the included template sees the loop’s forloop.
The title a resident typed, <b>Asha</b> …, arrived as <b>Asha</b>, and the filter for security
notices, which matched none, showed the {% empty %} text. The header’s link came from {% url 'notices:list' %},
the tag of the routing lesson.
More blocks in the base template
Django’s documentation advises more {% block %} tags in base templates rather than fewer: an empty block costs
nothing, and a child that needs to add a script or a stylesheet then has a place for it. Text in a child template
outside every block is never rendered.
The querystring tag changes one parameter and keeps the rest
The pagination links of a filtered list must keep the filters: on /notices/?category=water&category=lift, the link to
page 2 has to keep both categories. {% querystring %}, added in Django 5.1, builds such links from the query string
of the current request, changing only the parameters you name:
"""{% querystring %} builds a link from the query string of the page it is on, changing only what it is told to."""
import django
from django.conf import settings
settings.configure(TEMPLATES=[{'BACKEND': 'django.template.backends.django.DjangoTemplates'}])
django.setup()
from django.template import Context, Template # noqa: E402
from django.test import RequestFactory # noqa: E402
request = RequestFactory().get('/notices/', {'category': ['water', 'lift'], 'page': '2'})
print('The page: /notices/?category=water&category=lift&page=2')
for tag in ['{% querystring page=3 %}', '{% querystring page=None %}', '{% querystring category="lift" %}',
'{% querystring category=None page=None %}', '{% querystring sort="new" %}',
'{% querystring category=chosen %}']:
context = Context({'chosen': ['security', 'parking']})
context.request = request # render(request, ...) gives the template its request; the tag reads request.GET from it
print(f'{tag:45} -> {Template(tag).render(context)}') Output
The page: /notices/?category=water&category=lift&page=2
{% querystring page=3 %} -> ?category=water&category=lift&page=3
{% querystring page=None %} -> ?category=water&category=lift
{% querystring category="lift" %} -> ?category=lift&page=2
{% querystring category=None page=None %} -> ?
{% querystring sort="new" %} -> ?category=water&category=lift&page=2&sort=new
{% querystring category=chosen %} -> ?category=security&category=parking&page=2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 querystring_tag.py
page=3 replaced the page and kept both categories; page=None removed the page; a list, category=chosen, replaced
both categories with the two values in it. Since Django 6.0 the result always starts with ?, even when nothing is
left, as category=None page=None shows, so it can go straight into an href. In the HTML source each & appears
as &, the escaped form a browser reads as &. The notices list uses
{% querystring page=page_obj.next_page_number %}, and the rendered page above shows its links keeping the filter. The
tag starts from request.GET of the request the template is rendered with; Django’s documentation asks for the
request context processor for that, which a new project’s settings already list.
Checkpoint
Check: extends, block and include · Links with the querystring tag
Django searches the template folders in a fixed order
render(request, 'notices/notice_list.html', …) names a template, not a file. The engine searches for it: first the
folders in DIRS, here the project’s templates/, then, with APP_DIRS on, the templates/ folder of each app in
the order of INSTALLED_APPS. Step through the search for a name that exists and for one that does not:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first time. A view renders notices/notice_list.html. The engine of a new project wraps its loaders in the cached loader, which has not seen this name yet.
- The project's folder first. DIRS lists the project's own templates/ folder, which Nivas uses for base.html. There is no notices/notice_list.html in it.
- Then each app, in order. With APP_DIRS on, Django tries the templates/ folder of each installed app in the order of INSTALLED_APPS: the admin's, then auth's. Neither has the file.
- Found in the app. notices/templates/notices/notice_list.html exists, so the search stops here, and the cached loader remembers it for the next request.
- Why the inner notices folder. Every app's templates/ folder is searched as one, so a file named list.html in two apps would clash, and the earlier app in INSTALLED_APPS would win. The inner notices/ folder gives each name its app.
- A name nobody has. For notices/missing.html every folder says "Source does not exist", and get_template() raises TemplateDoesNotExist with the list of folders it tried.
The program asks for four names and prints where each was found, relative to the project, or which folders it tried:
"""Which file Django renders for a template name, and what it says when no folder has one.
Run it next to manage.py. DIRS (the project's templates/ folder) is searched first, then each installed app's
templates/ folder, in the order of INSTALLED_APPS.
"""
import os
from pathlib import Path
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django # noqa: E402
django.setup()
from django.conf import settings # noqa: E402
from django.template import TemplateDoesNotExist # noqa: E402
from django.template.loader import get_template # noqa: E402
def where(name):
"""A path relative to the project folder, or to the installed package for Django's own templates."""
path = Path(name)
if path.is_relative_to(settings.BASE_DIR):
return str(path.relative_to(settings.BASE_DIR))
return 'django/' + str(path.relative_to(Path(django.__file__).parent))
for name in ['base.html', 'notices/notice_list.html', 'admin/base.html', 'notices/missing.html']:
try:
template = get_template(name)
except TemplateDoesNotExist as error:
print(f'{name:26} -> TemplateDoesNotExist; tried:')
for origin, status in error.chain[0].tried: # the Django template engine's own list
print(f' {where(origin.name)}: {status}')
continue
print(f'{name:26} -> {where(template.origin.name)}')
loaders = settings.TEMPLATES[0].get('OPTIONS', {}).get('loaders')
engine_loaders = get_template('base.html').template.engine.loaders
print('loaders in OPTIONS:', loaders, '-> the engine uses', engine_loaders[0][0]) Output
base.html -> templates/base.html
notices/notice_list.html -> notices/templates/notices/notice_list.html
admin/base.html -> django/contrib/admin/templates/admin/base.html
notices/missing.html -> TemplateDoesNotExist; tried:
templates/notices/missing.html: Source does not exist
django/contrib/admin/templates/notices/missing.html: Source does not exist
django/contrib/auth/templates/notices/missing.html: Source does not exist
notices/templates/notices/missing.html: Source does not exist
loaders in OPTIONS: None -> the engine uses django.template.loaders.cached.Loader
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 find_templates.py
base.html came from the project’s folder and notices/notice_list.html from the notices app; admin/base.html
from Django’s admin, which a file of the same name in the project’s templates/admin/ would override, because DIRS
comes first. For notices/missing.html the error lists every folder tried. The last line shows the cached loader:
it keeps each compiled template in memory for the next request, and since Django 4.1 it wraps the other loaders in
development too, unless the OPTIONS of TEMPLATES set loaders.
The same setting adds names to every page. Its context processor: A function listed in the TEMPLATES setting that adds names to the context of every template rendered with a request, such as request and user, which the default settings of a new project include. run for every
template rendered with a request, and the three in a new project’s settings add request, user and perms from
authentication, and messages.
Checkpoint
Check: Where templates are found
Practise: the links of the querystring tag
The tag is easier to trust once you have built it. Write build_querystring(current, **changes), which turns a page’s
query string and a set of changes into the link the tag would print, with the same rules for lists, None and the
order of keys. Its eight sample tests run in your browser:
Exercise · Medium · Python
Build the links of the querystring tag
The pagination links of the notices list keep the filters of the page they are on: on /notices/?category=water&page=2, the link to the next page is ?category=water&page=3. Django's {% querystring page=3 %} builds such links. Write build_querystring(current, **changes), which builds the same query string in plain Python:
currentis the list of(key, value)pairs of the page's query string, in order; a key may appear more than once, as in[('category', 'water'), ('category', 'lift')].- Each keyword argument changes one key. A single value replaces every value of that key; a list sets several values, leaving out any
Nonein it;Noneitself removes the key. Values are turned into strings, sopage=3gives'3'. - A key keeps its place when it was already in
current; a new key goes at the end. - The result is URL-encoded, as
urllib.parse.urlencodedoes it (a space becomes+,&becomes%26), and always starts with?, even when it is nothing but?.
So build_querystring([('category', 'water'), ('page', '2')], page=3) returns '?category=water&page=3', and build_querystring([('category', 'water'), ('page', '2')], category=None) returns '?page=2'. In a template the tag's output is also HTML-escaped, so & appears as & in the page's source; return the string without that step.
Starter code · query.py
from urllib.parse import urlencode
def build_querystring(current, **changes):
"""Return the query string that {% querystring %} would build, starting with '?'."""
pairs = list(current) + list(changes.items())
return urlencode(pairs) The sample tests · test_query.py
from query import build_querystring
def test_a_value_replaces_the_old_one_in_its_place():
assert build_querystring([('category', 'water'), ('page', '2')], page=3) == '?category=water&page=3'
def test_a_new_key_goes_at_the_end():
assert build_querystring([('category', 'water')], page=2) == '?category=water&page=2'
def test_none_removes_a_key():
assert build_querystring([('category', 'water'), ('page', '2')], category=None) == '?page=2'
def test_a_list_repeats_the_key_and_drops_none():
assert build_querystring([], category=['water', 'lift']) == '?category=water&category=lift'
assert build_querystring([], category=['water', None]) == '?category=water'
assert build_querystring([('category', 'water')], category=[None]) == '?'
def test_repeated_keys_of_the_page_are_kept():
current = [('category', 'water'), ('category', 'lift'), ('page', '2')]
assert build_querystring(current, page=3) == '?category=water&category=lift&page=3'
def test_the_result_always_starts_with_a_question_mark():
assert build_querystring([]) == '?'
assert build_querystring([('page', '2')], page=None) == '?'
def test_values_are_url_encoded():
assert build_querystring([], title='lift repair & paint') == '?title=lift+repair+%26+paint'
def test_several_changes_at_once():
current = [('page', '2'), ('category', 'water')]
assert build_querystring(current, page=None, sort='new', category='lift') == '?category=lift&sort=new' A hint
Collect current into a dictionary of lists first: Python's dictionaries keep the order in which keys were added, so replacing a key's list keeps its place, while a key added later goes at the end. Then turn the dictionary back into (key, value) pairs for urlencode.
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.
Sixteen questions on templates follow, from dot lookups to the template search. Each comes with an explanation as soon as you answer, and the order follows your own answers, weakest idea first:
Practice that adapts to you
Practise this lesson
16 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 does auto-escaping do, and how do you switch it off for one value?
Before a template prints a variable’s value, Django replaces the five characters that can change the meaning of HTML:
< with <, > with >, ' with ', " with " and & with &. The browser then shows
what a user typed as text, so it can never become a tag or a script. {{ value|safe }} switches escaping off for
one value, and {% autoescape off %} for a block, but both are only for markup your own code built, never for text a
user typed. String literals written in the template itself are not escaped.
Two design questions of this module start from these templates: rich text in notices without the safe hole, and a
set of base templates for pages with different layouts.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Staff want bold text, lists and links in notices, so a template shows {{ notice.body|safe }}. Any resident can post a notice, and every resident reads them. What is the risk, and what would you ship instead?
-
Fresher to senior
A society app has public pages with a large header, resident pages with a sidebar, and printable bills with no header at all. Design the base templates and their blocks so that no part of a layout is written twice, and say where the sidebar's data comes from.
Key takeaways
- A variable prints a value from the context; for each dot Django tries a dictionary key, then an attribute or a method called without arguments, then a list index, and a name it cannot find renders as an empty string.
- Filters such as
length,pluralizeanddefaultchange a value; tags such asforwithempty,ifandurlrun logic, and work that needs arguments or queries belongs in the view. - Auto-escaping turns
< > ' " &into entities in every value, so typed text stays text;safe,mark_safe()andautoescape offare for markup your own code built, andjson_scriptpasses data to JavaScript safely. - A base template with blocks holds the layout; a child starts with
{% extends %}and fills blocks,block.superkeeps the parent’s text, and{% include %}renders a fragment with the current context. {% querystring %}changes the parameters you name, removes those set toNoneand keeps the rest of the page’s query string, which is what pagination on a filtered list needs.- Django looks for a template in
DIRSfirst, then in each app’stemplates/folder inINSTALLED_APPSorder, so an app keeps its templates in a folder of its own name and a project can override any app’s template.
Terms in this lesson
- auto-escaping
- Django's default of replacing <, >, &, ' and " in the value of every template variable with HTML entities, so that what a user typed is shown as text and never runs as markup.
- context
- In Django templates, the names a view passes to a template, such as {'notices': notices}, together with the names that context processors add to every template rendered with a request, such as request.
- context processor
- A function listed in the TEMPLATES setting that adds names to the context of every template rendered with a request, such as request and user, which the default settings of a new project include.
- filter
- In Django templates, a function applied to a value with a pipe, such as {{ notices|length }} or {{ flat|default:"none" }}. Filters can be chained, and each takes at most one argument.
- template inheritance
- Building pages from a base template whose block tags a child template replaces, the child starting with {% extends %}, so that the layout shared by every page is written once.
- template tag
- In Django templates, an instruction inside {% %}, such as for, if, url, include or extends, that runs logic, loads another template or prints something a variable alone cannot.
References
- Django documentation: Templates (Django Software Foundation)
- The Django template language (Django Software Foundation)
- Built-in template tags and filters (Django Software Foundation)
- The Django template language: for Python programmers (Django Software Foundation)
- Django 6.0 release notes (Django Software Foundation)
- Django 6.1 release notes (Django Software Foundation)
- Django 4.1 release notes (Django Software Foundation)
- Cross Site Scripting Prevention Cheat Sheet (OWASP Foundation)
- Django Developers Survey 2026 results (Django Software Foundation and JetBrains)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress