📄 Jinja2 Template System
Building HTML with string concatenation in Python quickly turns into an unreadable mess. Jinja2 — Flask's built-in templating engine — separates your presentation from your logic, letting you write clean HTML files that get filled with data at render time. This lesson covers its syntax, filters, inheritance, macros, and the auto-escaping that keeps your users safe.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why templates separate presentation from business logic, and render one from a Flask view
- Use Jinja2's core syntax: variables, control structures (if/for), and comments
- Transform data in templates with filters and chain them
- Build maintainable layouts with template inheritance, includes, and macros
- Write a custom filter and explain how auto-escaping protects against XSS
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Build a base layout plus a child page that loops over data, uses a macro, and applies a custom filter.
In This Lesson
Why Templates?
Templates separate the presentation layer (HTML and layout) from the business logic (your Python code). This separation makes each side easier to maintain and lets frontend and backend work in parallel without stepping on each other.
Without templates, you end up assembling HTML by hand inside your view — which becomes unreadable and error-prone almost immediately:
# Without templates — painful to read and maintain
@app.route("/profile")
def profile():
user = get_user()
html = "<html><body>"
html += f"<h1>Welcome, {user.name}!</h1>"
html += '<div class="stats">'
for stat in user.stats:
html += f'<div class="stat">{stat.name}: {stat.value}</div>'
html += "</div></body></html>"
return html
Notice the bug hiding in plain sight: user.name is dropped straight into the page, so a name containing HTML could break the layout — or inject a script. Templates solve both the readability and the safety problem.
What Is Jinja2?
Jinja2 is a modern, designer-friendly templating language for Python, written by Armin Ronacher — the same author as Flask. It was inspired by Django's template language but is more powerful and flexible.
- Fast: templates are compiled to optimized Python bytecode, then cached.
- Safe by default: auto-escaping guards against cross-site scripting (XSS).
- Sandboxed option: untrusted templates can run in a restricted environment.
- Inheritance: a powerful block system for reusable page layouts.
- Expressive: filters, tests, macros, and rich control structures.
💡 The stencil analogy: A Jinja2 template is like an artist's stencil. The stencil defines the shape and outline (your HTML structure), but you fill it with different colours each time (your data). One stencil, unlimited unique prints — one template, unlimited pages.
Rendering in Flask
Flask wires up Jinja2 automatically. It looks for templates in a templates/ folder next to your application module. You render one with render_template(), passing data as keyword arguments:
from flask import Flask, render_template
app = Flask(__name__)
@app.route("/hello/<name>")
def hello(name):
return render_template("hello.html", name=name)
The matching template templates/hello.html:
<!DOCTYPE html>
<html>
<head><title>Hello</title></head>
<body>
<h1>Hello, {{ name }}!</h1>
<p>Welcome to our website.</p>
</body>
</html>
Visiting /hello/John renders the template with name set to "John", producing personalized HTML. The double braces {{ ... }} are Jinja2's way of saying "print this value here."
Syntax Fundamentals
Jinja2 has just three delimiters to remember:
📖 The three delimiters
{{ ... }} — expressions: print a value.
{% ... %} — statements: logic like if, for, block.
{# ... #} — comments: removed from the output.
Variables
Print values, and reach into objects or dictionaries with dot or bracket access:
{{ user.name }} {# attribute access (object) #}
{{ user['name'] }} {# key access (dict) #}
Control structures
{% if user.is_admin %}
<div class="admin">Admin options</div>
{% elif user.is_moderator %}
<div class="mod">Moderator options</div>
{% else %}
<div class="user">User options</div>
{% endif %}
<ul>
{% for item in items %}
<li>{{ loop.index }}. {{ item.name }} — ${{ item.price }}</li>
{% else %}
<li>No items found.</li>
{% endfor %}
</ul>
💡 The loop variable
Inside a {% for %} Jinja2 gives you a special loop object: loop.index (1-based), loop.index0 (0-based), loop.first, loop.last, and loop.length. The {% else %} branch runs when the collection is empty — a clean way to handle "no results."
Filters & Functions
Filters transform a value using the pipe | syntax, borrowed from Unix pipes. They read left to right and can be chained:
{{ name|capitalize }} {# Capitalize first letter #}
{{ text|truncate(100) }} {# Limit to ~100 chars #}
{{ tags|join(', ') }} {# Join a list with commas #}
{{ price|round(2) }} {# Round a number #}
{{ items|length }} {# Count items #}
{{ title|striptags|title|truncate(80) }} {# Chained #}
Jinja2 also exposes a few global functions, plus anything Flask injects — most importantly url_for():
{{ range(1, 5)|list }} {# [1, 2, 3, 4] #}
{{ url_for('product_detail', product_id=p.id) }} {# a real URL #}
Worked example — a product grid
<div class="product-grid">
{% for product in products %}
<div class="card {% if product.is_featured %}featured{% endif %}">
<h3>{{ product.name|title }}</h3>
<p>{{ product.description|truncate(100) }}</p>
{% if product.on_sale %}
<span class="was">${{ product.original_price|round(2) }}</span>
<span class="now">${{ product.sale_price|round(2) }}</span>
{% else %}
<span>${{ product.price|round(2) }}</span>
{% endif %}
<a href="{{ url_for('product_detail', product_id=product.id) }}">View</a>
</div>
{% else %}
<p>No products match your criteria.</p>
{% endfor %}
</div>
Template Inheritance
The single most valuable Jinja2 feature is inheritance. You define a base layout with named {% block %} regions, and child templates fill those blocks. Write your header, nav, and footer once; every page inherits them.
Base template — base.html
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}My Site{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block extra_css %}{% endblock %}
</head>
<body>
<header>
<nav>
<a href="{{ url_for('index') }}">Home</a>
<a href="{{ url_for('about') }}">About</a>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>© {{ current_year }} My Website</footer>
{% block extra_js %}{% endblock %}
</body>
</html>
Child template — about.html
{% extends "base.html" %}
{% block title %}About Us — My Site{% endblock %}
{% block content %}
<h1>About Our Company</h1>
<p>Founded in 2020, we build delightful software.</p>
<div class="team">
{% for member in team_members %}
<div class="member">
<h3>{{ member.name }}</h3>
<p>{{ member.role }}</p>
</div>
{% endfor %}
</div>
{% endblock %}
The child declares {% extends "base.html" %} and only supplies the blocks it cares about. Everything else — the doctype, nav, footer — comes from the base. Change the nav once in base.html and every page updates.
✅ Use super() to add, not replace
Inside a child block, {{ super() }} pulls in whatever the parent block contained, so you can append to it rather than overwrite it — handy for adding page-specific CSS while keeping the base styles.
Includes & Macros
Inheritance handles whole-page layout. For smaller repeated fragments, use includes and macros.
Includes
Drop one template into another — great for partials like a header or sidebar:
{% include 'partials/header.html' %}
{% include 'partials/sidebar.html' %}
Macros
A macro is like a function for templates: define a reusable fragment with parameters, then call it wherever you need it. This is the DRY way to render form fields, buttons, or cards:
{% macro input_field(name, label, type='text', required=False) %}
<div class="form-group">
<label for="{{ name }}">{{ label }}{% if required %} *{% endif %}</label>
<input type="{{ type }}" name="{{ name }}" id="{{ name }}"
{% if required %}required{% endif %}>
</div>
{% endmacro %}
{# Call the macro #}
{{ input_field('username', 'Username', required=True) }}
{{ input_field('email', 'Email Address', type='email', required=True) }}
{{ input_field('password', 'Password', type='password', required=True) }}
Importing macros from another file
Keep macros in a dedicated file and import them where needed:
{# forms.html #}
{% macro button(text, type='button', css='') %}
<button type="{{ type }}" class="btn {{ css }}">{{ text }}</button>
{% endmacro %}
{# some_page.html #}
{% import 'forms.html' as forms %}
{{ forms.button('Submit', type='submit', css='primary') }}
💡 Inheritance vs include vs macro
Inheritance = the overall page skeleton (one base, many children). Include = drop in a static fragment as-is. Macro = a parameterized, reusable component you call with arguments. Reach for the smallest tool that fits.
Custom Filters & Security
Writing a custom filter
Flask lets you register your own filters with the @app.template_filter decorator. A classic example is a "time since" filter that turns a timestamp into "3 hours ago":
import time
@app.template_filter("time_since")
def time_since(timestamp):
diff = time.time() - timestamp
if diff < 60:
return "just now"
if diff < 3600:
minutes = int(diff // 60)
return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
if diff < 86400:
hours = int(diff // 3600)
return f"{hours} hour{'s' if hours != 1 else ''} ago"
days = int(diff // 86400)
return f"{days} day{'s' if days != 1 else ''} ago"
Now use it in any template exactly like a built-in filter:
<span class="post-time">{{ post.created_at|time_since }}</span>
Auto-escaping and XSS
In Flask, Jinja2 auto-escapes every variable in .html templates by default. Dangerous characters are converted to HTML entities, so injected markup is displayed as text rather than executed:
{# If user.name is "<script>alert('XSS')</script>" #}
{{ user.name }}
{# Renders safely as: <script>alert('XSS')</script> #}
⚠️ Be very careful with |safe
The |safe filter disables escaping for a value, telling Jinja2 to trust it as raw HTML. Only ever use it on content you fully control or have sanitized. Applying |safe to user-supplied input reopens the exact XSS hole auto-escaping closes.
✅ Security best practices
- Never disable auto-escaping globally.
- Reserve
|safefor trusted, sanitized content (e.g. Markdown you've cleaned). - Layer on a Content Security Policy (CSP) to further limit script execution.
- Do heavy logic in Python, not in templates — templates should stay presentational.
Hands-on Exercise
🏋️ Build a Small Template System
Objective: Create a base layout and a child page that loops over data, uses a macro, and applies a filter.
Instructions:
- Create
templates/base.htmlwith blocks fortitleandcontent, plus a shared header. - Create
templates/macros.htmlwith acard(title, body)macro. - Create
templates/projects.htmlthat extends the base, imports the macro, and loops over a list of projects, rendering each with the card macro. - In your Flask view, pass a list of project dicts to the template.
- Bonus: apply the
truncatefilter to each project description.
💡 Hint
The child template starts with {% extends "base.html" %} and {% import "macros.html" as m %}. Call the macro inside the loop as {{ m.card(p.name, p.description) }}. Handle an empty list with the loop's {% else %} branch.
✅ Solution
app.py
from flask import Flask, render_template
app = Flask(__name__)
PROJECTS = [
{"name": "Portfolio Site", "description": "A personal site built with Flask and Jinja2 to showcase work."},
{"name": "Weather API", "description": "A small JSON API that proxies and caches forecast data."},
]
@app.route("/projects")
def projects():
return render_template("projects.html", projects=PROJECTS)
templates/base.html
<!DOCTYPE html>
<html>
<head><title>{% block title %}My Portfolio{% endblock %}</title></head>
<body>
<header><h1>My Portfolio</h1></header>
<main>{% block content %}{% endblock %}</main>
</body>
</html>
templates/macros.html
{% macro card(title, body) %}
<div class="card">
<h3>{{ title }}</h3>
<p>{{ body }}</p>
</div>
{% endmacro %}
templates/projects.html
{% extends "base.html" %}
{% import "macros.html" as m %}
{% block title %}Projects — My Portfolio{% endblock %}
{% block content %}
<h2>Projects</h2>
{% for p in projects %}
{{ m.card(p.name, p.description|truncate(60)) }}
{% else %}
<p>No projects yet.</p>
{% endfor %}
{% endblock %}
Run the app and visit /projects. Each project renders through the shared card macro inside the shared layout — change the header once and every page follows.
🎯 Quick Quiz
Question 1: Which Jinja2 delimiter prints a value into the output?
Question 2: A child template needs the base layout's nav and footer but its own content. Which feature does it use?
Question 3: Why is applying |safe to user-submitted content dangerous?
Summary & Quiz
🎉 Key Takeaways
- Templates separate presentation from logic and are rendered with
render_template(). - Jinja2 has three delimiters:
{{ }}expressions,{% %}statements,{# #}comments. - Filters (
|) transform values and can be chained; Flask exposesurl_for()in templates. - Inheritance (base + blocks), includes, and macros keep your HTML DRY and maintainable.
- Jinja2 auto-escapes by default; use
|safeonly on trusted, sanitized content.
📚 Further Reading
🚀 What's Next?
You can now build clean, safe, reusable HTML. But real apps need to accept input from users — and validate it. Next you'll learn form handling with Flask-WTF, adding proper validation and CSRF protection to the forms your templates render.
🎉 Excellent work!
Templates, inheritance, macros, and safety — you've got the presentation layer covered. On to forms.