Jinja2 Templates
Every render_template() call we've used so far hands a template file and some
data over to Jinja2, the templating engine Flask uses by default. Jinja2's
job is to take an HTML file sprinkled with small placeholders and logic tags, combine it
with the Python values your view passed in, and produce the final HTML that gets sent to
the browser. This lesson covers Jinja2's own mini-language: variable output, its core tags,
its built-in filters, and how data travels from a view into a template in the first place.
Jinja2: Deliberately Simpler Than Python
Jinja2's syntax looks a little like Python — it has if, for, and
expressions — but it is intentionally a much smaller language. You cannot define a class in
a template, import an arbitrary module, or run a multi-line function. This isn't a
limitation to work around; it's a design choice. Templates are meant to describe
presentation — how data is arranged and displayed — while business logic
(querying the database, deciding what a "reading" book even means, checking permissions)
belongs in your view functions. Keeping that boundary enforced by the language itself
means a template can never quietly grow into a second copy of your application logic; if
you find yourself wanting to write something Jinja2 makes hard, that's usually a sign the
computation belongs in the view instead.
The templates/ Folder Convention
render_template() looks for files inside a folder named templates/
sitting next to your application module — no configuration needed for that default location
to work. A typical Bookshelf project organizes it by feature:
bookshelf/
app.py
templates/
base.html
books/
list.html
detail.html
add.html
partials/
book_card.html
errors/
404.html
500.html
Passing 'books/list.html' to render_template() refers to that
path relative to templates/ — the subfolders are just organization and carry
no special meaning to Flask beyond that.
Outputting Variables With {{ }}
Double curly braces print a value into the HTML. Anything passed into
render_template() as a keyword argument becomes available under that name
inside the template, and dotted attribute access works the same way it does on the
underlying Python object:
<p>{{ book.title }}</p>
<p>Shelf: {{ book.shelf.name }}</p>
book.shelf.name walks from the Book instance to its related
Shelf (via the shelf_id foreign key relationship) and prints that
shelf's name — Jinja2 follows attribute access exactly like Python does,
it's just written with dots instead of separate lookups.
Core Template Tags Reference
Logic in a template — conditionals and loops — is written between {% %}
delimiters rather than {{ }}, since these tags don't output a value directly:
| Tag | Purpose |
|---|---|
{% if %} ... {% elif %} ... {% else %} ... {% endif %} | Conditional branching, same logic as Python's if/elif/else |
{% for item in list %} ... {% endfor %} | Loop over an iterable — a list of books, tags, shelves |
loop.index | Inside a for loop, the special loop variable gives the current position — loop.index is 1-based, loop.index0 is 0-based |
Both patterns together, filtering and numbering a list of books:
{% if books %}
<ol>
{% for book in books %}
<li>{{ loop.index }}. {{ book.title }} by {{ book.author }}</li>
{% endfor %}
</ol>
{% else %}
<p>No books on this shelf yet.</p>
{% endif %}
Built-in Filters Reference
A filter transforms a value on its way to being printed, applied with a pipe character:
{{ value|filter_name }}. Filters can be chained, and some accept arguments:
| Filter | Effect |
|---|---|
{{ value|default('Untitled') }} | Prints 'Untitled' if value is undefined or falsy |
{{ book.title|upper }} | Converts text to uppercase |
{{ description|truncate(100) }} | Cuts long text to roughly 100 characters, adding an ellipsis |
{{ books|length }} | The number of items in a list — equivalent to Python's len() |
Comments
Anything between {# #} is stripped out entirely and never reaches the
rendered HTML — unlike an HTML comment (<!-- -->), which still ships to the
browser and is visible in "View Source":
{# TODO: add a "mark as finished" button here once ratings are in #}
Passing Data From A View To A Template
Every extra keyword argument to render_template() becomes a variable the
template can reference by that exact name:
# app.py
from flask import render_template
@app.route('/shelves/<int:shelf_id>')
def shelf_detail(shelf_id):
shelf = Shelf.query.get_or_404(shelf_id)
books = Book.query.filter_by(shelf_id=shelf.id).all()
return render_template('books/list.html', books=books, shelf_name=shelf.name)
Full Worked Example: books/list.html
Combining variable output, dotted lookups, the if/for tags, the
loop variable, several filters, and a comment into one real template for the
Bookshelf book list:
{# templates/books/list.html #}
<h1>{{ shelf_name|default('All Books') }}</h1>
{% if books %}
<p>{{ books|length }} book(s) on this shelf.</p>
<ul>
{% for book in books %}
<li>
{{ loop.index }}. {{ book.title|upper }} by {{ book.author }}
{% if book.rating %}
— rated {{ book.rating }}/5
{% else %}
— not rated yet
{% endif %}
<br>
<small>{{ book.author }}'s notes: {{ book.description|default('No notes yet')|truncate(100) }}</small>
</li>
{% endfor %}
</ul>
{% else %}
<p>No books on this shelf yet.</p>
{% endif %}
Passed books=books, shelf_name=shelf.name from the view above, this template
renders a numbered list, falls back to a friendly message when the shelf is empty, shows
each book's rating only when one exists, and truncates a long description — all without a
single line of that logic living inside the view function itself.