Templates with Jinja2
In Phase 2 your views returned HTML by hand — strings like f"<h1>{notes[note_id]}</h1>". That works for
one line. It falls apart the moment a note page needs a real <head>, a nav bar, a loop over a list, and a
footer. Stuffing all of that into a Python f-string is how you end up with unreadable views and HTML you
can't see the shape of. This phase hands that job to Jinja2, the template engine that ships inside Flask.
Hold this mental model before any code: a view's job is to gather data; a template's job is to turn that
data into HTML. The view talks to your data (the Note objects), bundles up what it found, and passes it
to a template that knows how to lay it out. Two roles, one clean handoff. The view never builds HTML by hand;
the template never reaches into the database. Keeping those jobs apart is the whole point — it's why the same
list of notes can render as a web page today and (in Building a JSON API) as JSON
tomorrow without rewriting your data logic.
Why templates — getting HTML out of your views
📝 You don't import Jinja2 or wire it up. It's built into Flask, and you reach it through one function:
render_template. You give it a template filename and the data it needs; Flask finds the file, runs it with
your data available inside, and returns a finished response full of HTML.
=
=
return
What just happened: the view fetched its data (here a throwaway list — a real database arrives in
Working with a Database) and handed it to render_template("notes.html", notes=notes). Flask looks for notes.html, runs it with notes available inside, and returns the rendered
HTML as the response. Notice the view contains zero HTML — it only decides what to show, not how it
looks.
📝 Where does notes.html live? In a folder named templates/ next to your app file. Flask looks there
automatically — you don't configure the path:
your-app/
app.py
templates/
notes.html
note_detail.html
base.html
The Jinja language
A Jinja template is mostly plain HTML with three special markers sprinkled in. That's the entire language, and there are only three shapes to learn:
{{ value }}— output a value.{{ note.title }}prints the title.{% tag %}— logic: loops and conditionals like{% for %},{% if %}, plus helpers like{% url ... %}.{{ value|filter }}— transform a value on its way out:{{ note.title|upper }}.
Here's notes.html looping over the notes the view passed in:
Your Notes
{% if notes %}
{% for note in notes %}
{{ note.title }}
— {{ note.content }}
{% endfor %}
{% else %}
No notes yet. Add your first one.
{% endif %}
What just happened: {% for note in notes %} walks the list, and for each one {{ note.title }} prints the
title while {{ note.content }} prints the body. The {% if notes %} / {% else %} branch shows a friendly
message when the list is empty. And {{ url_for('note_detail', note_id=note.id) }} builds the link by the
view function's name — the same url_for you met in Phase 2 — instead of hardcoding /notes/1, so if you
ever change that route's path, every link follows along automatically.
⚠️ Jinja is deliberately limited — you can't call arbitrary Python, run a database query, or do heavy computation from inside a template. That's a feature, not a gap. It enforces the rule from the top of this phase: real logic belongs in the view, where it's visible and testable. If you find yourself fighting the template to compute something, that's the template telling you the work should have happened in the view before the data was handed over.
Context — what the template can see
📝 The keyword arguments you pass to render_template have a name: the context. It is the entire world
the template can see. If a name isn't in the context, the template does not have it at all — there's no
reaching back into the view or the database for more.
return
What just happened: the view passed two things into the context — notes and page_title. Inside
notes.html, both {{ notes }} and {{ page_title }} are now available, and nothing else from the view is.
Rename the keyword (say notes= becomes items=) and the template's {{ notes }} silently goes blank,
because the name the template uses must match the key you passed. That tight boundary is what makes templates
predictable: to know what a template can use, you only have to read the context, not the whole view.
Template inheritance — write the layout once
Every page on your site shares chrome — the same <head>, the nav bar, the footer. Copy-pasting that into
notes.html, note_detail.html, and every other template is how you end up updating the nav in five files
and forgetting one. Jinja's answer is template inheritance: a base.html defines the skeleton with
{% block %} holes, and child templates fill the holes.
<!-- templates/base.html -->
{% block title %}Notebook{% endblock %}
All notes
{% block content %}{% endblock %}
Built with Flask
<!-- templates/notes.html -->
{% extends "base.html" %}
{% block title %}Your Notes — Notebook{% endblock %}
{% block content %}
Your Notes
{% for note in notes %}
{{ note.title }}
{% endfor %}
{% endblock %}
What just happened: base.html lays out the page once and marks two spots — {% block title %} and
{% block content %} — as overridable holes. The child's {% extends "base.html" %} says "start from that
skeleton," then its own {% block %} tags pour content into the matching holes. The child never repeats the
<nav>, the <footer>, or the <head> — it inherits them.
💡 This is the DRY win for server-rendered HTML: Don't Repeat Yourself, applied to layout. One base
template, many children, zero duplicated chrome. Change the footer in base.html and every page that extends
it updates at once. When you add a third page later, you write only its {% block content %} and get the
whole shell for free.
Auto-escaping — the XSS shield you get for free
Now the part that quietly protects you. By default, Jinja auto-escapes every variable it outputs. If a
note's content contains <script>alert('xss')</script>, Jinja doesn't render a live script tag — it converts
the angle brackets to <script> so the browser prints the text harmlessly instead of executing it.
Stored note content: Nice list! <script>steal()</script>
Rendered to page: Nice list! <script>steal()</script>
What just happened: a malicious note body went into the template via {{ note.content }}, but
auto-escaping defanged it on the way out. The visitor sees the literal text; the browser never runs the
script. This is your default defense against cross-site scripting (XSS) — the attack where someone smuggles
markup through user input to run code in another visitor's browser. It's the same trust-the-input family as
SQL injection; for the full picture of why this attack works and how it bites, read
SQL Injection & XSS. The good news: in Jinja, the safe behavior is the one
you get for free.
⚠️ The escape hatch is the |safe filter, which tells Jinja "trust this, render it raw." That turns the shield
off for that value. Only ever reach for it on content you generated or have already sanitized — never on
anything a user typed. {{ note.content|safe }} on a user-submitted note is exactly how an XSS hole gets
created. When in doubt, leave it escaped.
💡 Templates are the surface the user actually sees — the V in the request → view → template loop. So far
the data has flowed one direction: storage → view → template → browser. Next we reverse it: forms are how
data flows back in, from the user to your app, and that's where reading and validating request data earns
its full treatment.
Recap
render_template("notes.html", notes=notes)is how a view returns HTML: it finds the file intemplates/, runs it with your data, and returns the response. Jinja2 ships inside Flask — no setup.- The Jinja language has three shapes:
{{ value }}to output,{% tag %}for logic ({% for %},{% if %}), and{{ value|filter }}to transform. ⚠️ It's deliberately limited — real logic stays in the view.url_forworks in templates, so build links by view name, not hardcoded paths. - The context is the keywords you pass to
render_template, and it's the template's entire world. Only names you pass are visible inside; the name in the template must match the key. - Template inheritance —
{% block %}holes inbase.html,{% extends "base.html" %}in children — gives you shared layout with zero duplication. 💡 The DRY win for server-rendered HTML. - 💡 Jinja auto-escapes variables by default, blocking XSS for free. ⚠️
|safeturns that off — only use it on content you trust, never on user input.
Quick check
Make sure the data → HTML handoff stuck:
[
{
"q": "Your view calls `render_template(\"notes.html\", notes=notes)`. Inside the template, what is available?",
"choices": [
"Only `{{ notes }}` — the context is exactly what you pass, nothing more",
"Every variable defined in the view function",
"`notes` plus anything in the global Python scope",
"Nothing, because templates can't receive Python data"
],
"answer": 0,
"explain": "The context is the keywords you pass to render_template. Only `notes` is available; the template can't reach back into the view for anything else."
},
{
"q": "A note's content contains `<script>steal()</script>`. You render it with `{{ note.content }}`. What does the visitor's browser do?",
"choices": [
"Runs the script — XSS succeeds",
"Prints the literal text harmlessly because Jinja auto-escapes it",
"Strips the tag silently and shows nothing",
"Throws a template error and 500s"
],
"answer": 1,
"explain": "Jinja auto-escapes by default, converting the angle brackets to entities so the browser prints the text instead of executing it. Adding `|safe` would disable this and reopen the XSS hole."
},
{
"q": "What does `{% extends \"base.html\" %}` at the top of a child template do?",
"choices": [
"Imports Python functions from base.html into the child",
"Copies base.html's HTML inline before rendering",
"Tells Jinja to start from base.html's skeleton and fill its `{% block %}` holes with the child's blocks",
"Runs base.html as a separate request first"
],
"answer": 2,
"explain": "`{% extends %}` makes the child inherit base.html's layout; the child's `{% block %}` tags pour content into the matching holes, so shared chrome (nav, footer, head) lives in one place."
}
]
← Phase 2: Routing & Views · Guide overview · Phase 4: Forms & Request Data →
Check your understanding
1. Your view calls `render_template("notes.html", notes=notes)`. Inside the template, what is available?
2. A note's content contains `<script>steal()</script>`. You render it with `{{ note.content }}`. What does the visitor's browser do?
3. What does `{% extends "base.html" %}` at the top of a child template do?