Skip to main content

🎨 Django Template Language

Your views assemble the data; templates decide how it looks. The Django Template Language (DTL) is a small, deliberately restrained language for turning a context dictionary into HTML — with variables, filters, control-flow tags, and a powerful inheritance system that keeps your markup DRY.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Output context data with variables and dot-lookup, and transform it with filters
  • Add logic with tagsif, for, url, static, and the forloop helpers
  • Build a layout with template inheritance (extends, block, block.super, include)
  • Explain autoescaping and use the safe filter responsibly
  • Write a custom filter and render a form's fields with per-field error handling

Estimated Time: 35–45 minutes  •  Difficulty: Intermediate

Hands-on: Build a base layout and a child template that extends it to render a paginated post list.

In This Lesson

Templates in the MVT Flow

In Django's MVT pattern, the template is the "T". A view builds a context — an ordinary Python dictionary — and hands it, with a template name, to the template engine. The engine renders the template against that context and produces the final HTML string that becomes the response body.

flowchart LR A[View builds context dict] -- "render(request, tpl, context)" --> B[Template engine] C[Template file .html] --> B B -- Rendered HTML --> D[HttpResponse]
💡 A useful analogy: A template is an interior-design blueprint. It says "hang the client's artwork here, scaled to the frame" and "paint this wall the chosen colour" — but it holds no actual furniture. The data (furniture) arrives at render time and drops into the labelled spots.

DTL is intentionally not full Python. You can't run arbitrary statements or call functions with arguments. That restriction is a feature: it keeps presentation logic in templates and business logic in views and models, where it belongs and can be tested.

📖 Key Terms

Variable: {{ value }} — outputs a value from the context.

Filter: {{ value|filter }} — transforms a value for display.

Tag: {% tag %} — performs logic: loops, conditionals, inheritance, URL generation.

Variables & Dot Lookup

A variable is wrapped in double braces. The dot operator does a flexible lookup: Django tries dictionary-key access, then attribute access, then a no-argument method call, then numeric list-index access — in that order — and uses the first that succeeds.

<h1>{{ user.username }}'s Profile</h1>
<p>Joined {{ user.date_joined }}</p>
<p>Full name: {{ user.get_full_name }}</p>   {# calls the method, no () #}
<p>First post: {{ posts.0.title }}</p>        {# list index, then attribute #}

The same view context drives it. Note that in the template you write user.get_full_name with no parentheses — DTL calls it for you, and methods that need arguments simply can't be called from a template:

# views.py
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.models import User

def profile(request, username):
    user = get_object_or_404(User, username=username)
    return render(request, "accounts/profile.html", {
        "user": user,
        "posts": user.posts.all(),
    })

If a variable doesn't exist, DTL renders an empty string rather than raising an error — quiet by design, so a typo shows up as a blank spot rather than a crashed page.

Filters

Filters transform a value on its way to the page, using the pipe character. Some take an argument after a colon, and filters can be chained left to right:

{{ post.published_date|date:"F j, Y" }}     {# January 15, 2026 #}
{{ user.bio|default:"No bio provided." }}
{{ post.body|truncatewords:40 }}
{{ post.title|lower|truncatechars:20 }}      {# chained #}
{{ tags|join:", " }}
{{ attachment.size|filesizeformat }}         {# 2.4 MB #}
{{ posts|length }}
FilterPurposeExample → Output
dateFormat a date/timevalue|date:"D d M Y" → Thu 15 Jan 2026
defaultFallback when empty/falsebio|default:"—"
truncatewordsLimit to N wordsbody|truncatewords:30
linebreaksPlain text → paragraphscomment|linebreaks
pluralizeAdd "s" when count ≠ 1{{ n }} item{{ n|pluralize }}
yesnoMap booleans to wordsactive|yesno:"on,off"

💡 Localize with humanize

Add "django.contrib.humanize" to INSTALLED_APPS, then {% load humanize %} to unlock friendly filters like intcomma (1,234,567), naturaltime ("3 hours ago"), and naturalday ("yesterday").

Tags & Control Flow

Tags, wrapped in {% %}, provide logic. The essentials are conditionals and loops:

{% if user.is_authenticated %}
    <p>Welcome back, {{ user.username }}!</p>
    {% if user.is_staff %}<a href="/admin/">Admin</a>{% endif %}
{% else %}
    <p>Please <a href="{% url 'login' %}">log in</a>.</p>
{% endif %}

<ul>
{% for post in posts %}
    <li>
        <a href="{% url 'blog:post_detail' post.pk %}">{{ post.title }}</a>
        <small>#{{ forloop.counter }} by {{ post.author.username }}</small>
    </li>
{% empty %}
    <li>No posts yet.</li>
{% endfor %}
</ul>

Inside a loop, the forloop variable gives you position helpers: forloop.counter (1-indexed), forloop.counter0, forloop.first, forloop.last, forloop.revcounter, and forloop.parentloop for nested loops. The {% empty %} clause renders when the iterable is empty — no manual length check needed.

The url and static tags

Never hardcode paths. The url tag reverses a named URL pattern, and static builds the correct path to a static asset regardless of storage backend:

{% load static %}

<link rel="stylesheet" href="{% static 'blog/css/style.css' %}">
<img src="{% static 'blog/img/logo.png' %}" alt="Logo">

<a href="{% url 'blog:post_detail' post.pk %}">Read more</a>
<a href="{% url 'blog:archive' year=2026 month=1 %}">January archive</a>

⚠️ Hardcoded URLs rot

Writing href="/blog/post/5/" breaks the moment you change a URL pattern. {% url %} looks the path up from its name, so a URL change never forces a template edit. The same logic applies to {% static %} for assets.

Template Inheritance

Inheritance is DTL's most powerful feature. A base template defines the page skeleton and marks overridable holes with {% block %}. Child templates {% extends %} the base and fill only the blocks they care about.

{# templates/base.html #}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{% block title %}My Site{% endblock %}</title>
    {% load static %}
    <link rel="stylesheet" href="{% static 'css/style.css' %}">
    {% block extra_css %}{% endblock %}
</head>
<body>
    {% include "partials/navbar.html" %}

    <main class="container">
        {% if messages %}
            {% for message in messages %}
                <div class="alert alert-{{ message.tags }}">{{ message }}</div>
            {% endfor %}
        {% endif %}

        {% block content %}{% endblock %}
    </main>

    <footer>{% block footer %}&copy; 2026 My Site.{% endblock %}</footer>
</body>
</html>

A child template overrides blocks and can reuse the parent's content with {{ block.super }}:

{# templates/blog/post_list.html #}
{% extends "base.html" %}

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

{% block content %}
    <h1>Latest Posts</h1>
    {% for post in posts %}
        {% include "blog/_post_card.html" with post=post only %}
    {% empty %}
        <p>No posts found.</p>
    {% endfor %}
{% endblock %}
flowchart TD A["base.html
blocks: title, content, footer"] --> B["blog/base.html
overrides header, extra_css"] B --> C["blog/post_list.html
fills content"] B --> D["blog/post_detail.html
fills content, title"]

The {% include %} tag pulls in a reusable partial; with post=post only passes just that one variable and isolates the partial from the rest of the context — a clean, predictable component. Multi-level inheritance (base → section base → page) lets whole sections share chrome while individual pages stay lean.

Autoescaping & Custom Filters

By default, DTL autoescapes every variable — it converts <, >, &, ", and ' to HTML entities. This is your first line of defence against cross-site scripting (XSS): if a comment contains <script>, the browser shows the text instead of running it.

{{ user_comment }}          {# safe: any HTML is escaped and shown as text #}
{{ trusted_html|safe }}      {# renders raw HTML — only for content YOU control #}

⚠️ The safe filter is a loaded gun

Only mark content safe when you are certain it can't contain attacker-supplied markup — output from your own Markdown renderer with sanitisation, for example. Never apply safe to raw user input; that's how XSS holes are born.

Writing a custom filter

When a built-in filter doesn't exist, add your own in an app's templatetags/ package:

# blog/templatetags/blog_extras.py
from django import template

register = template.Library()

@register.filter
def currency(value, code="USD"):
    """Format a number as currency: {{ price|currency:'EUR' }}"""
    symbols = {"USD": "$", "EUR": "€", "GBP": "£"}
    return f"{symbols.get(code, '')}{value:.2f}"
{% load blog_extras %}
<p>Price: {{ product.price|currency:"EUR" }}</p>

Remember the templatetags/ directory needs an __init__.py, and you must restart the dev server after adding a new tag library.

Rendering a form field-by-field

Looping over a form gives you full control of markup while still surfacing per-field errors:

<form method="post">
    {% csrf_token %}
    {% for field in form %}
        <div class="form-group">
            {{ field.label_tag }}
            {{ field }}
            {% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}
            {% for error in field.errors %}
                <p class="error">{{ error }}</p>
            {% endfor %}
        </div>
    {% endfor %}
    {{ form.non_field_errors }}
    <button type="submit">Save</button>
</form>

Hands-on Exercise

🏋️ Build a base layout and a post-list child

Objective: Create a reusable base.html and a post_list.html that extends it to render a paginated list of posts.

Requirements:

  1. base.html defines title and content blocks and a footer.
  2. post_list.html sets a page-specific title and loops over posts, showing each title as a link to its detail page.
  3. Show "No posts yet." when the list is empty.
  4. Below the list, add Previous/Next pagination links using the page_obj that ListView provides.
💡 Hint

Use {% empty %} inside the {% for %} loop. For pagination, guard the links with {% if page_obj.has_previous %} / {% if page_obj.has_next %} and build hrefs with ?page={{ page_obj.previous_page_number }}. Link each post with {% url 'blog:post_detail' post.pk %}.

✅ Sample solution
{# base.html #}
<!DOCTYPE html>
<html lang="en">
<head><title>{% block title %}My Blog{% endblock %}</title></head>
<body>
    <main>{% block content %}{% endblock %}</main>
    <footer>&copy; 2026 My Blog</footer>
</body>
</html>

{# blog/post_list.html #}
{% extends "base.html" %}
{% block title %}Latest Posts — {{ block.super }}{% endblock %}
{% block content %}
    <h1>Latest Posts</h1>
    <ul>
    {% for post in posts %}
        <li><a href="{% url 'blog:post_detail' post.pk %}">{{ post.title }}</a></li>
    {% empty %}
        <li>No posts yet.</li>
    {% endfor %}
    </ul>

    <nav class="pagination">
        {% if page_obj.has_previous %}
            <a href="?page={{ page_obj.previous_page_number }}">← Prev</a>
        {% endif %}
        <span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
        {% if page_obj.has_next %}
            <a href="?page={{ page_obj.next_page_number }}">Next →</a>
        {% endif %}
    </nav>
{% endblock %}

🎯 Quick Quiz

Question 1: How do you call the no-argument method get_full_name() in a template?

Question 2: What does autoescaping protect against by default?

Question 3: Which tag lets a child block reuse the content defined in its parent block?

Best Practices

✅ Do

  • Use template inheritance for a single base layout; extract repeated chunks into {% include %} partials.
  • Generate links and asset paths with {% url %} and {% static %} — never hardcode.
  • Keep logic thin: compute in the view/model, present in the template.
  • Namespace app templates in a subfolder (blog/post_list.html) to avoid collisions.
  • Prefix private partials with an underscore (_post_card.html) as a readability convention.

⚠️ Don't

  • Don't pile complex boolean logic into {% if %} — expose a single computed flag from the view.
  • Don't apply |safe to user-generated content.
  • Don't forget {% csrf_token %} inside every POST <form>.
  • Don't repeat markup across pages when a base template or partial would do.

Summary & Quiz

🎉 Key Takeaways

  • Templates render a context dictionary into HTML; DTL is deliberately limited to keep logic out.
  • Variables use flexible dot lookup; filters transform values and can be chained.
  • Tags add control flow; use {% url %} and {% static %} instead of hardcoded paths.
  • Inheritance (extends/block/block.super/include) keeps markup DRY.
  • Autoescaping guards against XSS; use safe only for trusted HTML, and add custom filters when built-ins fall short.

📚 Further Reading

🚀 What's Next?

You can now display data beautifully — but real apps also collect it. The next lesson covers the Django Forms System: defining forms and ModelForms, validating input, and wiring them back into the views and templates you've just mastered.

🎉 Nicely done!

Views, generics, and now templates — you can move data from the database all the way to a polished page. On to collecting input with forms.