ZetCode

Django custom template tags and filters

last modified November 13, 2026 Python 3.15

In this article we extend the Django template language with our own filters and tags. We create a templatetags package and write filters, simple tags, a tag that reads the template context, and an inclusion tag. The examples use Django 6.1 and Python 3.15.

Custom template tags and filters

The built-in tags and filters (see Django templates) cover most needs, but sometimes we want to format a value in a way specific to our site or to render a reusable block with its own data. For that Django lets us write a template library: a Python module that registers new filters and tags. A filter transforms a value: {{ value|initials }}. A tag can produce output or even control the rendering: {% price 10 %}. Django offers three helpers to write tags: simple_tag for a tag that returns a string, inclusion_tag for a tag that renders a template, and the low-level register.tag for tags that process the content between a start and an end tag, which is rarely needed.

The example project

The project is created with uv; the steps are explained in Django introduction. The commands below create a project directory, install Django, generate the project with the configuration package config, and start the app shop.

$ mkdir templatetags
$ cd templatetags
$ uv init --bare
$ uv add django
$ uv run django-admin startproject config .
$ uv run python manage.py startapp shop

The trailing dot in startproject config . creates manage.py and the config package in the current directory instead of one level deeper. A new app is ignored by Django until it is listed in INSTALLED_APPS, so we add the app to the list in config/settings.py; the other entries are generated by startproject.

config/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'shop',
]

Every listing in this article is a file of the application, shown with a solid blue border and the label Project file.

The templatetags package

A template library lives in a package named templatetags inside an app. The app must be listed in INSTALLED_APPS, and the package needs an __init__.py file. The name of the module inside it is the name that templates use in the {% load %} tag. We create the package and the empty file with two commands.

$ mkdir shop/templatetags
$ touch shop/templatetags/__init__.py

The next tree shows the layout of our app shop with the package and the files that we create in this article.

shop
├── __init__.py
├── admin.py
├── apps.py
├── migrations
│   └── __init__.py
├── models.py
├── templates
│   └── shop
│       ├── _product_list.html
│       └── demo.html
├── templatetags
│   ├── __init__.py
│   └── shop_extras.py
├── tests.py
├── urls.py
└── views.py

Registering filters

Every library module creates a Library instance named register; Django looks for that name. The @register.filter decorator registers a function as a filter. The function receives the value before the pipe as the first argument and the optional filter argument as the second. Here is the complete module; we describe its parts one by one.

shop/templatetags/shop_extras.py
from django import template
from django.utils.html import conditional_escape, format_html
from django.utils.safestring import mark_safe

register = template.Library()


@register.filter
def initials(value):
    return ''.join(word[0].upper() for word in str(value).split())


@register.filter(name='mul')
def multiply(value, arg):
    return value * arg


@register.filter(needs_autoescape=True)
def emphasize(text, autoescape=True):
    escape = conditional_escape if autoescape else (lambda value: value)
    return mark_safe(f'<em>{escape(text)}</em>')


@register.simple_tag
def price(amount, currency='EUR'):
    return f'{amount:,.2f} {currency}'


@register.simple_tag(takes_context=True)
def greeting(context):
    return f"Hello, {context['user_name'].title()}!"


@register.simple_tag
def badge(text, color='blue'):
    return format_html('<span class="badge badge-{}">{}</span>', color, text)


@register.inclusion_tag('shop/_product_list.html')
def product_list(products, limit=3):
    return {'products': products[:limit]}

The module registers three filters, three simple tags, and one inclusion tag. We use it from the template demo.html, which is rendered by the demo() view at the root URL. The view passes the user name, a note with HTML in it, and a list of products to the template.

A simple filter

The initials filter builds the initials from a name. When we register a function without arguments, the name of the filter is the name of the function. The name parameter changes it, as in the mul filter that calls the function multiply(). A filter takes one or two arguments; if we need more values, we pass a string and split it or use a tag instead.

@register.filter
def initials(value):
    return ''.join(word[0].upper() for word in str(value).split())


@register.filter(name='mul')
def multiply(value, arg):
    return value * arg

Filters should not raise exceptions, because an error in a filter breaks the whole page; if the input can be invalid, return the original value or an empty string. Our filters are used like this in the template: {{ user_name|initials }} and {{ 6|mul:7 }}. The complete output of the page is shown at the end of the next sections.

Filters and escaping

A filter that returns HTML must care about auto-escaping. The string returned by a filter is escaped when it is printed, unless it is marked safe with mark_safe(). If a filter marks its result as safe, it takes the responsibility that all input is escaped. The needs_autoescape=True option passes the current autoescaping state to the function as the autoescape argument, so that the filter can behave correctly inside the {% autoescape off %} block. The conditional_escape() function escapes a value unless it is already marked safe.

@register.filter(needs_autoescape=True)
def emphasize(text, autoescape=True):
    escape = conditional_escape if autoescape else (lambda value: value)
    return mark_safe(f'<em>{escape(text)}</em>')

The filter wraps the text in an em element. The element is HTML we wrote ourselves, so the result is marked safe, while the text from the variable is escaped first. The note in our context contains a <b> tag, which must not become real markup.

Simple tags

The @register.simple_tag decorator registers a function as a tag. The tag accepts positional and keyword arguments, written like in Python code, and the value that the function returns is printed. The result of a simple tag is escaped when it is not marked safe. The tag can store its result in a variable with the as keyword instead of printing it.

@register.simple_tag
def price(amount, currency='EUR'):
    return f'{amount:,.2f} {currency}'

The function formats a number with two decimals and a currency code. In a template, the tag is called in three ways.

{% price 1234.5 %}
{% price 99 currency='USD' %}
{% price 5 as cheap %}

The first call prints the amount with the default currency, the second one passes the currency as a keyword argument, and the third one stores the text in the cheap variable, which is printed later with {{ cheap }}. The thousands separator and the two decimals come from the format specification ,.2f.

Tags that read the context

When a tag needs the template context, for example to read a variable of the page or the current user, we pass takes_context=True. The function then receives the context as its first argument.

@register.simple_tag(takes_context=True)
def greeting(context):
    return f"Hello, {context['user_name'].title()}!"

The tag takes no arguments in the template, {% greeting %}, but reads user_name from the context. A context-reading tag is convenient, but it makes the tag depend on the names of the variables; explicit arguments are easier to understand and test.

Producing HTML safely

A tag that returns HTML should build it with format_html(). It works like str.format() but escapes the arguments and returns a safe string, so the template engine does not escape the markup itself again. Concatenating user data into HTML with an f-string and calling mark_safe() on it creates an XSS vulnerability.

@register.simple_tag
def badge(text, color='blue'):
    return format_html('<span class="badge badge-{}">{}</span>', color, text)

In the template we call {% badge 'New' color='green' %} and {% badge note %}. The first produces a green badge. The second passes the note variable, whose HTML characters are escaped by format_html(). Note that Django treats a string literal written in a template as safe text, because the template author wrote it; only the values of variables are escaped. That is why the second call uses a variable.

Inclusion tags

An inclusion tag renders a template with data computed by the tag. It is the right tool for a reusable block with its own context, such as a list of the latest articles or a sidebar. The decorator receives the name of the template, and the function returns the dictionary used as the context of that template.

@register.inclusion_tag('shop/_product_list.html')
def product_list(products, limit=3):
    return {'products': products[:limit]}

The tag accepts a list of products and a limit, and passes the first products to the template of the block. The template of the block has its own context; it contains only what the function returned. Note that the template must also load the library with {% load shop_extras %} to use the price tag, because loaded libraries are not inherited by included templates.

shop/templates/shop/_product_list.html
{% load shop_extras %}
<ul class="products">
{% for product in products %}
  <li>{{ product.name }}: {% price product.price %}</li>
{% endfor %}
</ul>

The product list template prints each product with the price formatted by our tag. The page template that uses all the tags is below.

Using the library in a template

The {% load %} tag loads the library by the name of its module. It must be repeated in every template that uses the library; a loaded library is not inherited from a parent template. The name stands for the file shop/templatetags/shop_extras.py. The template is rendered by a view, which we show first.

shop/views.py
from django.shortcuts import render


def demo(request):
    context = {
        'user_name': 'alice smith',
        'note': 'a <b>risky</b> note',
        'products': [
            {'name': 'Lamp', 'price': 1234.5},
            {'name': 'Desk <small>', 'price': 99},
            {'name': 'Chair', 'price': 45.25},
        ],
    }
    return render(request, 'shop/demo.html', context)

The view passes a user name, a note with HTML in it, and a list of products to the template. The note and one product name contain HTML characters on purpose, so that we can watch the escaping.

shop/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path('', views.demo, name='demo'),
]

The only route of the app is the root URL, which calls the view. The project's urls.py includes this file.

config/urls.py
from django.urls import include, path

urlpatterns = [
    path('', include('shop.urls')),
]

Now we can write the template that uses the library.

shop/templates/shop/demo.html
{% load shop_extras %}
<p>Initials: {{ user_name|initials }}</p>
<p>Multiplied: {{ 6|mul:7 }}</p>
<p>Emphasized: {{ note|emphasize }}</p>
<p>Price: {% price 1234.5 %}</p>
<p>Price: {% price 99 currency='USD' %}</p>
{% price 5 as cheap %}
<p>Cheap: {{ cheap }}</p>
<p>{% greeting %}</p>
<p>{% badge 'New' color='green' %} {% badge note %}</p>
{% product_list products limit=2 %}

The template uses each filter and tag. The {% load %} tag comes first. We start the server and request the page with curl; the server must be restarted after the templatetags package is created, because Django scans the libraries at startup (later changes of the module are picked up by the autoreloader). The blank lines produced by the tags are removed from the output.

The rendered page

The initials of alice smith are AS and 6|mul:7 gives 42. The note is wrapped in em, and its <b> tags are shown as escaped text. The prices use the thousands separator and the chosen currency, and the variable of the as form holds the text 5.00 EUR. The badge produced by format_html() is real HTML, while the text of the second badge, the note variable, is escaped. The inclusion tag lists two of the three products, because of limit=2, and the name Desk <small> is escaped in the template of the block.

$ curl -s http://127.0.0.1:8000/
<p>Initials: AS</p>
<p>Multiplied: 42</p>
<p>Emphasized: <em>a &lt;b&gt;risky&lt;/b&gt; note</em></p>
<p>Price: 1,234.50 EUR</p>
<p>Price: 99.00 USD</p>
<p>Cheap: 5.00 EUR</p>
<p>Hello, Alice Smith!</p>
<p><span class="badge badge-green">New</span> <span class="badge badge-blue">a &lt;b&gt;risky&lt;/b&gt; note</span></p>
<ul class="products">
  <li>Lamp: 1,234.50 EUR</li>
  <li>Desk &lt;small&gt;: 99.00 EUR</li>
</ul>

Testing template tags

Template tags are ordinary Python code, so we test them with the Django test runner. The SimpleTestCase class does not need a database. The tests render small template strings with the Template class and compare the result.

shop/tests.py
from django.template import Context, Template
from django.test import SimpleTestCase


class TemplateTagTests(SimpleTestCase):

    def render(self, source, **context):
        template = Template('{% load shop_extras %}' + source)
        return template.render(Context(context))

    def test_initials(self):
        self.assertEqual(self.render('{{ name|initials }}', name='alice smith'), 'AS')

    def test_mul_filter_name(self):
        self.assertEqual(self.render('{{ 6|mul:7 }}'), '42')

    def test_emphasize_escapes_input(self):
        self.assertEqual(self.render('{{ text|emphasize }}', text='<b>'), '<em>&lt;b&gt;</em>')

    def test_price_with_currency(self):
        self.assertEqual(self.render("{% price 1234.5 currency='USD' %}"), '1,234.50 USD')

    def test_price_stored_in_variable(self):
        self.assertEqual(self.render('{% price 5 as p %}[{{ p }}]'), '[5.00 EUR]')

    def test_badge_escapes_text(self):
        html = self.render('{% badge text %}', text='<i>')
        self.assertIn('&lt;i&gt;', html)

    def test_inclusion_tag_limit(self):
        products = [{'name': n, 'price': 1} for n in 'abc']
        html = self.render('{% product_list products limit=2 %}', products=products)
        self.assertIn('a:', html)
        self.assertNotIn('c:', html)

The render() helper prepends the {% load %} tag to a template fragment. The tests cover the name of the renamed filter, the escaping behavior of emphasize and badge, the as form of the tag, and the limit of the inclusion tag.

$ uv run python manage.py test
Found 7 test(s).
System check identified no issues (0 silenced).
.......
----------------------------------------------------------------------
Ran 7 tests in 0.003s

OK

Source

Django documentation: Custom template tags and filters

Django documentation: django.utils.html

In this article we have created a templatetags package with custom filters, simple tags, a tag that reads the context, and an inclusion tag, and we have seen how to keep their output safe from XSS. The language itself is described in Django templates; the next article, Django static files, shows how to load stylesheets and images in templates.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.