Yesterday I covered routes, requests, and responses. Returning plain strings and JSON is fine for APIs, but for actual web pages, you need HTML, and writing HTML inside Python strings is a nightmare. Today, I covered Flask's templating system, which uses Jinja2, and learned how to serve static files. Coming from Django, this felt immediately familiar because Django's template language is also based on Jinja2.
How Flask Finds Templates
Flask looks for templates in a templates/ folder in the same directory as your app file. Create it:
flask-app/
app.py
templates/
index.html
static/
css/
style.css
This is the same convention as Django: a templates/ folder with your HTML files inside it.
Rendering a Template
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def home():
return render_template('index.html')
render_template() is Flask's equivalent of Django's render(). It finds the template file, renders it, and returns the HTML as a response.
Passing Data to Templates
Pass variables as keyword arguments to render_template:
@app.route('/profile/<username>')
def profile(username):
user = {
'username': username,
'role': 'Developer',
'location': 'New York',
'skills': ['Python', 'Flask', 'Django', 'JavaScript']
}
return render_template('profile.html', user=user, title='Profile')
<!-- templates/profile.html -->
<h1>{{ user.username }}</h1>
<p>{{ user.role }} from {{ user.location }}</p>
<ul>
{% for skill in user.skills %}
<li>{{ skill }}</li>
{% endfor %}
</ul>
If you've used Django templates, this is identical. {{ }} for outputting variables, {% %} for logic. That's because Django's template language was inspired by Jinja2.
Jinja2 Syntax
Variables
{{ variable }}
{{ user.name }}
{{ user['name'] }}
{{ items[0] }}
Both dot notation and bracket notation work for accessing dictionary keys and object attributes.
Filters
Filters modify variables inline using the | pipe character:
{{ name|upper }} <!-- HARIS -->
{{ name|lower }} <!-- haris -->
{{ name|title }} <!-- Haris -->
{{ description|truncate(100) }} <!-- truncates to 100 chars -->
{{ items|length }} <!-- count of items -->
{{ price|round(2) }} <!-- rounds to 2 decimal places -->
{{ html_content|safe }} <!-- render HTML without escaping -->
{{ name|default('Anonymous') }} <!-- fallback if variable is None -->
These are almost identical to Django template filters. The main difference is that Jinja2 filters can take arguments directly, truncate(100), whereas Django uses colons: truncatewords:10.
If / Else
{% if user.is_authenticated %}
<p>Welcome, {{ user.username }}</p>
{% elif user.is_guest %}
<p>Welcome, Guest</p>
{% else %}
<p>Please log in</p>
{% endif %}
For Loops
{% for job in jobs %}
<div>
<h2>{{ job.title }}</h2>
<p>{{ job.company }}</p>
</div>
{% else %}
<p>No jobs found.</p>
{% endfor %}
The {% else %} on a for loop renders when the list is empty, same as Django's {% empty %}. Different keyword, same concept.
Loop Variables
Jinja2 provides a loop object inside every for loop:
{% for item in items %}
<p>{{ loop.index }} — {{ item }}</p> <!-- 1-based index -->
<p>{{ loop.index0 }} — {{ item }}</p> <!-- 0-based index -->
{% if loop.first %}<strong>{% endif %}
{{ item }}
{% if loop.first %}</strong>{% endif %}
{% if loop.last %}<hr>{% endif %} <!-- after last item -->
{% endfor %}
loop.index, loop.first, loop.last, loop.length, these don't exist in Django's template language. Jinja2 is more powerful here.
Comments
{# This is a Jinja2 comment — not rendered in HTML #}
Template Inheritance
This is where Jinja2 really shines. You define a base template with blocks that child templates can fill in.
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% block title %}My Flask App{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<nav>
<a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('about') }}">About</a>
</nav>
<main>
{% block content %}{% endblock %}
</main>
<footer>
<p>Built with Flask</p>
</footer>
</body>
</html>
<!-- templates/index.html -->
{% extends 'base.html' %}
{% block title %}Home — My Flask App{% endblock %}
{% block content %}
<h1>Welcome</h1>
<p>This is the home page.</p>
{% endblock %}
{% extends %} and {% block %} work exactly like Django's template inheritance. Same keywords, same concept, same behavior. This is the biggest area of overlap between the two.
super()
In Jinja2, you can call {{ super() }} inside a block to include the parent block's content and then add to it:
{% block content %}
{{ super() }}
<p>Additional content added by this page.</p>
{% endblock %}
Django doesn't have a direct equivalent of this.
Including Templates
{% include %} inserts another template inline:
<!-- templates/base.html -->
{% include 'partials/navbar.html' %}
{% include 'partials/footer.html' %}
<!-- templates/partials/navbar.html -->
<nav>
<a href="{{ url_for('home') }}">Home</a>
</nav>
Good for components you repeat across multiple templates: navbars, footers, sidebars. Same as Django's {% include %}.
Macros — Reusable Template Components
Jinja2 has macros: reusable template functions. Django doesn't have an equivalent.
<!-- templates/macros.html -->
{% macro render_field(field, label) %}
<div class="field">
<label>{{ label }}</label>
<input type="text" name="{{ field }}" placeholder="{{ label }}">
</div>
{% endmacro %}
<!-- templates/form.html -->
{% from 'macros.html' import render_field %}
<form method="post">
{{ render_field('username', 'Username') }}
{{ render_field('email', 'Email') }}
<button type="submit">Submit</button>
</form>
Macros are like Python functions but for template markup. Define once, use anywhere. Very useful for form fields, cards, and repeated UI components.
Static Files
Static files — CSS, JavaScript, images — go in the static/ folder:
flask-app/
static/
css/
style.css
js/
main.js
images/
logo.png
Reference them in templates using url_for('static', filename='...'):
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Logo">
url_for('static', filename='path') generates the correct URL automatically. In Django this was {% load static %} then {% static 'path' %}. Flask's approach is simpler, no loading required, just url_for.
Template Context — Global Variables
Sometimes you want a variable available in every template without passing it manually every time. Flask's context_processor handles this:
@app.context_processor
def inject_globals():
return {
'app_name': 'My Flask App',
'current_year': 2025,
}
Now {{ app_name }} and {{ current_year }} are available in every template automatically. In Django this was done with context processors in settings.py. Flask's version is a decorator on a function, simpler to set up.
Escaping — Security by Default
Jinja2 escapes HTML by default. If a variable contains <script>alert('xss')</script>, Jinja2 renders it as the literal text, not as executable JavaScript. This prevents XSS attacks automatically.
When you actually want to render HTML, for example, a blog post body stored as HTML — use the safe filter:
{{ post.content|safe }}
Only use safe on content you trust completely. Never on user-submitted data.
Jinja2 vs Django Template Language — Key Differences
| Feature | Django | Jinja2/Flask |
|---|---|---|
| Variable output | {{ var }} |
{{ var }} |
| Tags | {% tag %} |
{% tag %} |
| Filters | `\ | filter` |
| Filter arguments | truncatewords:10 |
truncate(10) |
| Template inheritance |
{% extends %}, {% block %}
|
identical |
| Empty loop | {% empty %} |
{% else %} on for |
| Include | {% include %} |
{% include %} |
| Reusable components | No direct equivalent | Macros |
| Call parent block | Not available | {{ super() }} |
| Loop helpers | Not available |
loop.index, loop.first etc |
| Static files |
{% load static %} + {% static %}
|
url_for('static', filename=...) |
Jinja2 is a superset of what Django templates can do. If you know Django templates, you already know most of Jinja2.
Wrapping Up
Templates in Flask feel immediately comfortable coming from Django. The syntax is nearly identical, template inheritance works the same way, and static files follow the same convention. The differences: macros, loop variables, super() are additive rather than replacements. Jinja2 is just more capable.
Thanks for reading. Feel free to share your thoughts!
Top comments (0)