Skip to main content

📄 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.

A template plus data produces rendered HTML A template file with placeholders is combined with a Python data context by the Jinja2 engine, producing finished HTML sent to the browser. Template HTML + {{ placeholders }} Data (context) Jinja2 engine Rendered HTML to the browser
Figure 1 — Jinja2 combines a template with a data context to produce the finished HTML. The same template can render endlessly different pages just by changing the data.

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."

flowchart LR A[Browser] -->|Request| B[Flask view] B -->|render_template| C[Jinja2 Engine] C -->|Loads| D[Template File] D -->|Content + data| C C -->|Rendered HTML| B B -->|HTTP Response| A

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.

graph TD A[base.html] -->|extends| B[about.html] A -->|extends| C[index.html] A -->|extends| D[contact.html] B --> B1[block: title] B --> B2[block: content] B --> B3[block: extra_css]

✅ 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: &lt;script&gt;alert('XSS')&lt;/script&gt; #}

⚠️ 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 |safe for 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:

  1. Create templates/base.html with blocks for title and content, plus a shared header.
  2. Create templates/macros.html with a card(title, body) macro.
  3. Create templates/projects.html that extends the base, imports the macro, and loops over a list of projects, rendering each with the card macro.
  4. In your Flask view, pass a list of project dicts to the template.
  5. Bonus: apply the truncate filter 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 exposes url_for() in templates.
  • Inheritance (base + blocks), includes, and macros keep your HTML DRY and maintainable.
  • Jinja2 auto-escapes by default; use |safe only 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.