π§© Jinja2 Templating Engine
Your Flask routes return data, but users expect polished HTML pages. Jinja2 is the bridge: a designer-friendly templating language that mixes your Python data into HTML templates, keeping logic and presentation cleanly apart. In this lesson you'll learn the syntax that powers almost every Flask front end.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain what a templating engine does and how
render_template()passes context data into a template - Use Jinja2's three delimiters β
{{ }},{% %}, and{# #}β correctly - Apply filters and tests to transform and inspect variables
- Write control structures (conditionals, loops with
loopvariables) and reusable macros - Describe Jinja2's auto-escaping and why it protects against XSS
Estimated Time: 35β45 minutes β’ Difficulty: Intermediate
Hands-on: Build a dynamic product-listing template that loops, filters, and formats real data.
In This Lesson
What Is a Templating Engine?
A templating engine takes a template file β HTML with special placeholders β and a bundle of data (the context), and produces a finished HTML string. Jinja2 is Flask's built-in engine, installed automatically alongside Flask. It was created by the same team (Pallets) and is used far beyond Flask, including in tools like Ansible.
π‘ A useful analogy: A template is a form letter. The wording stays the same for everyone ("Dear ____, your order #____ has shipped"), and the engine fills the blanks from each customer's record. Write the letter once, mail thousands of personalized copies.
The big win is separation of concerns: your Python view functions stay focused on fetching and shaping data, while your .html templates focus on presentation. A designer can edit a template without touching Python, and you can restructure a route without breaking the markup.
Rendering Templates from Flask
By convention, Flask looks for templates in a folder named templates/ next to your application code, and static assets (CSS, JS, images) in static/:
myapp/
βββ app.py
βββ static/
β βββ css/
β βββ style.css
βββ templates/
βββ base.html
βββ home.html
βββ products.html
To render a template, import render_template and return its result from a view. Any keyword arguments you pass become variables inside the template. This example uses the modern application factory pattern so the app is easy to configure and test:
from flask import Flask, render_template
def create_app():
app = Flask(__name__)
@app.route("/")
def home():
return render_template("home.html", title="Home")
@app.route("/user/<username>")
def profile(username):
# In a real app you'd load this from a database
user = {"name": username.title(), "is_admin": False}
return render_template("profile.html", user=user, title=f"{user['name']}'s Profile")
return app
if __name__ == "__main__":
create_app().run(debug=True)
π Key Terms
Template: an HTML file containing Jinja2 placeholders, stored in templates/.
Context: the dictionary of variables you pass to render_template().
Rendering: the act of merging context into the template to produce a final HTML string.
The Three Delimiters
Everything in Jinja2 falls into one of three kinds of markers. Memorize these and the rest is detail:
| Delimiter | Purpose | Example |
|---|---|---|
{{ ... }} | Expressions β print a value into the output | {{ user.name }} |
{% ... %} | Statements β logic like if, for, set | {% if user.is_admin %} |
{# ... #} | Comments β stripped from the output | {# TODO: paginate #} |
Here is a small but complete template that uses all three, plus variable access and a conditional:
<!DOCTYPE html>
<html lang="en">
<head><title>{{ title }}</title></head>
<body>
{# Greet the visitor by name #}
<h1>Hello, {{ user.name }}!</h1>
{% if user.is_admin %}
<p>You have admin privileges.</p>
{% else %}
<p>Welcome back.</p>
{% endif %}
</body>
</html>
Notice that user.name works whether user is a dictionary (user["name"]) or an object with a .name attribute β Jinja2 tries attribute access first, then item access. Both {{ user.name }} and {{ user['name'] }} are valid.
β οΈ Statements don't print, expressions do
A common beginner slip is writing {{ if x %} or {% user.name %}. Remember: {% %} runs logic (no output on its own), {{ }} prints a value. Mixing them up is the source of most "why is nothing showing?" bugs.
Filters & Tests
Filters transform a value
A filter modifies a value using the pipe symbol (|). Filters chain left to right, so {{ name|trim|title }} trims whitespace first, then title-cases the result.
{{ name|upper }} <!-- HELLO -->
{{ name|title }} <!-- Hello World -->
{{ price|round(2) }} <!-- 19.99 -->
{{ items|length }} <!-- 3 -->
{{ tags|join(", ") }} <!-- python, flask, web -->
{{ bio|truncate(100) }} <!-- first 100 chars⦠-->
{{ nickname|default("Guest") }} <!-- fallback if undefined -->
π‘ Format dates with a real filter
Jinja2 has no built-in date formatter. Rather than invent one, prefer to pass an already-formatted string from Python, or register a small custom filter:
@app.template_filter("datetimeformat")
def datetimeformat(value, fmt="%B %d, %Y"):
return value.strftime(fmt)
Then in a template: {{ post.created_at|datetimeformat }} β March 10, 2026.
Tests inspect a value
A test checks a condition and returns true or false, used after the is keyword:
{% if name is defined %}...{% endif %} {# variable exists #}
{% if value is none %}...{% endif %} {# is None #}
{% if count is divisibleby(3) %}...{% endif %}
{% if items is iterable %}...{% endif %}
Control Structures & Loops
Conditionals
{% if cart|length == 0 %}
<p>Your cart is empty.</p>
{% elif cart|length == 1 %}
<p>You have 1 item.</p>
{% else %}
<p>You have {{ cart|length }} items.</p>
{% endif %}
Loops and the loop variable
Inside a {% for %} block, Jinja2 gives you a special loop object with handy attributes. Note the {% else %} clause, which runs when the collection is empty:
<ul>
{% for product in products %}
<li class="{{ loop.cycle('row-odd', 'row-even') }}">
{{ loop.index }}. {{ product.name }} β ${{ product.price }}
</li>
{% else %}
<li>No products available.</li>
{% endfor %}
</ul>
loop attribute | Meaning |
|---|---|
loop.index / loop.index0 | Current iteration, 1-based / 0-based |
loop.first / loop.last | True on the first / last pass |
loop.length | Total number of items |
loop.cycle(a, b, β¦) | Rotate through the given values each pass |
Macros & the set Tag
Setting local variables
{% set greeting = "Hello" %}
<p>{{ greeting }}, {{ user.name }}!</p>
Macros: reusable template functions
A macro is like a function for markup β define a chunk of HTML once, call it with different arguments. This is the DRY (Don't Repeat Yourself) tool for repeated UI like form fields or cards:
{% macro input(name, label, type="text", value="") %}
<div class="form-group">
<label for="{{ name }}">{{ label }}</label>
<input type="{{ type }}" id="{{ name }}" name="{{ name }}" value="{{ value }}">
</div>
{% endmacro %}
{{ input("username", "Username") }}
{{ input("password", "Password", type="password") }}
You can keep macros in their own file and import them where needed:
{% from "macros/forms.html" import input %}
{{ input("email", "Email address", type="email") }}
β When to reach for a macro
Use a macro whenever you catch yourself copy-pasting the same block of markup with small variations β form fields, star ratings, product cards, alert boxes. One definition, many calls, one place to fix.
Auto-Escaping & Security
By default in Flask, Jinja2 auto-escapes every value printed in an HTML template. That means characters like <, >, and & are converted to their safe HTML entities. This is your first line of defense against Cross-Site Scripting (XSS) β an attack where a malicious user injects <script> tags through form input.
What escaping does
If comment = "<script>alert('hacked')</script>", then {{ comment }} renders as harmless text:
<script>alert('hacked')</script>
Occasionally you have HTML you truly trust (say, output from a sanitized Markdown renderer) and want it rendered as markup. The |safe filter disables escaping for that value:
<div class="content">{{ article_html|safe }}</div>
β οΈ |safe is a loaded gun
Never apply |safe to anything a user typed unless it has been run through a trusted HTML sanitizer first. Marking untrusted input as safe re-opens the exact XSS hole auto-escaping was closing.
Hands-on Exercise
ποΈ Build a Product Listing Template
Objective: Render a list of products with looping, filtering, and a conditional empty state.
Instructions:
- Create a Flask app (factory pattern) with a
/shoproute that passes this context toshop.html:products = [ {"name": "Keyboard", "price": 49.99, "in_stock": True}, {"name": "Mouse", "price": 19.5, "in_stock": False}, {"name": "Monitor", "price": 199.0, "in_stock": True}, ] - In
shop.html, loop overproductsand show each name and price. - Use
{{ product.price|round(2) }}and title-case the name with|title. - Add a badge that reads "Out of stock" only when
in_stockis false. - Add a
{% else %}clause that shows "No products yet" for an empty list.
π‘ Hint
The empty-list case goes on the for loop itself, not a separate if: {% for p in products %} β¦ {% else %} β¦ {% endfor %}. For the badge, wrap it in {% if not product.in_stock %}.
β Sample solution
<ul class="products">
{% for product in products %}
<li>
<strong>{{ product.name|title }}</strong> β ${{ product.price|round(2) }}
{% if not product.in_stock %}
<span class="badge">Out of stock</span>
{% endif %}
</li>
{% else %}
<li>No products yet.</li>
{% endfor %}
</ul>
π― Quick Quiz
Question 1: Which delimiter prints a variable's value into the rendered HTML?
Question 2: What does Jinja2's auto-escaping protect you from?
Question 3: Inside a {% for %} loop, which variable tells you the current 1-based iteration number?
Summary & Quiz
π Key Takeaways
- Jinja2 merges a template with context data to produce HTML;
render_template()is how Flask calls it. - Three delimiters:
{{ }}prints,{% %}runs logic,{# #}comments. - Filters (
|) transform values; tests (is) inspect them. - Loops expose a
loopobject; a{% else %}clause handles empty collections. - Macros keep repeated markup DRY; auto-escaping guards against XSS, and
|safemust be used with great care.
π Further Reading
- Jinja2 β Template Designer Documentation
- Flask β Templates guide
- Jinja2 β Built-in filters reference
π What's Next?
You now write single templates fluently. Next we'll tackle template inheritance and includes β the technique that lets a whole site share one layout so you never copy a navbar or footer again.
π Well done!
Dynamic HTML is now in your toolkit. Let's make it maintainable across an entire site.