Your country

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

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

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

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

What you will learn

  • 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:

Interactive · stage by stage What Django tries for each dot of a variable Step through five variables of the recorded run. Each box is one lookup, in the order Django tries them.
What Django tries for each dot of a variable, step 6 of 6. Showing {{ counts.items }} ({{ resident.name }}), Dictionary key (not a dictionary), Attribute or method (no attribute 0), List index (not reached), A callable is called (needs an argument: ''), Nothing found: '' (renders as ''). In focus: Nothing found: ''.found{{ counts.items }}{{ resident.name }}Dictionary keynot a dictionaryAttribute or methodno attribute 0List indexnot reachedA callable is calledneeds an argument: ''Nothing found: ''renders as ''

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

Every step as text
  1. 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.
  2. 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.
  3. 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'.
  4. 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.
  5. 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 ''.
  6. 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 dotted variable finds Python · lookups.py
"""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 and tags on the notices of Nivas Python · filters_and_tags.py
"""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

  1. Question 1 of 2 counts is the dictionary {'title': 'a dictionary value', 'items': 'the key named items'}. What does {{ counts.items }} render?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: the key named items

    For each dot Django tries a dictionary key, then an attribute or a method, then a list index, and stops at the first that works. A key that shadows a method name wins.

  2. Question 2 of 2 Inside {% for notice in notices %} with two notices, what does {{ forloop.counter }} of {{ forloop.length }} print on the second pass?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 2 of 2

    forloop gives each pass its counter (from 1), counter0 (from 0), first, last, revcounter and, since Django 6.0, length. On 5.2 LTS use {{ notices|length }} for the length.

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
< &lt;
> &gt;
' &#x27;
" &quot;
& &amp;

A browser shows &lt;b&gt; 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:

The same text with and without auto-escaping Python · escaping.py
"""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 }}
  -> &lt;b&gt;Asha&lt;/b&gt; says &quot;the lift is fixed&quot; &amp; thanks
{{ title|safe }}
  -> <b>Asha</b> says "the lift is fixed" & thanks
{{ link }}
  -> &lt;a href=&quot;https://pay.example/&quot;&gt;Pay your dues here&lt;/a&gt;
     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

  1. Question 1 of 2 A template shows {{ notice.body|safe }} so that staff can use bold text in notices, but any resident can post a notice. What can a resident do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Put live markup into the page that every resident sees, such as a link to a fake payment site, or a script

    safe and mark_safe() say "this value is trusted markup". Use them only on HTML your own code built, never on something a user typed; text from users stays escaped, or goes through a filter that allows a fixed set of tags.

  2. Question 2 of 2 A page's JavaScript needs the dues of each flat, a Python dict. Which way of passing it is safe?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: {{ dues|json_script:"dues-data" }}, then JSON.parse of that element's text in the script

    json_script is made for this: it emits data, not code, it escapes the three characters that could break out of the element, and it works with a strict Content Security Policy.

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:

The base layout, the notices list and one row

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:

Interactive · step by step Three templates, one page Step through how the notices list is put together, from the template a view renders to the page a browser gets.
Three templates, one page, step 5 of 5. Showing notice_list.html, base.html, notice_row.html, <title> (Notices · Nivas), <main> (h1, ul and nav), <li> for each notice (1 of 2, then 2 of 2). In focus: notice_row.html, <li> for each notice.extendsincludenotice_list.htmlextends "base.html"base.htmlblocks: title, contentnotice_row.htmlincluded for each notice<title>Notices · Nivas<main>h1, ul and nav<li> for each notice1 of 2, then 2 of 2

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

The notices list, rendered Python · nivas/render_list.py
"""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&amp;category=lift&amp;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: &lt;b&gt;Asha&lt;/b&gt; 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&amp;category=lift&amp;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 &lt;b&gt;Asha&lt;/b&gt;, 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:

The querystring tag on a filtered, paged list Python · querystring_tag.py
"""{% 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&amp;category=lift&amp;page=3
{% querystring page=None %}                   -> ?category=water&amp;category=lift
{% querystring category="lift" %}             -> ?category=lift&amp;page=2
{% querystring category=None page=None %}     -> ?
{% querystring sort="new" %}                  -> ?category=water&amp;category=lift&amp;page=2&amp;sort=new
{% querystring category=chosen %}             -> ?category=security&amp;category=parking&amp;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 &amp;, 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

  1. Question 1 of 2 base.html has {% block title %}Nivas{% endblock %}. The child has {% block title %}Notices · {{ block.super }}{% endblock %}. What is the page's title?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Notices · Nivas

    A child template replaces each block it defines and keeps the parent's version of the rest. block.super lets a child add to a block instead of replacing it.

  2. Question 2 of 2 The page is /notices/?category=water&category=lift&page=2. What does {% querystring page=3 %} give in the HTML source?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ?category=water&amp;category=lift&amp;page=3

    querystring starts from request.GET, replaces the keys you pass, removes those you pass as None and keeps the rest, which is what pagination links on a filtered list need.

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:

Interactive · stage by stage Where Django looks for notices/notice_list.html Step through the search for a template name that exists, then for one that does not.
Where Django looks for notices/notice_list.html, step 6 of 6. Showing notices/notice_list.html (notices/missing.html), The cached loader, DIRS: templates/ (no such file), admin/templates/ (no such file), auth/templates/ (no such file), notices/templates/ (no such file).none: TemplateDoesNotExistnotices/notice_list.htmlnotices/missing.htmlThe cached loadera name it has seen beforeDIRS: templates/no such fileadmin/templates/no such fileauth/templates/no such filenotices/templates/no such fileTemplateDoesNotExist

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 a template name loads Python · nivas/find_templates.py
"""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

  1. One question The notices and bookings apps both have templates/list.html, without an inner folder. A notices view renders 'list.html'. Which file is used?

    Choose one answer.

    Show the answer: One question

    Answer: The one in whichever app comes first in INSTALLED_APPS

    All app template folders form one namespace. A folder named after the app inside templates/, as in notices/templates/notices/, keeps each name unique.

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:

  • current is 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 None in it; None itself removes the key. Values are turned into strings, so page=3 gives '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.urlencode does 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 &amp; 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.

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.

HTML Entity Encoder & Decoder Encode text into HTML entities, and back, to see what auto-escaping writes.

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.

  1. Question 1 of 16 No name resident is in the context. What does {{ resident.name }} render with the default settings?

    Choose one answer.

    Show the answer: Question 1 of 16

    Answer: An empty string

    Missing names fail quietly in templates, which keeps a page rendering but can hide a typo. Tests that check the rendered text catch such typos.

  2. Question 2 of 16 Notice.mark_read(self, resident) needs an argument. What does {{ notice.mark_read }} render?

    Choose one answer.

    Show the answer: Question 2 of 16

    Answer: An empty string, because Django calls methods with no arguments and this call fails

    Methods without arguments can be called from a template, such as notice.summary. Anything that needs an argument belongs in the view, or in a custom filter or tag.

  3. Question 3 of 16 On Django 6.1 a template contains {{ book..title }}, with two dots. What happens?

    Choose one answer.

    Show the answer: Question 3 of 16

    Answer: It warns with RemovedInDjango2028Warning, because double-dot lookups are deprecated

    A double dot is usually a typo. Run your tests with python -Wa manage.py test, which shows every deprecation warning, so such templates are fixed before the release that turns the warning into an error.

  4. Question 4 of 16 What does notice{{ count|pluralize }} print when count is 0?

    Choose one answer.

    Show the answer: Question 4 of 16

    Answer: notices

    pluralize returns 's' unless the value is 1 or a list of one item; pluralize:"es" or pluralize:"y,ies" give other endings.

  5. Question 5 of 16 Which template shows No notices yet. when the list is empty, in the fewest tags?

    Choose one answer.

    Show the answer: Question 5 of 16

    Answer: {% for n in notices %}…{% empty %}No notices yet.{% endfor %}

    for … empty is the shorter, documented form for a list that may be empty, and it also covers a name that is missing from the context.

  6. Question 6 of 16 A notice list must show only the notices a resident has not read, which needs the resident and the database. Where does that filtering belong?

    Choose one answer.

    Show the answer: Question 6 of 16

    Answer: In the view, which queries the unread notices and passes that list to the template

    Keep logic that needs arguments, the request or queries in the view. The template receives data that is ready to show, and its tags only loop, branch and format.

  7. Question 7 of 16 Which characters does auto-escaping replace in a variable's value? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 7 of 16

    Answer:

    • a double quote
    • &
    • <

    Django replaces <, >, ', " and &, the five characters that can change the meaning of HTML, in every variable's value unless it is marked safe.

  8. Question 8 of 16 A template has {{ missing|default:"<i>none</i>" }}. Is <i> escaped in the output?

    Choose one answer.

    Show the answer: Question 8 of 16

    Answer: No, because string literals written in the template are inserted without escaping

    Escaping protects you from values, not from your own template. Write a literal as you want it to appear, and escape it yourself if it should show the characters, as in 3 &lt; 2.

  9. Question 9 of 16 An email template wraps everything in {% autoescape off %} because the email is plain text. Then it starts to show residents' names in an HTML version too. What should change?

    Choose one answer.

    Show the answer: Question 9 of 16

    Answer: Give the HTML version its own template with escaping on, and keep autoescape off only in the plain-text one

    Escaping is about the output format. HTML output escapes every variable; a plain-text template may switch it off, but the two should not share one template.

  10. Question 10 of 16 Where must {% extends "base.html" %} stand in a child template?

    Choose one answer.

    Show the answer: Question 10 of 16

    Answer: It must be the first template tag in the file

    A child template starts with extends and is otherwise a set of blocks; anything outside its blocks is not rendered.

  11. Question 11 of 16 A child template that extends base.html has <p>Call the office for help</p> after its last endblock. Where does it appear?

    Choose one answer.

    Show the answer: Question 11 of 16

    Answer: Nowhere; text outside the blocks of a child template is not rendered

    Put everything a child wants on the page inside one of the parent's blocks; if no block fits, add a block to the base template. More blocks in a base template are better, as Django's documentation advises.

  12. Question 12 of 16 notice_row.html uses {{ notice.title }} and {{ forloop.counter }}. Inside a for loop, the list includes it with {% include "notices/notice_row.html" %}. What does the row see?

    Choose one answer.

    Show the answer: Question 12 of 16

    Answer: The current notice and the loop's forloop, because include passes the current context

    include renders another template with the current context; with adds names, and only restricts the context to those names, which makes a fragment's inputs explicit.

  13. Question 13 of 16 On /notices/?category=water&page=2, what does {% querystring page=None %} give?

    Choose one answer.

    Show the answer: Question 13 of 16

    Answer: ?category=water

    Pass None to remove a parameter, for example the page number when a filter changes, so the new filter starts on page 1.

  14. Question 14 of 16 On /notices/?category=water&page=2, a template has {% querystring category=chosen %}, and chosen is ['security', 'parking']. What does it give?

    Choose one answer.

    Show the answer: Question 14 of 16

    Answer: ?category=security&amp;category=parking&amp;page=2

    querystring replaces a key's values with whatever you pass: one value, a list of values, or None to remove it, and keeps every key you do not name.

  15. Question 15 of 16 A new project's settings set no loaders in TEMPLATES OPTIONS. Which loader wraps the others?

    Choose one answer.

    Show the answer: Question 15 of 16

    Answer: The cached loader, in development too

    The cached loader keeps each compiled template in memory and returns it for later requests for the same name, which saves reading and compiling it again. Setting OPTIONS['loaders'] yourself turns that default off.

  16. Question 16 of 16 Nivas has templates/admin/base.html in its project folder, and the admin app has its own admin/base.html. Which one does the admin use?

    Choose one answer.

    Show the answer: Question 16 of 16

    Answer: The project's, because DIRS is searched before the apps' folders

    DIRS comes first, then each app in INSTALLED_APPS order. Put a file with the same name in the project's templates/ folder to override an app's template.

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 &lt;, > with &gt;, ' with &#x27;, " with &quot; and & with &amp;. 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.

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

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

  2. Fresher to 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.

    Model answer, Pro. First step and follow-ups

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, pluralize and default change a value; tags such as for with empty, if and url run 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() and autoescape off are for markup your own code built, and json_script passes data to JavaScript safely.
  • A base template with blocks holds the layout; a child starts with {% extends %} and fills blocks, block.super keeps the parent’s text, and {% include %} renders a fragment with the current context.
  • {% querystring %} changes the parameters you name, removes those set to None and keeps the rest of the page’s query string, which is what pagination on a filtered list needs.
  • Django looks for a template in DIRS first, then in each app’s templates/ folder in INSTALLED_APPS order, 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.