🎨 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 tags —
if,for,url,static, and theforloophelpers - Build a layout with template inheritance (
extends,block,block.super,include) - Explain autoescaping and use the
safefilter 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.
💡 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 }}
| Filter | Purpose | Example → Output |
|---|---|---|
date | Format a date/time | value|date:"D d M Y" → Thu 15 Jan 2026 |
default | Fallback when empty/false | bio|default:"—" |
truncatewords | Limit to N words | body|truncatewords:30 |
linebreaks | Plain text → paragraphs | comment|linebreaks |
pluralize | Add "s" when count ≠ 1 | {{ n }} item{{ n|pluralize }} |
yesno | Map booleans to words | active|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").
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 %}© 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 %}
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:
base.htmldefinestitleandcontentblocks and a footer.post_list.htmlsets a page-specific title and loops overposts, showing each title as a link to its detail page.- Show "No posts yet." when the list is empty.
- Below the list, add Previous/Next pagination links using the
page_objthatListViewprovides.
💡 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>© 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
|safeto 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
safeonly for trusted HTML, and add custom filters when built-ins fall short.
📚 Further Reading
- Django Docs — The template language
- Django Docs — Built-in tags & filters
- Django Docs — Custom template tags & filters
🚀 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.