Templates and the Django template language

Render HTML from views, organise layouts with inheritance, and use the tags, filters and context processors that keep presentation out of Python.

Templates and where Django finds them

A view decides what data the response contains; a template decides how it is written out. The template language is deliberately weaker than Python: no function calls with arguments, no imports, no arbitrary expressions. That limit is the feature — template authors get a safe sandbox and business logic stays in views, models and services.

# blog/views.py
from django.shortcuts import render, get_object_or_404
from .models import Post

def post_detail(request, slug):
    post = get_object_or_404(Post.objects.select_related("author"), slug=slug)
    return render(request, "blog/post_detail.html", {
        "post": post,
        "related": post.category.posts.exclude(pk=post.pk)[:5] if post.category else [],
    })
# config/settings.py
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],      # project-wide templates
        "APP_DIRS": True,                      # also look in <app>/templates/
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]
  • render(request, name, context) is loader.get_template plus HttpResponse. Returning the rendered string directly works too, but you lose the request-bound context processors.
  • With APP_DIRS on, Django searches every app in INSTALLED_APPS in order. Two apps with templates/index.html collide silently — always namespace as templates/<app>/index.html.
  • RequestContext is what makes {{ request }} and {{ user }} available without passing them explicitly.

Inheritance and the everyday tags

{# templates/base.html #}
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
  <title>{% block title %}Blog{% endblock %}</title>
  <link rel="stylesheet" href="{% static 'css/app.css' %}">
</head>
<body>
  {% include "blog/_nav.html" %}
  <main>
    {% block content %}{% endblock %}
  </main>
</body>
</html>
{# templates/blog/post_detail.html #}
{% extends "base.html" %}

{% block title %}{{ post.title }} · Blog{% endblock %}

{% block content %}
  <article>
    <h1>{{ post.title }}</h1>
    <p>By {{ post.author.get_full_name|default:post.author.username }}</p>
    {{ post.body|linebreaks }}

    {% if post.category %}
      <p>Filed under
        <a href="{% url 'blog:category' slug=post.category.slug %}">{{ post.category.name }}</a>
      </p>
    {% endif %}
  </article>

  <h2>Related</h2>
  <ul>
    {% for item in related %}
      <li><a href="{{ item.get_absolute_url }}">{{ item.title }}</a></li>
    {% empty %}
      <li>Nothing else in this category yet.</li>
    {% endfor %}
  </ul>

  {% with total=related|length %}
    <p>{{ total }} related post{{ total|pluralize }}.</p>
  {% endwith %}
{% endblock %}
TagWhat it does
{% extends %}Declares the parent template; must be the first tag in a child
{% block %}A named override point; {{ block.super }} keeps the parent content
{% include %}Renders another template with the current context
{% url %}Reverses a named route: {% url 'blog:detail' pk=post.pk %}
{% for %}{% empty %}Loop with an explicit no-items branch
{% if %}Supports and, or, not, in, comparisons — not parentheses
{% csrf_token %}Hidden token required by every POST form
{% static %}URL for a file under STATIC_URL (requires {% load static %})

Filters, custom tags and context processors

# blog/templatetags/blog_extras.py
from django import template
from django.utils.html import format_html

register = template.Library()

@register.filter
def reading_time(text):
    words = len(str(text).split())
    return max(1, round(words / 200))

@register.simple_tag(takes_context=True)
def active_class(context, name):
    request = context.get("request")
    return "active" if request and request.resolver_match.url_name == name else ""

@register.inclusion_tag("blog/_post_card.html")
def post_card(post, show_excerpt=True):
    return {"post": post, "show_excerpt": show_excerpt}

@register.filter
def badge(label):
    # only use format_html on values you built yourself, never on user input
    return format_html('<span class="badge">{}</span>', label)
{% load blog_extras %}
{{ post.body|reading_time }} min read
<span class="nav-link {% active_class 'list' %}">Posts</span>
{% post_card post show_excerpt=False %}          {# keyword arguments #}
{{ post.title|truncatechars:40 }} {{ post.published_at|date:"j M Y" }}
  • Filters are pure functions of one value (plus optional arguments) — value|filter:arg. Keep them small and side-effect free.
  • A context processor returns a dict merged into every template context. Use it for genuinely global data (site name, navigation, feature flags), not as a way to avoid writing views.
  • Custom tags only load after {% load %}. Put the module in <app>/templatetags/, ship an __init__.py, and restart the server when you add a tag.
⚠️
Django auto-escapes {{ value }}, and |safe, mark_safe() and {% autoescape off %} switch that protection off. Applied to anything a user can type, they turn a template into a stored-XSS hole. Escape by default, and if you must build HTML in Python, pass values through format_html() so the arguments are escaped for you.

FAQ

My variable renders as nothing — why no error?
The template language fails silently on unknown names: a missing key, a missing attribute and an empty value all render as an empty string. Check the context in the view or the shell, and use {{ value|default:'-' }} so an absence is visible instead of invisible.
Can I call a Python method from a template?
You can call a no-argument method or read a property — {{ post.publish }} — and Django adds the parentheses for you. Anything needing arguments belongs in the view, a filter, or a simple_tag.

Static and media files Authentication, users and permissions

Last refreshed 2026-09-18.