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

Template partials and custom template tags

Reuse a fragment with Django 6.0's partialdef and partial, render it alone by name, and write custom filters, simple tags and inclusion tags safely.

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

What you will learn

  • Define and reuse named fragments with partialdef and partial
  • Render a single fragment by name with 'template.html#name' from a view or an include
  • Write a custom filter, a simple tag and an inclusion tag in a tag library
  • Build HTML in Python with format_html() and conditional_escape() instead of mark_safe()

Before you start

What you’ll learn · Before you start

What you will learn

  • Define and reuse named fragments with partialdef and partial
  • Render a single fragment by name with 'template.html#name' from a view or an include
  • Write a custom filter, a simple tag and an inclusion tag in a tag library
  • Build HTML in Python with format_html() and conditional_escape() instead of mark_safe()

Before you start

On this page

The notice card of Nivas appears in 3 places: in the list, on each notice’s own page, and alone, as the answer to a script that adds one card to a page that is already open. Copy its markup three times and the copies drift apart with the first change; Django 6.0’s template partials give it one definition that all three render.

This lesson defines the card once with {% partialdef %}, renders it in a loop with {% partial %} and alone with 'notice_list.html#notice-card'. Then it writes the template code Nivas needs in Python: a filter that shows paise as rupees, a simple tag and an inclusion tag, all of them escaping what residents typed, and you will see why format_html() keeps that true where mark_safe() does not.

A partial names a fragment once and renders it where you need it

A template partial: A named fragment of a template, defined with {% partialdef name %} … {% endpartialdef %} and rendered with {% partial name %}, or loaded on its own as 'file.html#name'. New in Django 6.0. is a piece of a template with a name. {% partialdef notice-card %} … {% endpartialdef %} defines it, and the definition alone renders nothing. {% partial notice-card %} renders it, as often as you like, each time with the context at that point. Step through the card of Nivas and the three places that render it:

Interactive · step by step One partial, rendered in three places Step through where the notice card is defined and the three responses that render it.
One partial, rendered in three places, step 5 of 5. Showing partialdef notice-card, partial notice-card, include …#notice-card, render(…#notice-card), GET /notices/, GET /notices/9/, GET /notices/9/card/. In focus: partialdef notice-card.partialdef notice-cardin notice_list.htmlpartial notice-cardin the list's for loopinclude …#notice-cardin notice_detail.htmlrender(…#notice-card)in the notice_card viewGET /notices/a card for each noticeGET /notices/9/the card inside a pageGET /notices/9/card/the card and nothing else

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

Every step as text
  1. Defined once. notice_list.html defines the card between partialdef notice-card and endpartialdef: a list item with the title, the resident badge and the fee. The definition alone renders nothing.
  2. Used in the loop. In the list's for loop, {% partial notice-card %} renders the card once for each notice, with the loop's notice in the context. The recorded list shows three cards.
  3. Included by another page. notice_detail.html includes notices/notice_list.html#notice-card. The same card appears in the page of notice 9, with that page's notice.
  4. Rendered alone. The notice_card view renders notices/notice_list.html#notice-card itself. The response is the li element and nothing else, the fragment a script such as htmx swaps into a page.
  5. One edit, three places. A change to the card's markup, between partialdef and endpartialdef, reaches the list, the notice's page and the fragment at once, because none of them has a copy of it.

The list template defines the card at the top and renders it in its loop; the detail page and the card view, in the next section, use the same definition:

The notice card as a partial, and the page that reuses it

nivas/notices/templates/notices/notice_list.html

{% extends "base.html" %}
{% load nivas_extras %}

{% partialdef notice-card %}
  <li id="notice-{{ notice.pk }}">
    <h2>{{ notice.title }}</h2>
    {% resident_badge notice.posted_by notice.flat %}
    {% if notice.fee_paise %}<p>Fee: {{ notice.fee_paise|paise }}</p>{% endif %}
  </li>
{% endpartialdef %}

{% block title %}Notices · {{ block.super }}{% endblock %}

{% block content %}
  <h1>Notices</h1>
  {% fee_summary page_obj %}
  <ul id="notices">
    {% for notice in page_obj %}
      {% partial notice-card %}
    {% empty %}
      <li>No notices match these filters.</li>
    {% endfor %}
  </ul>
{% endblock %}

nivas/notices/templates/notices/notice_detail.html

{% extends "base.html" %}

{% block title %}{{ notice.title }} · {{ block.super }}{% endblock %}

{% block content %}
  <h1>Notice {{ notice.pk }}</h1>
  <ul>
    {% include "notices/notice_list.html#notice-card" %}
  </ul>
  <p>{{ notice.body|linebreaksbr }}</p>
{% endblock %}

Because a partial renders with the context it meets, the card inside the loop sees each pass’s notice, as a fragment written out in the loop would. The definition can sit outside every block, as here: it only names the fragment.

There is one argument to know before you use it: {% partialdef card inline %} defines the partial and renders it where it stands. This program shows what that does outside a loop:

partialdef with and without inline Python · inline_partial.py
"""partialdef … inline renders the fragment where it is defined as well. Outside the loop that means an extra,
empty item."""

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

LOOP = '<ul>{% for notice in notices %}{% partial card %}{% endfor %}</ul>'
DEFINED = '{% partialdef card %}<li>{{ notice }}</li>{% endpartialdef %}'
INLINE = '{% partialdef card inline %}<li>{{ notice }}</li>{% endpartialdef %}'

context = Context({'notices': ['Water tank cleaning', 'Lift repair']})
print('partialdef card, then the loop:')
print('  ', Template(DEFINED + LOOP).render(context))
print('partialdef card inline, then the loop:')
print('  ', Template(INLINE + LOOP).render(context))
print('partialdef card inline, inside the loop:')
inside = '<ul>{% for notice in notices %}' + INLINE + '{% endfor %}</ul>'
print('  ', Template(inside).render(context))

Output

partialdef card, then the loop:
   <ul><li>Water tank cleaning</li><li>Lift repair</li></ul>
partialdef card inline, then the loop:
   <li></li><ul><li>Water tank cleaning</li><li>Lift repair</li></ul>
partialdef card inline, inside the loop:
   <ul><li>Water tank cleaning</li><li>Lift repair</li></ul>

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

Without inline, the list held exactly two items. With inline above the loop, the partial also rendered at its definition, where no notice exists yet, and an empty <li></li> appeared before the list. Inside the loop, where the fragment belongs anyway, inline defines and renders it in one go.

inline on a partial defined away from where it is used

{% partialdef card inline %} at the top of a template renders the card there too, with whatever context the top of the template has. Leave out inline when the definition is not where the fragment belongs.

A partial can be rendered on its own, as a page’s fragment

A partial also has a template name of its own: the file’s name, #, and the partial’s name. Everything that loads a template by name accepts it: render(), get_template() and {% include %}. So the detail page includes notices/notice_list.html#notice-card, and a view can return the card and nothing else:

notices/views.py, with the notice_card view Python · nivas/notices/views.py
import csv
import io

from django.core.paginator import Paginator
from django.http import FileResponse, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_http_methods, require_safe

from .models import Notice

CATEGORIES = ['water', 'lift', 'parking', 'security']


@require_safe
def notice_list(request):
    # ?category=water&category=lift: getlist() keeps every value; unknown categories are ignored.
    chosen = [c for c in request.GET.getlist('category') if c in CATEGORIES]
    notices = Notice.objects.order_by('pk')
    if chosen:
        notices = notices.filter(category__in=chosen)
    page_obj = Paginator(notices, 10).get_page(request.GET.get('page'))  # 10 notices a page; a bad page gives page 1
    return render(request, 'notices/notice_list.html', {'page_obj': page_obj, 'chosen': chosen})


@require_safe
def notice_detail(request, pk):
    notice = get_object_or_404(Notice, pk=pk)
    return render(request, 'notices/notice_detail.html', {'notice': notice})


@require_safe
def notice_card(request, pk):
    # One card, rendered from the partial of the list page: the response a script such as htmx swaps into the page.
    notice = get_object_or_404(Notice, pk=pk)
    return render(request, 'notices/notice_list.html#notice-card', {'notice': notice})


@require_http_methods(['GET', 'POST'])
def notice_create(request):
    if request.method == 'POST':
        title = request.POST.get('title', '').strip()
        category = request.POST.get('category', '')
        if title and category in CATEGORIES:
            notice = Notice.objects.create(title=title, category=category)
            return redirect('notices:detail', pk=notice.pk)  # Post/Redirect/Get
        context = {'categories': CATEGORIES, 'error': 'Give a title and one of the categories.'}
        return render(request, 'notices/form.html', context, status=422)
    return render(request, 'notices/form.html', {'categories': CATEGORIES})


@require_safe
def notice_export(request):
    rows = io.StringIO()
    writer = csv.writer(rows)
    writer.writerow(['id', 'title', 'category'])
    for notice in Notice.objects.order_by('pk'):
        writer.writerow([notice.pk, notice.title, notice.category])
    return FileResponse(io.BytesIO(rows.getvalue().encode()), as_attachment=True, filename='notices.csv')


def flat_notices(request, code):
    return HttpResponse(f'Notices for flat {code}, block {code.block}')

Version note

partialdef, partial and the #name form are new in Django 6.0. A project on 5.2 LTS gets the same tags from the django-template-partials package, and the 6.0 release notes link to its guide for moving to the built-in tags.

This program requests the list, the page of notice 9 and the card of notice 9 through the test client, with three notices in a test database:

The card in the list, in a page, and alone Python · nivas/render_cards.py
"""One card, three uses: the notices list, a notice's own page and the card alone, all from one partial.

Run it next to manage.py. It makes a test database in memory with three notices and requests the three pages through
the test client, as a browser and a script such as htmx would.
"""

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

Notice.objects.create(pk=7, title='Water tank cleaning on Sunday', category='water', posted_by='Asha', flat='B-1203')
Notice.objects.create(pk=8, title='Clubhouse booking for Diwali', category='security', posted_by='Ravi', flat='A-101',
                      fee_paise=150000)
Notice.objects.create(pk=9, title='Lift annual maintenance', category='lift', posted_by='<i>Office</i>',
                      fee_paise=1234567, body='Paid in two parts.\nThe second in March.')

client = Client()
for url in ['/notices/', '/notices/9/', '/notices/9/card/']:
    response = client.get(url)
    print(f'GET {url}  ->  {response.status_code}')
    keep = False
    for line in response.text.splitlines():
        line = line.strip()
        keep = keep or line.startswith(('<h1', '<li', '<p'))  # the page's own content, after the layout's head
        if keep and line and line not in {'</main>', '</body>', '</html>'}:
            print('    ' + line)

Output

GET /notices/  ->  200
    <h1>Notices</h1>
    <p class="fees">2 notices with a fee, ₹13,845.67 in all</p>
    <ul id="notices">
    <li id="notice-7">
    <h2>Water tank cleaning on Sunday</h2>
    <span class="resident">Asha <small>B-1203</small></span>
    </li>
    <li id="notice-8">
    <h2>Clubhouse booking for Diwali</h2>
    <span class="resident">Ravi <small>A-101</small></span>
    <p>Fee: ₹1,500.00</p>
    </li>
    <li id="notice-9">
    <h2>Lift annual maintenance</h2>
    <span class="resident">&lt;i&gt;Office&lt;/i&gt;</span>
    <p>Fee: ₹12,345.67</p>
    </li>
    </ul>
GET /notices/9/  ->  200
    <h1>Notice 9</h1>
    <ul>
    <li id="notice-9">
    <h2>Lift annual maintenance</h2>
    <span class="resident">&lt;i&gt;Office&lt;/i&gt;</span>
    <p>Fee: ₹12,345.67</p>
    </li>
    </ul>
    <p>Paid in two parts.<br>The second in March.</p>
GET /notices/9/card/  ->  200
    <li id="notice-9">
    <h2>Lift annual maintenance</h2>
    <span class="resident">&lt;i&gt;Office&lt;/i&gt;</span>
    <p>Fee: ₹12,345.67</p>
    </li>

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

The three responses render one definition. In the list, each card got its own notice from the loop; the page of notice 9 included the card inside its own <ul>; and GET /notices/9/card/ answered with the <li> alone, without base.html, without a <title>. That last shape is what a script needs to add or replace one card in a page that is already open; htmx, which the module on front ends uses, works with fragments like it. In the 2026 Django Developers Survey, 34 % of respondents used htmx.

Key idea

A partial is written once and rendered by name: with {% partial %} inside its own template, and as file.html#name from a view or an include elsewhere.

Checkpoint

Check: Defining and using a partial · Rendering a partial on its own

  1. Question 1 of 2 A template starts with {% partialdef card inline %}<li>{{ notice }}</li>{% endpartialdef %}, then loops over two notices with {% partial card %}. What is rendered?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An empty <li></li>, then the list with the two notices

    partialdef without inline only defines a fragment; partialdef … inline also renders it where it stands, with the context at that point. Use inline where the fragment belongs in that place anyway, such as inside the loop.

  2. Question 2 of 2 The notice_card view returns render(request, 'notices/notice_list.html#notice-card', {'notice': notice}). What is in the response body?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Only the card's li element, rendered with that notice

    'file.html#name' works wherever Django loads a template by name: render(), get_template() and include. It is what lets one view return a fragment that a script swaps into the page, without a second copy of the markup.

A custom filter is a Python function in a tag library

The card shows a notice’s fee, and Nivas stores money as whole paise, the hundredth part of a rupee, so that no floating-point rounding ever touches a bill. Formatting that number belongs in a filter. Custom filters and tags live in a tag library: A module in the templatetags package of an installed app, with a module-level register = template.Library(). Its filters and tags are loaded into a template by the module's name, as in {% load nivas_extras %}.: a module in the templatetags package of an installed app, with an __init__.py, and a module-level register = template.Library(). Each template that uses it starts with {% load nivas_extras %}, the module’s name. Step through what happens between the load and the text on the page:

Interactive · stage by stage From {% load %} to the text a filter returns Step through what Django does for {{ notice.fee_paise|paise }} in the notice card.
From {% load %} to the text a filter returns, step 6 of 6. Showing {% load nivas_extras %}, notices/templatetags/, register = Library() (paise, resident_badge, …), paise(1234567) (paise(None)), '₹12,345.67' ('' and no error).a bad value: ''{% load nivas_extras %}at the top of the templatenotices/templatetags/nivas_extras.pyregister = Library()paise, resident_badge, …paise(1234567)paise(None)'₹12,345.67''' and no error''

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

Every step as text
  1. The template asks for a library. The card's template starts with {% load nivas_extras %}, which asks for the tag library of that name. Libraries are named after their modules, not after their apps.
  2. The module is found. When the template engine starts, it imports the templatetags modules of every installed app, so notices/templatetags/nivas_extras.py is known as nivas_extras. That is why a new module needs a restart of the development server.
  3. The library holds the names. The module's register, a template.Library, holds every filter and tag its decorators registered: the filter paise, the simple tag resident_badge and the inclusion tag fee_summary.
  4. The filter is called. When the card renders {{ notice.fee_paise|paise }}, Django calls paise() with the value of notice.fee_paise, 1234567, the whole number of paise for notice 9.
  5. Its text goes to the page. The filter returns '₹12,345.67'. It is a plain string, so auto-escaping still applies to it on its way to the page; here there is nothing to escape.
  6. A bad value, no error. For None, a string or a float, paise() returns '' instead of raising, because an exception in a filter becomes a server error for the whole page.

The library of Nivas, with the filter and the two tags of the next section:

notices/templatetags/nivas_extras.py Python · nivas/notices/templatetags/nivas_extras.py
from django import template
from django.utils.html import format_html

register = template.Library()


@register.filter
def paise(value):
    """An amount in paise as rupees: 1234567 gives '₹12,345.67'. Anything but a whole number gives ''."""
    if isinstance(value, bool) or not isinstance(value, int):
        return ''
    sign = '-' if value < 0 else ''
    rupees, rest = divmod(abs(value), 100)
    return f'{sign}₹{rupees:,}.{rest:02d}'


@register.simple_tag
def resident_badge(name, flat=''):
    """Who posted a notice, and from which flat. format_html() escapes every value it puts into the markup."""
    if not name:
        return ''
    if not flat:
        return format_html('<span class="resident">{}</span>', name)
    return format_html('<span class="resident">{} <small>{}</small></span>', name, flat)


@register.inclusion_tag('notices/fee_summary.html')
def fee_summary(notices):
    """How many of these notices ask for a fee, and the fees together."""
    fees = [notice.fee_paise for notice in notices if notice.fee_paise]
    return {'count': len(fees), 'total': sum(fees)}

Paise and rupees

The paise filter turns 1234567 into ₹12,345.67: 12,345 rupees and 67 paise. This program calls it on amounts and on values it must refuse, then from a template; the exercise of this lesson adds the Indian grouping of lakhs and crores, ₹1,23,45,678.90.

The paise filter on good and bad values Python · money.py
"""The paise filter of Nivas, called as a function and from a template. Amounts are whole paise, so no rounding."""

import os
import sys

sys.path.insert(0, 'nivas')  # the project folder, beside this file
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.template import Context, Template  # noqa: E402

from notices.templatetags.nivas_extras import paise  # noqa: E402

for value in [1234567, 150000, 5, 0, -5050, None, '1234567', 12345.67, True]:
    print(f'paise({value!r:>11}) -> {paise(value)!r}')

template = Template('{% load nivas_extras %}Fee: {{ fee|paise|default:"none" }}')
for fee in [150000, None]:
    print(f'{{{{ fee|paise|default:"none" }}}} with fee={fee!r:>6} -> {template.render(Context({"fee": fee}))!r}')

Output

paise(    1234567) -> '₹12,345.67'
paise(     150000) -> '₹1,500.00'
paise(          5) -> '₹0.05'
paise(          0) -> '₹0.00'
paise(      -5050) -> '-₹50.50'
paise(       None) -> ''
paise(  '1234567') -> ''
paise(   12345.67) -> ''
paise(       True) -> ''
{{ fee|paise|default:"none" }} with fee=150000 -> 'Fee: ₹1,500.00'
{{ fee|paise|default:"none" }} with fee=  None -> 'Fee: none'

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

The whole numbers came out with two digits of paise and the minus sign first. None, a string, a float and True gave an empty string, and the template’s default filter then printed none. That is deliberate: Django’s documentation says an exception raised in a filter is exposed as a server error, so a filter returns a fallback for a value it cannot format. True needs its own check, because bool is a subclass of int in Python.

Restart after adding a tag library

The development server does not pick up a new templatetags module by itself. After you add one, restart runserver before {% load %} can find it.

Checkpoint

Check: Custom filters in a tag library

  1. Question 1 of 2 A custom filter does int(value) // 100 and a notice has no fee, so value is None. What do residents see if the filter does not handle it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A server error for the whole page, because the TypeError leaves the filter

    A filter should return a reasonable fallback, often '', for a value it cannot format. Raise only for a clear bug in the template itself, where a silent fallback would hide it.

  2. Question 2 of 2 The paise filter lives in notices/templatetags/nivas_extras.py. What does a template write to use it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: {% load nivas_extras %}, then {{ value|paise }}

    Custom filters and tags live in a module of an app's templatetags package, which needs an __init__.py and an app in INSTALLED_APPS. Each template that uses them loads the module by its name.

Simple tags return a value, inclusion tags render a template

A filter takes one value. A tag can take several arguments, and Django has two easy kinds. A simple tag: A custom template tag made from a Python function with @register.simple_tag. It takes arguments and returns a value, which is escaped unless it is a safe string, such as the result of format_html()., @register.simple_tag, is a function whose return value is printed where the tag stands: {% resident_badge notice.posted_by notice.flat %} in the card calls resident_badge() with the two values. An inclusion tag: A custom template tag made with @register.inclusion_tag('file.html'). Its Python function returns a dictionary, and the tag renders that template with it as the context., @register.inclusion_tag('notices/fee_summary.html'), is a function that returns a dictionary, and the tag renders that small template with it. {% fee_summary page_obj %} at the top of the list adds up the fees of the notices in Python and leaves the sentence to the template:

notices/fee_summary.html, the template of the inclusion tag HTML · nivas/notices/templates/notices/fee_summary.html
{% load nivas_extras %}
{% if count %}<p class="fees">{{ count }} notice{{ count|pluralize }} with a fee, {{ total|paise }} in all</p>{% endif %}

The list in the run above shows both: its sentence about two notices with a fee came from the inclusion tag, which uses the paise filter inside its own template, and each <span class="resident"> came from the simple tag. Notice 9 was posted by <i>Office</i>, and its badge shows those characters as text, &lt;i&gt;Office&lt;/i&gt;, because the simple tag built its markup with format_html(), the subject of the next section.

Choosing between the five tools of this module comes down to what the piece is:

The piece is Use
A fragment of one template, used again in it or rendered alone a partial
A fragment many templates share its own file and {% include %}
A value changed for display a filter
A value or a short snippet computed from arguments a simple tag
Markup that needs data computed in Python first an inclusion tag

Escape every value when Python builds HTML

A simple tag that returns a plain string has it escaped, markup and all. To return markup, a tag must return a safe string: A string marked as HTML that needs no more escaping, such as the result of format_html() or mark_safe(). Templates print it as it is, so it must never hold unescaped text from a user., and there are two ways to make one. mark_safe(text) marks a string as trusted HTML and checks nothing. format_html(template, *values) escapes each value, puts it into the markup and marks only the result safe. This program builds the same badge both ways, for a resident who typed a link into their name:

One badge built with mark_safe and with format_html Python · badges.py
"""Building HTML in Python. The same badge two ways, for a name that a resident typed with markup in it."""

import django
from django.conf import settings

settings.configure()
django.setup()

from django.utils.html import conditional_escape, format_html, format_html_join  # noqa: E402
from django.utils.safestring import mark_safe  # noqa: E402


def badge_concatenated(name, flat):  # never do this: the name goes into the markup unescaped
    return mark_safe(f'<span class="resident">{name} <small>{flat}</small></span>')


def badge_formatted(name, flat):
    return format_html('<span class="resident">{} <small>{}</small></span>', name, flat)


for name in ['Asha', 'Ravi <a href="https://pay.example/">pay here</a>']:
    for build in [badge_concatenated, badge_formatted]:
        html = build(name, 'B-1203')
        live = '<a href' in html
        print(f'{build.__name__}({name!r}):')
        print(f'  {html}')
        print(f'  the typed link is {"a live link: a hole" if live else "shown as text"}')

print('conditional_escape() escapes text once, and leaves safe strings alone:')
print('  ', conditional_escape('<b>'), '|', conditional_escape(conditional_escape('<b>')), '|',
      conditional_escape(mark_safe('<b>')))
flats = [('B-1203', 'Asha'), ('A-101', 'Ravi & Meera')]
print('format_html_join() for a list:')
print('  ', format_html_join('', '<li>{} ({})</li>', flats))

Output

badge_concatenated('Asha'):
  <span class="resident">Asha <small>B-1203</small></span>
  the typed link is shown as text
badge_formatted('Asha'):
  <span class="resident">Asha <small>B-1203</small></span>
  the typed link is shown as text
badge_concatenated('Ravi <a href="https://pay.example/">pay here</a>'):
  <span class="resident">Ravi <a href="https://pay.example/">pay here</a> <small>B-1203</small></span>
  the typed link is a live link: a hole
badge_formatted('Ravi <a href="https://pay.example/">pay here</a>'):
  <span class="resident">Ravi &lt;a href=&quot;https://pay.example/&quot;&gt;pay here&lt;/a&gt; <small>B-1203</small></span>
  the typed link is shown as text
conditional_escape() escapes text once, and leaves safe strings alone:
   &lt;b&gt; | &lt;b&gt; | <b>
format_html_join() for a list:
   <li>B-1203 (Asha)</li><li>A-101 (Ravi &amp; Meera)</li>

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

For Asha the two badges were the same. For the name with a link in it, the f-string and mark_safe() put a live link into every page that shows the badge; format_html() escaped the name, and the link stayed text. Django’s documentation strongly recommends format_html() over mark_safe() for building small snippets of HTML. conditional_escape() escapes text once and leaves a safe string alone, as the second call in the run shows, and format_html_join() builds a list of pieces, escaping each value, as Ravi &amp; Meera shows.

Never pass user text through mark_safe

mark_safe(f'<span>{name}</span>'), mark_safe('<span>' + name + '</span>') and the same with % all put whatever the user typed into your markup. Write format_html('<span>{}</span>', name), and review every mark_safe() in a code base as a place where escaping is switched off.

Checkpoint

Check: Building HTML safely in Python · Simple tags and inclusion tags

  1. Question 1 of 2 A tag returns mark_safe(f'<span>{name}</span>'), and a resident is named Ravi <a href="https://pay.example/">pay here</a>. What does every page that shows the badge contain?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A live link to pay.example, because mark_safe told Django the string needs no escaping

    format_html('<span>{}</span>', name) escapes the name and marks only the result safe. Never build HTML from user text with f-strings, +, or % and then call mark_safe().

  2. Question 2 of 2 The fee summary needs a sum computed in Python and a few lines of markup with a condition. Which kind of tag fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An inclusion tag, whose function returns the numbers and whose template holds the markup

    A simple tag returns a value; an inclusion tag returns a context and renders a small template with it. Use the inclusion tag when the output is markup worth keeping as a template.

Practise: rupees with Indian grouping

Lakh and crore

Amounts in rupees are usually grouped the Indian way: the last three digits, then groups of two, so twelve lakh thirty-four thousand five hundred and sixty-seven rupees is written ₹12,34,567, and a crore has eight digits.

Write paise_to_rupees(value, indian_grouping=False), the function behind a filter like the lesson’s, with both kinds of grouping and the same rule for values it cannot show. Its eight sample tests run in your browser and cover both kinds of grouping, negative amounts and the values a filter must refuse:

Exercise · Medium · Python

Format paise as rupees, with Indian grouping

Nivas stores money as whole paise, the hundredth part of a rupee, so 1234567 is 12,345.67 rupees and nothing is lost to floating point. Write paise_to_rupees(value, indian_grouping=False), the function behind a template filter that shows such an amount:

  • The result is the rupee sign, the rupees with a comma between groups of digits, a point and two digits of paise: paise_to_rupees(1234567) is '₹12,345.67', and paise_to_rupees(5) is '₹0.05'.
  • By default the digits are grouped in threes: paise_to_rupees(123456789) is '₹1,234,567.89'.
  • With indian_grouping=True they are grouped the Indian way, the last three digits and then groups of two (lakh and crore): paise_to_rupees(123456789, indian_grouping=True) is '₹12,34,567.89', and 1234567890 gives '₹1,23,45,678.90'.
  • A negative amount starts with the minus sign: paise_to_rupees(-5050) is '-₹50.50'.
  • A template filter should not raise for a value it cannot show, so None, True or False, a float and a string all give ''.

Starter code · rupees.py

def paise_to_rupees(value, indian_grouping=False):
    """An amount in whole paise as rupees, such as '₹12,345.67'; '' for a value that is not a whole number."""
    return f'₹{value / 100}'
The sample tests · test_rupees.py
from rupees import paise_to_rupees


def test_an_amount_with_paise():
    assert paise_to_rupees(1234567) == '₹12,345.67'


def test_small_amounts_keep_two_digits_of_paise():
    assert paise_to_rupees(5) == '₹0.05'
    assert paise_to_rupees(0) == '₹0.00'
    assert paise_to_rupees(100) == '₹1.00'


def test_groups_of_three_by_default():
    assert paise_to_rupees(123456789) == '₹1,234,567.89'
    assert paise_to_rupees(99999) == '₹999.99'


def test_indian_grouping_for_lakhs():
    assert paise_to_rupees(123456789, indian_grouping=True) == '₹12,34,567.89'
    assert paise_to_rupees(1234567, indian_grouping=True) == '₹12,345.67'


def test_indian_grouping_for_crores():
    assert paise_to_rupees(1234567890, indian_grouping=True) == '₹1,23,45,678.90'


def test_amounts_under_a_thousand_rupees_have_no_comma():
    assert paise_to_rupees(99999, indian_grouping=True) == '₹999.99'
    assert paise_to_rupees(100000, indian_grouping=True) == '₹1,000.00'


def test_negative_amounts():
    assert paise_to_rupees(-5050) == '-₹50.50'
    assert paise_to_rupees(-12345678, indian_grouping=True) == '-₹1,23,456.78'


def test_values_that_are_not_whole_numbers_give_an_empty_string():
    for value in [None, True, False, 12.5, '100']:
        assert paise_to_rupees(value) == '', repr(value)
A hint

divmod(abs(value), 100) splits an amount into rupees and paise, and f'{rest:02d}' writes the paise with two digits. For the Indian grouping, write the rupees as text and cut it from the right: the last three digits first, then two at a time. Check for bool before int, because True is an int in Python.

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.

Fifteen questions follow on partials, fragments, filters, tags and safe HTML, chosen from where your answers so far were weakest, and every choice gives its reason:

Practice that adapts to you

Practise this lesson

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

  1. Question 1 of 15 Which Django release added {% partialdef %} and {% partial %} to the template language?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: Django 6.0

    Template partials are new in Django 6.0. On 5.2 LTS, install django-template-partials and load its tags; the 6.0 release notes link to the package's guide for moving to the built-in tags.

  2. Question 2 of 15 Inside a for loop, {% partial notice-card %} renders a card that uses {{ notice.title }}. Which notice does each card show?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: The notice of the current pass, because a partial renders with the current context

    A partial is a named piece of the same template. Each use renders it again with the context at that point, which is what makes it work inside a loop.

  3. Question 3 of 15 A card is used only by the list page and by the view that returns one card. Why keep it as a partial of notice_list.html rather than a file of its own?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: The markup stays next to the loop that uses it, and the fragment view can still render it alone by name

    Partials keep a fragment in the template it belongs to and still give it a name of its own. A fragment many pages share may still deserve its own file; both can be loaded by name.

  4. Question 4 of 15 How does notice_detail.html reuse the card defined in notice_list.html?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: {% include "notices/notice_list.html#notice-card" %}

    'file.html#partial' is a template name like any other, so include, render() and get_template() all accept it.

  5. Question 5 of 15 The card view renders notices/notice_list.html#notice-card. Does the response contain the <title> from base.html?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: No; only the partial is rendered, not the template around it or its parent

    A fragment response carries only the fragment, which is what a script that swaps one card into a page needs: no second html element, no second header.

  6. Question 6 of 15 Nivas must also run on 5.2 LTS until its upgrade, and the card view uses 'notice_list.html#notice-card'. What does the 5.2 project need?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: The django-template-partials package, set up as its documentation says, which provides the same tags and the # names

    New features of 6.0 often have a 5.2 route through a package. Plan the move to the built-in tags with the package's migration guide, which the 6.0 release notes link, when the project upgrades.

  7. Question 7 of 15 Where must the module with a custom filter live?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: In the templatetags package of an installed app, with an __init__.py

    An app in INSTALLED_APPS, a templatetags folder with an __init__.py, a module with register = template.Library(): that is all a tag library needs. Restart the development server after adding the module.

  8. Question 8 of 15 Why does the paise filter check for bool before it checks for int?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: Because True is an int in Python, and paise(True) would otherwise print ₹0.01

    isinstance(True, int) is True in Python. A filter that wants whole numbers only checks for bool first, as the exercise of this lesson does too.

  9. Question 9 of 15 The paise filter returns a plain str such as '₹12,345.67'. What happens to it on the way to the page?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: Auto-escaping applies to it, like any value; it has nothing to escape

    Return plain strings from filters that produce text. A filter that builds markup uses needs_autoescape and conditional_escape, or format_html, and returns a safe string.

  10. Question 10 of 15 A simple tag returns the plain string '<b>Ravi</b>'. How does it appear in the page?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: Escaped, as the text <b>Ravi</b>

    Return a plain value and the template escapes it; return format_html(...) when the tag builds markup, so the markup is kept and every value inside it is escaped.

  11. Question 11 of 15 What does the function behind @register.inclusion_tag('notices/fee_summary.html') return?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: A dictionary, which becomes the context of fee_summary.html

    An inclusion tag separates the work: Python computes the data, a small template holds the markup. With takes_context=True its function also receives the calling template's context.

  12. Question 12 of 15 Every resident page shows the number of unread notices in its header. Which approach costs a query only on the pages that show it?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: An inclusion tag in the header of the resident layout that runs the query itself

    Put data that only some pages need where those pages ask for it. An inclusion tag in one layout keeps the query in one place and runs it only there.

  13. Question 13 of 15 What does conditional_escape(conditional_escape("<b>")) return?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: &lt;b&gt;, escaped once

    conditional_escape escapes text that is not marked safe, and returns safe strings unchanged, so escaping twice by accident never produces &amp;lt; in a page.

  14. Question 14 of 15 A tag must build one <li> for each flat, from pairs of a flat code and a resident's name. What is the safe way?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: format_html_join('', '<li>{} ({})</li>', flats)

    format_html for one piece, format_html_join for a list of pieces: both escape every value and mark only the finished markup safe.

  15. Question 15 of 15 In a review you find return mark_safe('<span class="badge">' + escape(name) + '</span>'). Is it safe, and what would you ask for?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: Safe for now, because name is escaped, but ask for format_html, which cannot forget a value

    Hand-built HTML is only as safe as its least careful line. format_html makes escaping the default for every value, so a later change cannot forget it.

Interview questions

Warm-up (fresher to mid-level): Simple tag or inclusion tag: what does each return, and when do you pick which?

Both are custom template tags written as Python functions in a tag library. A simple tag returns a value, which is printed where the tag stands and escaped unless it is a safe string, so it suits a computed value or a short snippet built with format_html(). An inclusion tag returns a dictionary, and Django renders a template named in its decorator with that dictionary as the context, so the markup stays in a template. Choose an inclusion tag when the output is markup with conditions or loops, and a simple tag when it is a value.

Two design questions of this module start from these tags: one card used by three responses, and a review of a tag that builds HTML.

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

  1. Mid to senior

    A notice card appears in the list page, on each notice's page, and as the response a script fetches to add one card to an open page. The team keeps three copies of its markup. How do you get to one copy, and how does one URL answer both a full page and a fragment?

    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

    In a code review you find a custom tag that returns mark_safe(f'<span class="badge">{name}</span>'), where name is a resident's display name. What is wrong, how do you fix it, and how do you prove the fix with a test?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • {% partialdef name %} defines a named fragment and {% partial name %} renders it with the current context; inline also renders it where it is defined, which outside a loop adds an extra, empty copy.
  • 'file.html#name' renders one partial alone from render(), get_template() or {% include %}, which gives a view a fragment response without a second copy of the markup; 5.2 LTS needs django-template-partials for it.
  • Custom filters live in a templatetags module of an installed app with register = template.Library(), are loaded with {% load %}, and return a fallback such as '' instead of raising.
  • A simple tag returns a value; an inclusion tag returns a dictionary that renders its own small template.
  • Build HTML in Python with format_html(), format_html_join() and conditional_escape(), which escape every value, never with an f-string inside mark_safe().

Terms in this lesson

inclusion tag
A custom template tag made with @register.inclusion_tag('file.html'). Its Python function returns a dictionary, and the tag renders that template with it as the context.
safe string
A string marked as HTML that needs no more escaping, such as the result of format_html() or mark_safe(). Templates print it as it is, so it must never hold unescaped text from a user.
simple tag
A custom template tag made from a Python function with @register.simple_tag. It takes arguments and returns a value, which is escaped unless it is a safe string, such as the result of format_html().
tag library
A module in the templatetags package of an installed app, with a module-level register = template.Library(). Its filters and tags are loaded into a template by the module's name, as in {% load nivas_extras %}.
template partial
A named fragment of a template, defined with {% partialdef name %} … {% endpartialdef %} and rendered with {% partial name %}, or loaded on its own as 'file.html#name'. New in Django 6.0.

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.