Back to Scaling Python Applications guides

Jinja Templating in Python: A Practical Guide

Ayooluwa Isaiah
Updated on April 10, 2025

Jinja (specifically Jinja2, its current major version) was created by Armin Ronacher, the same developer behind the Flask web framework.

It draws inspiration from Django's templating system but offers more flexibility and powerful features. The name "Jinja" comes from a Japanese shrine, reflecting the elegant and simple design philosophy behind the engine.

Today, Jinja is widely used across the Python ecosystem, particularly in:

  • Web frameworks like Flask and Django.
  • Configuration management tools like Ansible and SaltStack.
  • Static site generators like Pelican.
  • Documentation generators like Sphinx.
  • Email templating systems.
  • Report generation tools.

What makes Jinja particularly popular is its balance of simplicity and power. It provides a straightforward syntax for basic operations while offering advanced features for complex requirements.

Additionally, Jinja is designed with security in mind, protecting against common vulnerabilities like cross-site scripting (XSS) attacks through automatic escaping.

This guide will walk you through everything you need to know about Jinja, from basic setup to advanced usage patterns, with practical examples along the way.

Setting up Jinja

Before diving into Jinja's features, let's get everything set up properly. Jinja is a Python package, so you'll need Python installed on your system.

The simplest way to install Jinja is via pip, Python's package manager:

 
pip install Jinja2

This will install the latest stable version of Jinja2. If you're using a virtual environment (which is recommended), make sure to activate it before running this command.

Basic environment setup

To use Jinja in a Python script, you need to import it and set up an environment. Here's a minimal example:

basic_setup.py
from jinja2 import Environment, FileSystemLoader

# Set up the environment
# The FileSystemLoader tells Jinja where to look for template files
env = Environment(loader=FileSystemLoader('templates'))

# Load a template
template = env.get_template('hello.html')

# Render the template with some variables
output = template.render(name='World')

print(output)

In this example, we're creating a Jinja environment that loads templates from a directory called templates relative to our script. We then load a specific template file, provide some data to it, and render the result.

For this to work, you'll need to create a directory called templates and within it, a file called hello.html with the following content:

 
mkdir templates
templates/hello.html
<h1>Hello, {{ name }}!</h1>

When you run the script, it should output:

Output
<h1>Hello, World!</h1>

Jinja Hello World!

Integration with Python applications

While the above example shows standalone usage, Jinja is often integrated into larger applications. Many web frameworks already include Jinja or similar templating engines.

For example, in Flask, Jinja is built-in and can be used with minimal configuration:

app.py
from flask import Flask, render_template

app = Flask(__name__)

@app.route('/')
def hello():
   return render_template('hello.html', name='World')

if __name__ == '__main__':
   app.run(debug=True)

Flash Hello world with Jinja2

Standalone usage

You can also use Jinja without file-based templates by using the Template class directly:

 
from jinja2 import Template

# Create a template from a string
template_string = 'Hello, {{ name }}!'
template = Template(template_string)

# Render the template
output = template.render(name='World')
print(output)

This approach is useful for simpler applications or when templates come from a database or other non-file sources.

Understanding the core concepts

Now that you have Jinja set up, let's explore its core concepts and syntax. Understanding these fundamentals will give you a solid foundation for using Jinja effectively.

Template basics and syntax

Jinja templates are text files with embedded expressions and statements. These elements are enclosed in specific delimiters:

  • {{ ... }} for expressions (variables or expressions that output a value).
  • {% ... %} for control structures like conditionals and loops.
  • {# ... #} for comments.

Here's a simple template demonstrating these elements:

 
<!DOCTYPE html>
<html>
<head>
   <title>{{ page_title }}</title>
</head>
<body>
   {# This is a comment that won't appear in the output #}
   <h1>Welcome to {{ site_name }}</h1>

   {% if user %}
       <p>Hello, {{ user.name }}!</p>
   {% else %}
       <p>Hello, guest!</p>
   {% endif %}

   <h2>Our Products</h2>
   <ul>
   {% for product in products %}
       <li>{{ product.name }} - ${{ product.price }}</li>
   {% endfor %}
   </ul>
</body>
</html>

Variables and expressions

Variables in Jinja templates are passed when rendering the template. They can be simple values or complex objects. You can access attributes and items using dot notation or square brackets:

 
<p>{{ user.name }}</p>
<p>{{ user['name'] }}</p>
<p>{{ user.profile.email }}</p>
<p>{{ array[0] }}</p>

Jinja also supports expressions within the {{ }} delimiters, so you can write something like:

 
<p>{{ user.age + 5 }}</p>
<p>{{ 'Hello, ' + user.name }}</p>
<p>{{ user.is_active ? 'Active' : 'Inactive' }}</p>

Control structures

Jinja provides several control structures to help you create dynamic content. Let's look at some of the most common ones.

The if statement allows you to conditionally display content:

 
{% if user.is_admin %}
   <p>Welcome, Administrator!</p>
{% elif user.is_authenticated %}
   <p>Welcome back, {{ user.name }}!</p>
{% else %}
   <p>Welcome, new visitor!</p>
{% endif %}

The for loop iterates over sequences like lists and dictionaries:

 
<ul>
{% for item in items %}
   <li>{{ item.name }} - {{ item.description }}</li>
{% else %}
   <li>No items found.</li>
{% endfor %}
</ul>

The else block in a for loop is executed if the sequence is empty.

Template inheritance and reuse

One of Jinja's most powerful features is template inheritance, which allows you to build a base "skeleton" template that contains common elements and defines blocks that child templates can override.

Let's create a base template:

templates/base.html
<!DOCTYPE html>
<html>
<head>
   <title>{% block title %}Default Title{% endblock %}</title>
   {% block extra_head %}{% endblock %}
</head>
<body>
   <header>
       <h1>My Website</h1>
       <nav>
           {% block navigation %}
           <ul>
               <li><a href="/">Home</a></li>
               <li><a href="/about">About</a></li>
               <li><a href="/contact">Contact</a></li>
           </ul>
           {% endblock %}
       </nav>
   </header>

   <main>
       {% block content %}
       <p>Default content</p>
       {% endblock %}
   </main>

   <footer>
       {% block footer %}
       <p>&copy; {{ current_year }} My Website</p>
       {% endblock %}
   </footer>
</body>
</html>

Now, we can create a child template that extends this base:

templates/page.html
{% extends "base.html" %}

{% block title %}About Us{% endblock %}

{% block content %}
<h2>About Our Company</h2>
<p>We are a leading provider of widgets and gadgets.</p>

<h3>Our Team</h3>
<ul>
   {% for member in team_members %}
   <li>{{ member.name }} - {{ member.position }}</li>
   {% endfor %}
</ul>
{% endblock %}

In this example, the child template inherits all the structure from the base template but overrides specific blocks to customize content.

Flask template inheritance demo

Filters and tests

Jinja provides filters and tests to modify variables and test conditions. Filters are applied using the pipe symbol (|) and can transform the output of an expression:

 
<p>{{ name|upper }}</p>
<p>{{ description|truncate(100) }}</p>
<p>{{ date|date("Y-m-d") }}</p>
<p>{{ tags|join(", ") }}</p>

Tests check if a variable matches certain criteria and are used with the is keyword:

 
{% if number is divisibleby(3) %}
   <p>{{ number }} is divisible by 3.</p>
{% endif %}

{% if value is defined %}
   <p>Value: {{ value }}</p>
{% else %}
   <p>Value is not defined.</p>
{% endif %}

{% if items is iterable %}
   <ul>
   {% for item in items %}
       <li>{{ item }}</li>
   {% endfor %}
   </ul>
{% endif %}

Macros and functions

Macros are similar to functions in programming languages and allow you to create reusable template fragments.

macros.html
{% macro input_field(name, value='', type='text') %}
   <div class="form-group">
       <label for="{{ name }}">{{ name|capitalize }}</label>
       <input type="{{ type }}" id="{{ name }}" name="{{ name }}" value="{{ value }}">
   </div>
{% endmacro %}

<form method="post">
   {{ input_field('username') }}
   {{ input_field('email', type='email') }}
   {{ input_field('password', type='password') }}
   <button type="submit">Submit</button>
</form>

You can also import macros from other templates:

form.html
{% import 'macros.html' as forms %}

<form method="post">
   {{ forms.input_field('username') }}
   {{ forms.input_field('email', type='email') }}
   <button type="submit">Submit</button>
</form>

Building a Flask application with Jinja

Now that you understand the core concepts of Jinja, let's build a practical Flask application that demonstrates these concepts in action. We'll create a simple app that fetches data from a free API and displays it using Jinja templates.

First, let's set up our Flask project with the necessary dependencies:

 
pip install Flask requests

Next, we'll create a basic project structure:

 
mkdir flask_jinja_demo && cd flask_jinja_demo
 
mkdir static templates
 
touch app.py

Now, let's create our basic Flask application in app.py:

app.py
from flask import Flask, render_template, abort
import requests
import json
from datetime import datetime

app = Flask(__name__)

# API endpoint we'll use
API_URL = "https://jsonplaceholder.typicode.com"

@app.context_processor
def inject_globals():
   """Add variables to all templates."""
   return {
       'current_year': datetime.now().year,
       'app_name': 'Post Explorer'
   }

@app.route('/')
def home():
   try:
       # Fetch posts from the API
       response = requests.get(f"{API_URL}/posts")
       posts = response.json()[:10]  # Limit to first 10 posts

       return render_template('home.html', posts=posts)
   except Exception as e:
       print(f"Error: {e}")
       abort(500)

@app.route('/post/<int:post_id>')
def post_detail(post_id):
   try:
       # Fetch post details
       post_response = requests.get(f"{API_URL}/posts/{post_id}")
       post = post_response.json()

       # Fetch comments for this post
       comments_response = requests.get(f"{API_URL}/posts/{post_id}/comments")
       comments = comments_response.json()

       # Fetch author (user) details
       user_response = requests.get(f"{API_URL}/users/{post['userId']}")
       user = user_response.json()

       return render_template('post_detail.html',
                             post=post,
                             comments=comments,
                             user=user)
   except Exception as e:
       print(f"Error: {e}")
       abort(404)

@app.errorhandler(404)
def page_not_found(e):
   return render_template('404.html'), 404

@app.errorhandler(500)
def server_error(e):
   return render_template('500.html'), 500

if __name__ == '__main__':
   app.run(debug=True)

In this setup:

  • We've created a Flask application with routes for a home page and post detail page.
  • We're using the JSONPlaceholder API to fetch blog posts and related data.
  • We've added error handlers for 404 and 500 errors.
  • We're using a context processor to inject global variables into all templates.

Now, let's create our base template that will serve as the foundation for all pages. We'll create a file called base.html in the templates folder:

templates/base.html
<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>{% block title %}{{ app_name }}{% endblock %}</title>
   <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
   <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
   {% block extra_css %}{% endblock %}
</head>
<body>
   <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
       <div class="container">
           <a class="navbar-brand" href="{{ url_for('home') }}">{{ app_name }}</a>
           <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
               <span class="navbar-toggler-icon"></span>
           </button>
           <div class="collapse navbar-collapse" id="navbarNav">
               <ul class="navbar-nav">
                   <li class="nav-item">
                       <a class="nav-link" href="{{ url_for('home') }}">Home</a>
                   </li>
               </ul>
           </div>
       </div>
   </nav>

   <div class="container mt-4">
       {% block content %}{% endblock %}
   </div>

   <footer class="footer mt-5 py-3 bg-light">
       <div class="container text-center">
           <span class="text-muted">&copy; {{ current_year }} {{ app_name }}. Powered by JSONPlaceholder API.</span>
       </div>
   </footer>

   <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
   {% block extra_js %}{% endblock %}
</body>
</html>

Next, let's create a simple CSS file in the static directory:

static/css/style.css
.post-card {
   transition: transform 0.3s ease;
   margin-bottom: 20px;
}

.post-card:hover {
   transform: translateY(-5px);
   box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}

.comment {
   border-left: 3px solid #0d6efd;
   padding-left: 15px;
   margin-bottom: 15px;
}

.user-info {
   border-radius: 5px;
   padding: 15px;
   background-color: #f8f9fa;
   margin-bottom: 20px;
}

Now, let's create our error page templates:

templates/404.html
{% extends "base.html" %}

{% block title %}Page Not Found - {{ app_name }}{% endblock %}

{% block content %}
<div class="text-center">
   <h1 class="display-1">404</h1>
   <p class="lead">Oops! The page you're looking for doesn't exist.</p>
   <a href="{{ url_for('home') }}" class="btn btn-primary">Go Home</a>
</div>
{% endblock %}
templates/500.html
{% extends "base.html" %}

{% block title %}Server Error - {{ app_name }}{% endblock %}

{% block content %}
<div class="text-center">
   <h1 class="display-1">500</h1>
   <p class="lead">Sorry, something went wrong on our end. Please try again later.</p>
   <a href="{{ url_for('home') }}" class="btn btn-primary">Go Home</a>
</div>
{% endblock %}

Finally, let's create our home page template, which will display a list of posts from the API:

templates/home.html
{% extends "base.html" %}

{% block title %}Home - {{ app_name }}{% endblock %}

{% block content %}
<h1>Latest Posts</h1>
<p class="lead">Explore the latest content from our community</p>

<div class="row">
   {% for post in posts %}
   <div class="col-md-6">
       <div class="card post-card">
           <div class="card-body">
               <h5 class="card-title">{{ post.title|capitalize }}</h5>
               <p class="card-text">{{ post.body|truncate(100) }}</p>
               <a href="{{ url_for('post_detail', post_id=post.id) }}" class="btn btn-primary">Read More</a>
           </div>
           <div class="card-footer text-muted">
               Post #{{ post.id }}
           </div>
       </div>
   </div>
   {% else %}
   <div class="col-12">
       <div class="alert alert-info">
           No posts found. Please try again later.
       </div>
   </div>
   {% endfor %}
</div>
{% endblock %}

In this template:

  • We're extending the base template.
  • We're overriding the title block to include the app name (which comes from our context processor).
  • We're iterating through the posts passed from our Flask route.
  • We're using the capitalize filter to capitalize the post title.
  • We're using the truncate filter to limit the length of the post body.
  • We're using the url_for function to generate the URL for the post detail page.

You may now start the application on port 5000 by running:

 
python app.py
Output
 * Serving Flask app 'app'
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 628-170-989

Visiting http://localhost:5000 in your browser will yield the following results:

Home page

At the moment, if you click any of the posts, you'll see a 404 page due to the template not being set up yet:

404 page

In the following section, we'll create the post layout template and learn more about template inheritance.

Creating the post template

Next, let's create our post detail template, which will display the details of a single post along with comments:

templates/post_detail.html
{% extends "base.html" %}

{% block title %}{{ post.title|capitalize }} - {{ app_name }}{% endblock %}

{% block content %}
<nav aria-label="breadcrumb">
   <ol class="breadcrumb">
       <li class="breadcrumb-item"><a href="{{ url_for('home') }}">Home</a></li>
       <li class="breadcrumb-item active">Post #{{ post.id }}</li>
   </ol>
</nav>

<div class="row">
   <div class="col-md-8">
       <article>
           <h1>{{ post.title|capitalize }}</h1>
           <p class="lead">{{ post.body|capitalize }}</p>
       </article>

       <h3 class="mt-5">Comments ({{ comments|length }})</h3>

       {% if comments %}
           {% for comment in comments %}
           <div class="comment">
               <h5>{{ comment.name|capitalize }}</h5>
               <p class="text-muted">{{ comment.email }}</p>
               <p>{{ comment.body|capitalize }}</p>
           </div>
           {% endfor %}
       {% else %}
           <p class="alert alert-info">No comments yet.</p>
       {% endif %}
   </div>

   <div class="col-md-4">
       <div class="user-info">
           <h4>Author Information</h4>
           <p><strong>Name:</strong> {{ user.name }}</p>
           <p><strong>Username:</strong> {{ user.username }}</p>
           <p><strong>Email:</strong> {{ user.email }}</p>
           <p><strong>Website:</strong> <a href="http://{{ user.website }}" target="_blank">{{ user.website }}</a></p>
           <p><strong>Company:</strong> {{ user.company.name }}</p>
       </div>

       <div class="card mt-3">
           <div class="card-header">Post Stats</div>
           <ul class="list-group list-group-flush">
               <li class="list-group-item">Post ID: {{ post.id }}</li>
               <li class="list-group-item">User ID: {{ post.userId }}</li>
               <li class="list-group-item">Comments: {{ comments|length }}</li>
               <li class="list-group-item">Title Length: {{ post.title|length }} characters</li>
               <li class="list-group-item">Body Length: {{ post.body|length }} characters</li>
           </ul>
       </div>
   </div>
</div>
{% endblock %}

{% block extra_js %}
<script>
   document.addEventListener('DOMContentLoaded', function() {
       // Just a simple example of how you might use JavaScript with your template
       console.log('Post detail page loaded for post #{{ post.id }}');
   });
</script>
{% endblock %}

In this template, we're extending the base template to maintain a consistent layout across our application. We've implemented more complex control structures by utilizing nested conditionals and loops to display the post content and comments dynamically based on available data.

Throughout the template, we access various properties of the post, user, and comments objects to display the relevant information in appropriate places. We've enhanced the presentation by applying filters like capitalize to format text properly and length to calculate and display metadata about the content.

Additionally, we've included some JavaScript functionality within the extra_js block that was defined in our base template, demonstrating how to incorporate client-side functionality specific to this page.

When you visit a /post route now, each post will now render correctly.

Post page

Creating reusable components with macros

Now let's create some reusable components using macros. We'll create a file called macros.html in the templates folder:

templates/macros.html
{% macro post_card(post, show_body=true) %}
<div class="card post-card h-100">
   <div class="card-body">
       <h5 class="card-title">{{ post.title|capitalize }}</h5>
       {% if show_body %}
       <p class="card-text">{{ post.body|truncate(100) }}</p>
       {% endif %}
       <a href="{{ url_for('post_detail', post_id=post.id) }}" class="btn btn-primary">Read More</a>
   </div>
   <div class="card-footer text-muted">
       Post #{{ post.id }}
   </div>
</div>
{% endmacro %}

The post_card macro serves as a reusable component that displays a post in a consistent card format throughout our application.

It takes a post object and an optional show_body parameter that determines whether to display the post's content. For example, you might show the body on the home page but hide it in search results or related posts sections.

Go ahead and update our home page to use the post card macro:

templates/home.html
{% extends "base.html" %}
{% from "macros.html" import post_card %}
{% block title %}Home - {{ app_name }}{% endblock %} {% block content %} <h1>Latest Posts</h1> <p class="lead">Explore the latest content from our community</p> <div class="row"> {% for post in posts %} <div class="col-md-6">
{{ post_card(post) }}
</div> {% else %} <div class="col-12"> <div class="alert alert-info"> No posts found. Please try again later. </div> </div> {% endfor %} </div> {% endblock %}

There will be no change to how the home page is rendered visually, but your templates will be more modular and easier to maintain.

In this manner, you can reuse components across multiple templates without duplicating code.

Using filters and tests

Now let's enhance our application by creating custom filters and tests. We'll add these to our app.py file:

app.py
. . .
# Custom filters
@app.template_filter('readtime')
def readtime_filter(text):
   """Estimates reading time for text content."""
   words_per_minute = 200
   word_count = len(text.split())
   minutes = max(1, round(word_count / words_per_minute))
   return f"{minutes} min read"

# Custom tests
@app.template_test('popular')
def is_popular(comments):
   """Test if a post is popular based on comment count."""
   return len(comments) >= 3

This code extends Jinja's functionality by adding custom template utilities:

  • A custom filter called readtime that calculates estimated reading time for text by:

    • Assuming a reading speed of 200 words per minute.
    • Counting words in the provided text.
    • Calculating and rounding minutes needed to read.
    • Returning a formatted string like "3 min read".
    • Ensuring at least 1 minute is shown even for very short content.
  • A custom test called popular that determines if content is popular by:

    • Checking if a post has 3 or more comments.
    • Returning True if it meets this threshold, False otherwise.

These extensions can be used in templates like {{ post.body|readtime }} and {% if comments is popular %}Popular!{% endif %}, enhancing template expressiveness without cluttering presentation logic.

You can see them in action by modifying your post_detail.html file as follows:

post_detail.html
{% extends "base.html" %}

{% block title %}{{ post.title|capitalize }} - {{ app_name }}{% endblock %}

{% block content %}
<nav aria-label="breadcrumb">
   <ol class="breadcrumb">
       <li class="breadcrumb-item"><a href="{{ url_for('home') }}">Home</a></li>
       <li class="breadcrumb-item active">Post #{{ post.id }}</li>
   </ol>
</nav>

<div class="row">
   <div class="col-md-8">
       <article>
           <h1>{{ post.title|capitalize }}</h1>
<span class="badge bg-secondary">{{ post.body|readtime }}</span>
<p class="lead">{{ post.body|capitalize }}</p> </article>
<h3 class="mt-5">
Comments ({{ comments|length }})
{% if comments is popular %}
<span class="badge bg-success">Popular</span>
{% endif %}
</h3>
. . .

Once you reload a post, you'll see the read time and "popular" tag where appropriate:

Post using filters and tests

Final thoughts

Jinja templating is a powerful tool that strikes a balance between simplicity and functionality. It allows you to separate your presentation logic from your business logic, making your code more maintainable and your templates more reusable.

In this guide, we've explored Jinja's core concepts and built a practical Flask application that demonstrates these concepts in action.

Thanks for reading!

Author's avatar
Article by
Ayooluwa Isaiah
Ayo is a technical content manager at Better Stack. His passion is simplifying and communicating complex technical ideas effectively. His work was featured on several esteemed publications including LWN.net, Digital Ocean, and CSS-Tricks. When he's not writing or coding, he loves to travel, bike, and play tennis.
Got an article suggestion? Let us know
Next article
DuckDB vs SQLite: Choosing the Right Embedded Database
Compare DuckDB and SQLite to find the best embedded database for your needs. Learn their key differences in performance, storage, and use cases for analytics vs. transactional workloads.
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github