When you build a website with Flask, your Python code handles the logic — fetching data, processing requests, deciding what to show. But how does that data actually become an HTML page the browser can display? The answer is Jinja2.
Jinja2 is a template engine. It lets you write HTML files with special placeholders, and Flask fills those placeholders in with real data before sending the page to the browser. The result is a dynamic page — one that can show different content each time, depending on who is visiting or what they asked for.
This guide walks through everything from your first template to advanced reusable components — all in simple, beginner-friendly English.
What Is Jinja2? (Simple Explanation)
Imagine you're writing a letter. Instead of writing a brand-new letter for every person, you use a template with blanks: "Dear _____, your order number is _____." You fill in the blanks for each person and the letter is personalised instantly.
Jinja2 works exactly like that — but for web pages. You write an HTML template with blanks (called variables), and Flask fills them in with real Python data before sending the page to the browser.
ℹ️
Good news: Jinja2 comes built into Flask. You don't need to install anything extra. When you install Flask, you get Jinja2 automatically.
Setup & Your First Template
Flask looks for templates inside a folder called templates/ in your project. That's the only rule — create that folder, put your HTML files in it, and Flask finds them automatically.
# Your project should look like this:
my_app/
├── app.py
└── templates/
├── index.html
├── about.html
└── user.html
To render a template from Flask, use the render_template() function. You pass it the filename and any data you want to send to the template.
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def home():
# Pass data to the template as keyword arguments
return render_template(
'index.html',
name='Shashank',
page_title='Welcome to Hoopsiper'
)
@app.route('/user/<int:user_id>')
def profile(user_id):
user = get_user_from_db(user_id) # fetch from database
return render_template('user.html', user=user)
Variables — Showing Python Data in HTML
In Jinja2, you put a variable inside {{ double curly braces }}. Whatever Python value you passed to render_template() under that name will appear there.
<!-- templates/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>{{ page_title }}</title>
</head>
<body>
<h1>Hello, {{ name }}!</h1>
<!-- Accessing object properties -->
<p>Email: {{ user.email }}</p>
<p>Score: {{ user.score }}</p>
<!-- Accessing dictionary values -->
<p>City: {{ settings['city'] }}</p>
<!-- You can do simple maths too -->
<p>Next year: {{ year + 1 }}</p>
</body>
</html>
⚠️
Jinja2 auto-escapes HTML. If a variable contains something like <script>alert('hack')</script>, Jinja2 will show it as plain text, not run it as code. This protects your site from XSS attacks automatically. If you trust the content and want to render raw HTML, use {{ content | safe }} — but be very careful with this.
Control Flow — if, else, and for Loops
Jinja2 uses a different syntax for logic — curly braces with a percent sign: {% %}. This is how you do if statements and loops inside your HTML.
if / elif / else — Show Different Content
Use {% if %} to show something only when a condition is true. Always end it with {% endif %}.
<!-- Show different messages based on the user's score -->
{% if score >= 90 %}
<p class="badge gold">🏆 Excellent! Score: {{ score }}</p>
{% elif score >= 60 %}
<p class="badge silver">👍 Good job! Score: {{ score }}</p>
{% else %}
<p class="badge red">Keep practising! Score: {{ score }}</p>
{% endif %}
<!-- Show a nav link only for logged-in users -->
{% if user.is_logged_in %}
<a href="/dashboard">My Dashboard</a>
<a href="/logout">Log Out</a>
{% else %}
<a href="/login">Log In</a>
<a href="/signup">Sign Up</a>
{% endif %}
for Loops — Repeat HTML for Each Item
Use {% for %} to loop through a list and generate HTML for each item. Always end with {% endfor %}. Jinja2 also gives you a special loop variable with useful info like the current index.
<!-- Loop through a list of posts -->
<ul>
{% for post in posts %}
<li>
<strong>{{ loop.index }}.</strong> <!-- 1, 2, 3... -->
<a href="/post/{{ post.id }}">{{ post.title }}</a>
<span>by {{ post.author }}</span>
</li>
{% else %}
<!-- This shows if the list is empty -->
<li>No posts yet. Be the first to write one!</li>
{% endfor %}
</ul>
<!-- Useful loop variables -->
{% for item in items %}
{% if loop.first %}<div class="first">{% endif %}
{{ item.name }} <!-- loop.index, loop.last, loop.even, loop.odd -->
{% if loop.last %}</div>{% endif %}
{% endfor %}
✅
The for...else trick: in Jinja2, a for loop can have an else block. The else runs only if the list was empty. This is perfect for showing "No results found" messages without extra if statements.
Filters — Transform Your Variables
Filters let you modify a variable before displaying it. You write them after a pipe character | inside the double curly braces. Think of it like a processing step: data goes in, formatted output comes out.
<!-- Text formatting -->
<p>{{ 'hello world' | upper }}</p> <!-- HELLO WORLD -->
<p>{{ 'HELLO WORLD' | lower }}</p> <!-- hello world -->
<p>{{ 'hello world' | title }}</p> <!-- Hello World -->
<p>{{ 'hello world' | capitalize }}</p> <!-- Hello world -->
<!-- Truncate long text with "..." at the end -->
<p>{{ post.body | truncate(100) }}</p>
<!-- Number formatting -->
<p>{{ 3.14159 | round(2) }}</p> <!-- 3.14 -->
<p>{{ 1000000 | int }}</p> <!-- 1000000 -->
<!-- List operations -->
<p>Total posts: {{ posts | length }}</p>
<p>First: {{ posts | first }}</p>
<p>Last: {{ posts | last }}</p>
<!-- Default value if variable is None or empty -->
<p>{{ user.bio | default('No bio yet.') }}</p>
<!-- Chain multiple filters -->
<p>{{ post.title | upper | truncate(50) }}</p>
Template Inheritance — Stop Repeating Yourself
Every page on your site shares the same header, navigation bar, and footer. Without inheritance, you'd copy-paste that HTML into every single template. When you need to change the nav, you'd have to edit every file. That's a nightmare.
Template inheritance solves this. You create one base template with all the shared layout, then each page just fills in the unique parts. Change the base template once, and every page updates automatically.
Step 1 — Create the Base Template
The base template defines the overall structure. Use {% block name %}{% endblock %} to mark the parts that child templates can replace.
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% block title %}Hoopsiper{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
{% block extra_css %}{% endblock %} <!-- pages can add their own CSS -->
</head>
<body>
<!-- This nav appears on EVERY page -->
<nav>
<a href="/">Home</a>
<a href="/blog">Blog</a>
<a href="/about">About</a>
</nav>
<!-- Each page fills this block with its own content -->
<main>
{% block content %}{% endblock %}
</main>
<!-- This footer appears on EVERY page -->
<footer>
<p>© 2025 Hoopsiper.com</p>
</footer>
<script src="/static/main.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
Step 2 — Create Child Templates
Each page extends the base template and fills in only its own unique content block. Everything else — the nav, head, footer — comes from the base automatically.
<!-- templates/home.html -->
{% extends 'base.html' %}
{% block title %}Home — Hoopsiper{% endblock %}
{% block content %}
<h1>Welcome, {{ name }}!</h1>
<p>Here are the latest posts:</p>
<ul>
{% for post in posts %}
<li><a href="/post/{{ post.id }}">{{ post.title }}</a></li>
{% endfor %}
</ul>
{% endblock %}
<!-- templates/post.html -->
{% extends 'base.html' %}
{% block title %}{{ post.title }} — Hoopsiper{% endblock %}
{% block content %}
<article>
<h1>{{ post.title }}</h1>
<p class="meta">By {{ post.author }} on {{ post.date }}</p>
<div class="body">{{ post.body | safe }}</div>
</article>
{% endblock %}
<!-- This block adds extra JS only on the post page -->
{% block extra_js %}
<script src="/static/comments.js"></script>
{% endblock %}
ℹ️
How it works: {% extends 'base.html' %} must be the very first line. Then you only need to fill in the blocks you want to change. Any block you skip keeps the default content from the base template (or stays empty if the base left it empty).
Includes & Macros — Reusable Components
Sometimes you have a piece of HTML — like a post card, a pagination widget, or a flash message — that you want to reuse across several different templates. Jinja2 gives you two tools for this.
{% include %} inserts one template file directly into another. It's like copy-pasting, but cleaner — you only write the HTML once.
<!-- templates/partials/nav.html -->
<nav>
<a href="/">Home</a>
<a href="/blog">Blog</a>
{% if user.is_logged_in %}<a href="/dashboard">Dashboard</a>{% endif %}
</nav>
<!-- templates/base.html — use the include here -->
<body>
{% include 'partials/nav.html' %}
{% block content %}{% endblock %}
{% include 'partials/footer.html' %}
</body>
{% macro %} is like a function in Python — you define a reusable chunk of HTML that accepts arguments. This is perfect for things like a post card that needs a title, author, and date.
<!-- templates/macros/cards.html -->
{% macro post_card(post) %}
<div class="card">
<span class="tag">{{ post.category }}</span>
<h3><a href="/post/{{ post.id }}">{{ post.title }}</a></h3>
<p>{{ post.summary | truncate(120) }}</p>
<p class="meta">{{ post.read_time }} min read · {{ post.author }}</p>
</div>
{% endmacro %}
<!-- templates/blog.html — import and use the macro -->
{% from 'macros/cards.html' import post_card %}
{% extends 'base.html' %}
{% block content %}
<div class="posts-grid">
{% for post in posts %}
{{ post_card(post) }} <!-- call the macro like a function -->
{% endfor %}
</div>
{% endblock %}
✅
include vs macro: use {% include %} when the HTML is always the same (like a nav or footer). Use {% macro %} when you need to pass in different data each time (like a card that shows different post info).
Static Files & url_for in Templates
Static files are things that never change — CSS stylesheets, JavaScript files, images, fonts. Flask serves them from a static/ folder. In templates, you should always use url_for('static', filename='...') to link to them instead of hardcoding the path.
<!-- Your static folder structure -->
# my_app/
# ├── static/
# │ ├── css/style.css
# │ ├── js/main.js
# │ └── images/logo.png
<!-- In your HTML template: -->
<head>
<!-- Link a CSS file -->
<link rel="stylesheet"
href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<!-- Show an image -->
<img src="{{ url_for('static', filename='images/logo.png') }}"
alt="Hoopsiper Logo">
<!-- Link to another page using url_for -->
<a href="{{ url_for('home') }}">Go Home</a>
<a href="{{ url_for('profile', user_id=user.id) }}">My Profile</a>
<!-- Link a JavaScript file -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
</body>
⚠️
Never hardcode paths like href="/static/css/style.css". If you ever move your app or run it under a subdirectory, hardcoded paths break. url_for() always generates the correct path automatically, no matter where your app is deployed.
Jinja2 Syntax — Quick Reference
| Syntax | What It Does | Example |
| {{ }} | Print a variable | {{ user.name }} |
| {% %} | Logic — if, for, block | {% if user %} |
| {# #} | Comment (not shown in HTML) | {# TODO fix this #} |
| | filter | Transform a value | {{ name | upper }} |
| {% extends %} | Inherit from a base template | {% extends 'base.html' %} |
| {% block %} | Define a replaceable section | {% block content %} |
| {% include %} | Insert another template file | {% include 'nav.html' %} |
| {% macro %} | Define a reusable HTML function | {% macro card(post) %} |
⚡ Key Takeaways
- Jinja2 comes built into Flask — no extra install needed. Put your templates in a templates/ folder.
- Use render_template('page.html', key=value) to send Python data to a template.
- Use {{ variable }} to display data. Use {% %} for logic like if and for. Use {# #} for comments.
- Jinja2 auto-escapes HTML — your site is protected from XSS attacks by default. Only use | safe when you truly trust the content.
- Use filters like | upper, | truncate, and | default to transform variables without touching Python code.
- Use template inheritance ({% extends %} + {% block %}) so your nav, head, and footer are written only once.
- Use {% include %} to insert reusable HTML partials. Use {% macro %} when the HTML needs different data each time.
- Always use url_for('static', filename='...') to link CSS, JS, and images — never hardcode paths.
Tags:
Jinja2
Flask
Templates
Python
HTML
Beginner
Shashank Shekhar
Founder & Creator — Hoopsiper.com
Full-stack developer and educator. Building Hoopsiper to help developers learn faster through practical, no-fluff coding guides on JavaScript, AI/ML, Python, and modern web development.