Django Basics #5: Templates and Static Files
In #4 URL and Views, the views returned HTML through calls like render(request, "blog/post_list.html", {"posts": posts}). This post unpacks what that HTML really is — Django template syntax, and how the static files (CSS, JS, images) the templates reference are handled.
Template directory structure #
Django looks for templates in two places — each app’s templates/ and the project-level templates/.
myblog/
├── config/
│ └── settings.py
├── templates/ # project-wide (base.html, etc.)
│ └── base.html
└── blog/
├── views.py
└── templates/
└── blog/ # one more level deep, by convention
├── post_list.html
└── post_detail.htmlThe reason for nesting the app name one level deeper — blog/templates/blog/post_list.html — is to avoid name collisions with templates in other apps. If two apps both have post_list.html, you don’t know which one wins.
TEMPLATES setting in settings.py
#
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"], # shared template directory
"APP_DIRS": True, # auto-search each app's templates/
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]DIRS for shared directories, APP_DIRS=True for auto-search inside each app.
Variable output — {{ }}
#
<h1>{{ post.title }}</h1>
<p>Author: {{ post.author.username }}</p>
<p>Created: {{ post.created_at }}</p>
<div>{{ post.content }}</div>{{ var }} outputs a variable. Dot notation is tried in this order:
- dict key (
var["title"]) - object attribute (
var.title) - method (
var.title()) — must be callable without arguments - list index (
var[0])
This automatic resolution is why templates only allow simple expressions — no Python code directly. That’s the Django template philosophy.
Auto-escape — XSS defense by default #
Django templates automatically HTML-escape every value rendered with {{ }}. If a variable contains <script>alert(1)</script>, it’s converted to <script>....
To turn it off (for example, pre-sanitized HTML), use the |safe filter:
{{ post.content_html|safe }}Use |safe only on data you truly trust. Slapping it on user input creates an XSS vulnerability.
Tags — {% %}
#
Tags handle logic. The most-used ones.
{% if %}
#
{% if post.is_published %}
<span class="badge">Published</span>
{% elif post.status == "draft" %}
<span class="badge">Draft</span>
{% else %}
<span class="badge">Other</span>
{% endif %}
{% if posts %}
<p>{{ posts|length }} total</p>
{% else %}
<p>No posts yet.</p>
{% endif %}Comparison operators — ==, !=, <, >, <=, >=, in, not in, is, is not. Logical operators — and, or, not.
{% for %}
#
<ul>
{% for post in posts %}
<li>
<a href="{% url 'blog:post_detail' post.id %}">{{ post.title }}</a>
<small>by {{ post.author.username }}</small>
</li>
{% empty %}
<li>No posts to show.</li>
{% endfor %}
</ul>{% empty %} branches when the collection is empty. Used very often.
Variables available inside the loop:
| Variable | Meaning |
|---|---|
forloop.counter | 1-based index |
forloop.counter0 | 0-based index |
forloop.first | True on the first iteration |
forloop.last | True on the last iteration |
forloop.revcounter | counts from end down to 1 |
{% for post in posts %}
<div class="{% if forloop.first %}featured{% endif %}">
{{ forloop.counter }}. {{ post.title }}
</div>
{% endfor %}{% url %}
#
<a href="{% url 'blog:post_list' %}">List</a>
<a href="{% url 'blog:post_detail' post.id %}">{{ post.title }}</a>
<a href="{% url 'blog:post_detail' post_id=post.id %}">{{ post.title }}</a>The template version of named URLs from #4.
{% include %}
#
{% include "blog/_post_card.html" %}
{% include "blog/_post_card.html" with post=post show_author=True %}Reuse partial templates. The convention for partials is to start the filename with _.
{% csrf_token %}
#
A tag that must always be in forms.
<form method="post">
{% csrf_token %}
<input name="title" />
<button>Save</button>
</form>If you forget it, the request is blocked with 403 Forbidden. Details in #6.
Filters — |
#
Filters transform variables. {{ value|filter }} or {{ value|filter:"arg" }}.
{{ post.title|upper }} {# uppercase #}
{{ post.title|lower }}
{{ post.title|truncatechars:50 }} {# cut at 50 chars and ... #}
{{ post.title|truncatewords:10 }}
{{ post.created_at|date:"Y-m-d H:i" }} {# date format #}
{{ post.created_at|timesince }} {# "3 days ago" #}
{{ post.content|linebreaks }} {# newlines → <p>, <br> #}
{{ post.content|striptags }} {# strip HTML tags #}
{{ posts|length }}
{{ post.content|default:"No content" }}
{{ value|yesno:"yes,no,maybe" }}
{{ price|floatformat:2 }} {# 1234.567 → 1234.57 #}
{{ name|capfirst }} {# only first letter uppercase #}
{{ posts|first }}
{{ posts|last }}
{{ posts|slice:":5" }}Filters can be chained — {{ post.title|truncatechars:50|upper }}.
Template inheritance — {% extends %} / {% block %}
#
A pattern that keeps the common skeleton of a page (header, footer, sidebar) in one place and fills in only the parts that change per page.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% block title %}My Blog{% endblock %}</title>
{% load static %}
<link rel="stylesheet" href="{% static 'css/main.css' %}">
</head>
<body>
<header>
<a href="{% url 'blog:post_list' %}">Home</a>
{% if user.is_authenticated %}
<span>{{ user.username }}</span>
<a href="{% url 'logout' %}">Logout</a>
{% else %}
<a href="{% url 'login' %}">Login</a>
{% endif %}
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>
<p>© 2026 My Blog</p>
</footer>
</body>
</html>{% extends "base.html" %}
{% block title %}Posts - My Blog{% endblock %}
{% block content %}
<h1>Posts</h1>
<ul>
{% for post in posts %}
<li>
<a href="{% url 'blog:post_detail' post.id %}">{{ post.title }}</a>
<small>{{ post.created_at|date:"Y-m-d" }}</small>
</li>
{% empty %}
<li>No posts yet.</li>
{% endfor %}
</ul>
{% endblock %}Rules:
- The first line of a child template is
{% extends "..." %} - The parent’s
{% block name %}...{% endblock %}is filled by a same-named block in the child - If the child leaves a block empty, the parent’s default is shown as-is
- Inside a block,
{{ block.super }}lets you combine the parent’s content with the child’s
Multi-level inheritance #
base.html (site skeleton)
↑
blog/_layout.html (blog-wide, adds sidebar)
↑
blog/post_list.html (the actual page)Each level extends the one above it. Be careful not to over-nest.
Static files — CSS, JS, images #
settings and directories #
STATIC_URL = "static/"
# Extra search paths during development
STATICFILES_DIRS = [
BASE_DIR / "static",
]
# Output location for collectstatic in production
STATIC_ROOT = BASE_DIR / "staticfiles"Each app’s static/ directory is also auto-searched (when django.contrib.staticfiles is enabled — the default).
myblog/
├── static/ # project-wide
│ └── css/
│ └── main.css
└── blog/
└── static/
└── blog/ # app name once more (same convention as templates)
├── css/
│ └── post.css
└── images/
└── logo.png{% static %} in templates
#
{% load static %}
<link rel="stylesheet" href="{% static 'css/main.css' %}">
<link rel="stylesheet" href="{% static 'blog/css/post.css' %}">
<img src="{% static 'blog/images/logo.png' %}" alt="logo">
<script src="{% static 'blog/js/post.js' %}"></script>You must declare {% load static %} at the top of the template once. Usually near the top of base.html’s <head>.
Production deployment — collectstatic
#
runserver (the dev server) auto-serves static files. In production, collectstatic gathers all static files into one directory (STATIC_ROOT), and a web server like Nginx serves them directly.
uv run python manage.py collectstaticWhat this command does:
- Copies files from every app’s
static/and fromSTATICFILES_DIRSintoSTATIC_ROOT - If a name collides, the file from the first search path wins
The detailed production flow (WhiteNoise, CDN, manifest storage) is in Intermediate #6 Static/Media in production.
Media files — user uploads #
STATIC is developer-authored files (CSS, JS); MEDIA is user-uploaded files (profile images, attachments). They’re different places.
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
# ...
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)Don’t serve user uploads directly in production — use external storage like S3. Also covered in Intermediate #6.
Django templates vs Jinja2 — one-line comparison #
Django templates are a deliberately limited language. You can’t write Python code directly, and arbitrary function calls are hard (only the tags/filters you {% load %}). That’s the way it preserves security and designer-friendliness.
Jinja2 is more powerful — almost any Python expression works inside the template, which also means business logic leaks into templates more easily.
You can use Jinja2 as a backend in Django (change BACKEND in TEMPLATES), but Admin, Form, and other built-ins assume Django templates, so you end up mixing both. Sticking with Django templates is the safer default to start with.
Custom tags / filters #
If you have an expression you use often, you can build your own.
from django import template
register = template.Library()
@register.filter
def markdown_to_html(value: str) -> str:
import markdown
return markdown.markdown(value)
@register.simple_tag
def current_year():
from datetime import datetime
return datetime.now().year{% load blog_extras %}
<div>{{ post.content|markdown_to_html|safe }}</div>
<footer>© {% current_year %}</footer>A templatetags/ directory with an __init__.py is required. Without it, Django won’t find the tags.
Recap #
What this post nailed down:
- Template locations — per-app
templates/<app>/...+ project-widetemplates/ TEMPLATESsettings:DIRS/APP_DIRS- Variables —
{{ }}, automatic HTML escaping (XSS defense) - Tags —
{% if %},{% for %}(with{% empty %}),{% url %},{% include %},{% csrf_token %} - Filters —
|date,|truncatechars,|linebreaks,|safe, … - Template inheritance —
{% extends %},{% block %},{{ block.super }} - Static files —
STATIC_URL,STATICFILES_DIRS,{% load static %},{% static %} collectstaticis for production- Media files are separate (
MEDIA_URL/MEDIA_ROOT) - Django templates are deliberately limited — security/simplicity
- Custom tags/filters —
templatetags/
In the next post (#6 Forms and ModelForm), you’ll handle forms inside these templates the standard way — using Form / ModelForm to validate, manage CSRF, and display errors all at once.