Introduction
Routing and templating are the two pillars of every Flask application. Routes map incoming HTTP requests to Python functions, and templates turn raw data into HTML that browsers can render. If you understand these two systems deeply, you can build anything from a single-page prototype to a production web application with dozens of endpoints.
Flask’s routing system is powered by Werkzeug, a battle-tested WSGI toolkit, while its templating engine is Jinja2, one of the most capable template engines in the Python ecosystem. Together they give you explicit control over every URL, every HTTP method, and every piece of markup your application serves.
This tutorial assumes you have Flask installed and a basic understanding of Python. We will start with route fundamentals, work through request and response handling, take a deep dive into Jinja2, and finish by building a complete Task Manager application that ties everything together.
1. Route Fundamentals
A route in Flask is a mapping between a URL pattern and a Python function. You declare routes with the @app.route() decorator.
from flask import Flask
app = Flask(__name__)
@app.route('/')
def index():
return 'Home Page'
@app.route('/about')
def about():
return 'About Page'
if __name__ == '__main__':
app.run(debug=True)How Route Registration Works
When Python loads your module, each @app.route() decorator calls app.add_url_rule() under the hood. This registers the URL pattern, the endpoint name (defaults to the function name), and the view function in Flask’s URL map. You can inspect registered routes at any time:
# Print all registered routes
for rule in app.url_map.iter_rules():
print(f'{rule.endpoint}: {rule.rule} [{", ".join(rule.methods)}]')Trailing Slashes
Flask distinguishes between routes with and without trailing slashes, and the behavior is intentional:
# With trailing slash - acts like a directory
# /projects -> redirects to /projects/
# /projects/ -> 200 OK
@app.route('/projects/')
def projects():
return 'Projects List'
# Without trailing slash - acts like a file
# /about -> 200 OK
# /about/ -> 404 Not Found
@app.route('/about')
def about():
return 'About Page'When a route is defined with a trailing slash and a user visits the URL without it, Flask issues a 301 redirect to the version with the slash. When a route is defined without a trailing slash and a user visits with one, Flask returns a 404. This is consistent with how web servers handle directories and files. My recommendation: use trailing slashes for collection endpoints and no trailing slash for individual resource endpoints.
Registering Routes Without Decorators
You can also register routes explicitly using add_url_rule(). This is useful when the view function is defined elsewhere or generated dynamically:
def health_check():
return 'OK', 200
app.add_url_rule('/health', endpoint='health', view_func=health_check)2. HTTP Methods
By default, a route only responds to GET requests. To handle other HTTP methods, pass them explicitly via the methods parameter.
from flask import Flask, request, jsonify
app = Flask(__name__)
tasks = []
# GET - Retrieve all tasks
@app.route('/api/tasks', methods=['GET'])
def get_tasks():
return jsonify(tasks)
# POST - Create a new task
@app.route('/api/tasks', methods=['POST'])
def create_task():
data = request.get_json()
task = {
'id': len(tasks) + 1,
'title': data['title'],
'done': False
}
tasks.append(task)
return jsonify(task), 201
# PUT - Update an existing task
@app.route('/api/tasks/<int:task_id>', methods=['PUT'])
def update_task(task_id):
task = next((t for t in tasks if t['id'] == task_id), None)
if task is None:
return jsonify({'error': 'Task not found'}), 404
data = request.get_json()
task['title'] = data.get('title', task['title'])
task['done'] = data.get('done', task['done'])
return jsonify(task)
# DELETE - Remove a task
@app.route('/api/tasks/<int:task_id>', methods=['DELETE'])
def delete_task(task_id):
global tasks
tasks = [t for t in tasks if t['id'] != task_id]
return '', 204Handling Multiple Methods in One View
Sometimes it makes sense to handle multiple methods in a single view function. Use request.method to branch logic:
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
# authenticate user
return redirect(url_for('dashboard'))
# GET request - show login form
return render_template('login.html')3. URL Variables
URL variables let you capture dynamic segments from the URL and pass them as arguments to your view function. Flask supports several built-in converters.
String Converter (Default)
# Matches any text without slashes
@app.route('/user/<username>')
def user_profile(username):
return f'Profile: {username}'
# /user/folau -> username = "folau"
# /user/jane -> username = "jane"Integer Converter
# Only matches integers, rejects non-numeric values with 404
@app.route('/post/<int:post_id>')
def show_post(post_id):
return f'Post #{post_id}'
# /post/42 -> post_id = 42 (200 OK)
# /post/abc -> 404 Not FoundFloat Converter
@app.route('/price/<float:amount>')
def show_price(amount):
return f'Price: ${amount:.2f}'
# /price/19.99 -> amount = 19.99
# /price/100 -> 404 (integers don't match float)Path Converter
# Matches text including slashes - useful for file paths
@app.route('/files/<path:filepath>')
def serve_file(filepath):
return f'File: {filepath}'
# /files/docs/readme.txt -> filepath = "docs/readme.txt"UUID Converter
import uuid
@app.route('/transaction/<uuid:transaction_id>')
def show_transaction(transaction_id):
return f'Transaction: {transaction_id}'
# /transaction/a8098c1a-f86e-11da-bd1a-00112444be1e -> matchesMultiple Variables
@app.route('/blog/<int:year>/<int:month>/<slug>')
def blog_post(year, month, slug):
return f'Post: {slug} ({year}-{month:02d})'
# /blog/2026/02/flask-routes -> year=2026, month=2, slug="flask-routes"4. URL Building with url_for()
Hard-coding URLs in your application is fragile. If you rename a route or change a URL pattern, every hard-coded reference breaks. The url_for() function solves this by generating URLs from endpoint names.
from flask import Flask, url_for
app = Flask(__name__)
@app.route('/')
def index():
return 'Home'
@app.route('/user/<username>')
def user_profile(username):
return f'Profile: {username}'
@app.route('/post/<int:post_id>')
def show_post(post_id):
return f'Post #{post_id}'
with app.test_request_context():
print(url_for('index')) # /
print(url_for('user_profile', username='folau')) # /user/folau
print(url_for('show_post', post_id=42)) # /post/42
print(url_for('show_post', post_id=42, highlight='true'))
# /post/42?highlight=true (extra kwargs become query params)
print(url_for('static', filename='css/style.css'))
# /static/css/style.cssWhy url_for() Matters
There are three reasons to always use url_for() instead of hard-coded paths:
- Refactoring safety – Change a URL pattern in one place and all generated URLs update automatically.
- Escaping – Special characters are properly URL-encoded.
- Blueprint awareness – When using blueprints,
url_for()correctly prefixes the blueprint’s URL prefix without you having to remember it.
Using url_for() in Templates
<!-- In Jinja2 templates -->
<a href="{{ url_for('index') }}">Home</a>
<a href="{{ url_for('user_profile', username='folau') }}">My Profile</a>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">5. Blueprints
As your application grows, keeping every route in a single file becomes unmanageable. Blueprints let you organize routes into logical modules, each with its own URL prefix, templates, and static files.
Creating a Blueprint
# blueprints/auth.py
from flask import Blueprint, render_template, request, redirect, url_for
auth_bp = Blueprint('auth', __name__, template_folder='templates')
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
# handle login
return redirect(url_for('auth.dashboard'))
return render_template('auth/login.html')
@auth_bp.route('/logout')
def logout():
# handle logout
return redirect(url_for('auth.login'))
@auth_bp.route('/dashboard')
def dashboard():
return render_template('auth/dashboard.html')Registering a Blueprint
# app.py
from flask import Flask
from blueprints.auth import auth_bp
from blueprints.tasks import tasks_bp
from blueprints.api import api_bp
app = Flask(__name__)
# Register with URL prefixes
app.register_blueprint(auth_bp, url_prefix='/auth')
app.register_blueprint(tasks_bp, url_prefix='/tasks')
app.register_blueprint(api_bp, url_prefix='/api/v1')
# Routes become:
# /auth/login, /auth/logout, /auth/dashboard
# /tasks/, /tasks/create, /tasks/1/edit
# /api/v1/tasks, /api/v1/tasks/1url_for() with Blueprints
# Reference a blueprint endpoint with 'blueprint_name.endpoint'
url_for('auth.login') # /auth/login
url_for('tasks.create') # /tasks/create
url_for('api.get_tasks') # /api/v1/tasks
# Within the same blueprint, use a dot prefix for the current blueprint
url_for('.login') # resolves to current blueprint's loginRecommended Project Structure
myapp/
├── app.py
├── config.py
├── blueprints/
│ ├── __init__.py
│ ├── auth/
│ │ ├── __init__.py
│ │ ├── routes.py
│ │ └── templates/
│ │ └── auth/
│ │ ├── login.html
│ │ └── register.html
│ └── tasks/
│ ├── __init__.py
│ ├── routes.py
│ └── templates/
│ └── tasks/
│ ├── list.html
│ └── detail.html
├── templates/
│ └── base.html
└── static/
├── css/
├── js/
└── img/6. Request Data
Flask provides the request object as a context-local proxy. It gives you access to everything the client sent: query parameters, form data, JSON payloads, files, headers, and cookies.
Query Parameters (request.args)
from flask import request
# URL: /search?q=flask&page=2&limit=20
@app.route('/search')
def search():
query = request.args.get('q', '') # 'flask'
page = request.args.get('page', 1, type=int) # 2
limit = request.args.get('limit', 10, type=int) # 20
results = perform_search(query, page, limit)
return render_template('search.html', results=results, query=query)Form Data (request.form)
# Handles application/x-www-form-urlencoded and multipart/form-data
@app.route('/register', methods=['POST'])
def register():
username = request.form['username'] # raises 400 if missing
email = request.form.get('email', '') # returns '' if missing
password = request.form['password']
# validate and create user
return redirect(url_for('login'))JSON Data (request.json)
# Handles application/json content type
@app.route('/api/tasks', methods=['POST'])
def create_task():
data = request.get_json() # returns None if parsing fails
if data is None:
return jsonify({'error': 'Invalid JSON'}), 400
title = data.get('title')
if not title:
return jsonify({'error': 'Title is required'}), 400
task = {'id': 1, 'title': title, 'done': False}
return jsonify(task), 201File Uploads (request.files)
import os
from werkzeug.utils import secure_filename
UPLOAD_FOLDER = 'uploads'
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16 MB limit
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return 'No file part', 400
file = request.files['file']
if file.filename == '':
return 'No selected file', 400
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
return f'File {filename} uploaded successfully', 201
return 'File type not allowed', 400Other Useful Request Attributes
@app.route('/debug')
def debug_request():
info = {
'method': request.method, # GET, POST, etc.
'url': request.url, # full URL
'path': request.path, # /debug
'host': request.host, # localhost:5000
'content_type': request.content_type,
'user_agent': str(request.user_agent),
'remote_addr': request.remote_addr, # client IP
'is_json': request.is_json,
'cookies': dict(request.cookies),
'headers': dict(request.headers),
}
return jsonify(info)7. Responses
Flask gives you multiple ways to construct HTTP responses, from simple strings to fully customized response objects.
Return a String
@app.route('/')
def index():
return 'Hello, World!' # 200 OK, text/htmlReturn a Tuple (Body, Status, Headers)
# (body, status_code)
@app.route('/not-found')
def not_found():
return 'Resource not found', 404
# (body, status_code, headers)
@app.route('/custom')
def custom():
return 'Custom Response', 200, {'X-Custom-Header': 'flask-tutorial'}
# (body, headers)
@app.route('/no-cache')
def no_cache():
return 'Fresh data', {'Cache-Control': 'no-store'}make_response()
from flask import make_response
@app.route('/cookie')
def set_cookie():
resp = make_response('Cookie has been set')
resp.set_cookie('username', 'folau', max_age=3600)
resp.headers['X-Custom'] = 'value'
return resp
@app.route('/download')
def download_csv():
csv_data = 'name,email\nFolau,folau@example.com\n'
resp = make_response(csv_data)
resp.headers['Content-Type'] = 'text/csv'
resp.headers['Content-Disposition'] = 'attachment; filename=users.csv'
return respjsonify()
from flask import jsonify
@app.route('/api/status')
def api_status():
return jsonify({
'status': 'healthy',
'version': '1.0.0',
'uptime': 99.9
})
# Returns application/json with proper Content-Type header
# With status code
@app.route('/api/error')
def api_error():
return jsonify({'error': 'Something went wrong'}), 500redirect() and abort()
from flask import redirect, url_for, abort
@app.route('/old-page')
def old_page():
return redirect(url_for('new_page')) # 302 redirect
@app.route('/old-permanent')
def old_permanent():
return redirect(url_for('new_page'), code=301) # 301 permanent
@app.route('/admin')
def admin():
if not current_user.is_admin:
abort(403) # Forbidden - raises HTTPException
return render_template('admin.html')
# Custom error handlers
@app.errorhandler(404)
def page_not_found(e):
return render_template('404.html'), 404
@app.errorhandler(500)
def internal_error(e):
return render_template('500.html'), 5008. Jinja2 Templates Deep Dive
Jinja2 is Flask’s default template engine. It provides template inheritance, control structures, filters, macros, and more. Templates live in the templates/ directory by default.
Template Inheritance
Template inheritance is Jinja2’s most powerful feature. You define a base template with blocks that child templates can override.
<!-- 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 %}My App{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block extra_css %}{% endblock %}
</head>
<body>
<nav>
{% block navbar %}
<a href="{{ url_for('index') }}">Home</a>
<a href="{{ url_for('tasks.list') }}">Tasks</a>
<a href="{{ url_for('auth.login') }}">Login</a>
{% endblock %}
</nav>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer>
{% block footer %}
<p>© 2026 My App</p>
{% endblock %}
</footer>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html><!-- templates/tasks/list.html -->
{% extends "base.html" %}
{% block title %}Tasks - My App{% endblock %}
{% block content %}
<h1>Task List</h1>
{% if tasks %}
<ul>
{% for task in tasks %}
<li>
<a href="{{ url_for('tasks.detail', task_id=task.id) }}">
{{ task.title }}
</a>
{% if task.done %}
<span class="badge">Done</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>No tasks yet. <a href="{{ url_for('tasks.create') }}">Create one</a>.</p>
{% endif %}
{% endblock %}Variables and Expressions
<!-- Simple variable output -->
<h1>{{ user.name }}</h1>
<p>Email: {{ user.email }}</p>
<!-- Expressions -->
<p>Total: ${{ price * quantity }}</p>
<p>Full Name: {{ first_name ~ ' ' ~ last_name }}</p>
<!-- Default values -->
<p>Welcome, {{ username | default('Guest') }}</p>
<!-- Accessing dictionaries and lists -->
<p>{{ config['DEBUG'] }}</p>
<p>{{ items[0] }}</p>
<!-- Ternary-style conditional expression -->
<p class="{{ 'active' if is_active else 'inactive' }}">Status</p>Control Structures
<!-- If / Elif / Else -->
{% if user.role == 'admin' %}
<a href="/admin">Admin Panel</a>
{% elif user.role == 'editor' %}
<a href="/editor">Editor Panel</a>
{% else %}
<a href="/profile">My Profile</a>
{% endif %}
<!-- For Loop -->
<table>
<thead>
<tr><th>#</th><th>Name</th><th>Email</th></tr>
</thead>
<tbody>
{% for user in users %}
<tr class="{{ 'even' if loop.index is even else 'odd' }}">
<td>{{ loop.index }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
</tr>
{% else %}
<tr><td colspan="3">No users found.</td></tr>
{% endfor %}
</tbody>
</table>
<!-- Loop Variable Properties -->
{# loop.index - current iteration (1-indexed) #}
{# loop.index0 - current iteration (0-indexed) #}
{# loop.first - True if first iteration #}
{# loop.last - True if last iteration #}
{# loop.length - total number of items #}
{# loop.revindex - iterations until the end (1-indexed) #}Filters
Filters transform variable output. They are applied with the pipe (|) operator.
<!-- String filters -->
{{ name | capitalize }} <!-- "folau" -> "Folau" -->
{{ name | upper }} <!-- "folau" -> "FOLAU" -->
{{ name | lower }} <!-- "FOLAU" -> "folau" -->
{{ name | title }} <!-- "hello world" -> "Hello World" -->
{{ text | truncate(100) }} <!-- Truncates to 100 chars with "..." -->
{{ text | striptags }} <!-- Removes HTML tags -->
{{ text | wordcount }} <!-- Counts words -->
<!-- HTML escaping -->
{{ user_input | e }} <!-- Escapes HTML (default behavior) -->
{{ trusted_html | safe }} <!-- Marks content as safe, no escaping -->
<!-- List/Collection filters -->
{{ users | length }} <!-- Number of items -->
{{ numbers | sum }} <!-- Sum of numeric list -->
{{ names | join(', ') }} <!-- Joins list items -->
{{ items | sort }} <!-- Sorts items -->
{{ users | sort(attribute='name') }} <!-- Sort by attribute -->
{{ items | first }} <!-- First item -->
{{ items | last }} <!-- Last item -->
{{ items | unique }} <!-- Unique items -->
{{ items | reverse }} <!-- Reversed list -->
<!-- Number filters -->
{{ price | round(2) }} <!-- Rounds to 2 decimal places -->
{{ large_num | filesizeformat }} <!-- 13312 -> "13.0 kB" -->
<!-- Default values -->
{{ bio | default('No bio provided') }}
<!-- Chaining filters -->
{{ description | striptags | truncate(200) | capitalize }}Custom Filters
# Register custom filters in your Flask app
from datetime import datetime
@app.template_filter('dateformat')
def dateformat_filter(value, format='%B %d, %Y'):
if isinstance(value, str):
value = datetime.fromisoformat(value)
return value.strftime(format)
@app.template_filter('currency')
def currency_filter(value):
return f'${value:,.2f}'<!-- Using custom filters -->
<p>Created: {{ task.created_at | dateformat }}</p>
<p>Created: {{ task.created_at | dateformat('%m/%d/%Y') }}</p>
<p>Price: {{ product.price | currency }}</p>Macros (Reusable Template Components)
Macros are like functions for templates. They let you create reusable HTML components.
<!-- templates/macros/forms.html -->
{% macro input(name, label, type='text', value='', required=false, placeholder='') %}
<div class="form-group">
<label for="{{ name }}">{{ label }}</label>
<input type="{{ type }}"
id="{{ name }}"
name="{{ name }}"
value="{{ value }}"
placeholder="{{ placeholder }}"
class="form-control"
{{ 'required' if required }}>
</div>
{% endmacro %}
{% macro textarea(name, label, value='', rows=4, required=false) %}
<div class="form-group">
<label for="{{ name }}">{{ label }}</label>
<textarea id="{{ name }}"
name="{{ name }}"
rows="{{ rows }}"
class="form-control"
{{ 'required' if required }}>{{ value }}</textarea>
</div>
{% endmacro %}
{% macro select(name, label, options, selected='') %}
<div class="form-group">
<label for="{{ name }}">{{ label }}</label>
<select id="{{ name }}" name="{{ name }}" class="form-control">
{% for value, text in options %}
<option value="{{ value }}" {{ 'selected' if value == selected }}>
{{ text }}
</option>
{% endfor %}
</select>
</div>
{% endmacro %}<!-- Using macros in another template -->
{% from "macros/forms.html" import input, textarea, select %}
<form method="POST">
{{ input('title', 'Task Title', required=true, placeholder='Enter task title') }}
{{ textarea('description', 'Description', rows=6) }}
{{ select('priority', 'Priority', [('low', 'Low'), ('medium', 'Medium'), ('high', 'High')]) }}
<button type="submit" class="btn btn-primary">Create Task</button>
</form>Include (Partial Templates)
<!-- templates/partials/task_card.html -->
<div class="card">
<div class="card-body">
<h5>{{ task.title }}</h5>
<p>{{ task.description | truncate(100) }}</p>
<span class="badge badge-{{ 'success' if task.done else 'warning' }}">
{{ 'Done' if task.done else 'Pending' }}
</span>
</div>
</div>
<!-- Including the partial -->
{% for task in tasks %}
{% include "partials/task_card.html" %}
{% endfor %}
<!-- Include with ignore missing (won't error if file is absent) -->
{% include "partials/sidebar.html" ignore missing %}9. Forms
HTML forms are the standard way to collect user input on the web. Flask gives you direct access to submitted form data through the request object.
Basic Form Handling
from flask import Flask, render_template, request, redirect, url_for, flash
app = Flask(__name__)
app.secret_key = 'your-secret-key-here' # required for flash messages
tasks = []
@app.route('/tasks/create', methods=['GET', 'POST'])
def create_task():
if request.method == 'POST':
title = request.form.get('title', '').strip()
description = request.form.get('description', '').strip()
priority = request.form.get('priority', 'medium')
# Validation
errors = []
if not title:
errors.append('Title is required.')
if len(title) > 200:
errors.append('Title must be under 200 characters.')
if errors:
for error in errors:
flash(error, 'danger')
return render_template('tasks/create.html',
title=title,
description=description,
priority=priority)
task = {
'id': len(tasks) + 1,
'title': title,
'description': description,
'priority': priority,
'done': False
}
tasks.append(task)
flash('Task created successfully!', 'success')
return redirect(url_for('task_list'))
return render_template('tasks/create.html')<!-- templates/tasks/create.html -->
{% extends "base.html" %}
{% block content %}
<h1>Create New Task</h1>
<form method="POST" action="{{ url_for('create_task') }}">
<div class="form-group">
<label for="title">Title</label>
<input type="text" id="title" name="title"
value="{{ title | default('') }}"
class="form-control" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description"
class="form-control" rows="4">{{ description | default('') }}</textarea>
</div>
<div class="form-group">
<label for="priority">Priority</label>
<select id="priority" name="priority" class="form-control">
<option value="low" {{ 'selected' if priority == 'low' }}>Low</option>
<option value="medium" {{ 'selected' if priority == 'medium' else 'selected' if not priority }}>Medium</option>
<option value="high" {{ 'selected' if priority == 'high' }}>High</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Create Task</button>
<a href="{{ url_for('task_list') }}" class="btn btn-secondary">Cancel</a>
</form>
{% endblock %}Flash Messages
# In your view function
flash('Operation successful!', 'success') # green
flash('Please check your input.', 'warning') # yellow
flash('Something went wrong.', 'danger') # red
flash('FYI: system update tonight.', 'info') # blue<!-- In your base template -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="close" data-dismiss="alert">
<span>×</span>
</button>
</div>
{% endfor %}
{% endif %}
{% endwith %}CSRF Protection
Flask does not include CSRF protection by default. The Flask-WTF extension is the standard way to add it:
pip install flask-wtffrom flask_wtf import FlaskForm, CSRFProtect
from wtforms import StringField, TextAreaField, SelectField
from wtforms.validators import DataRequired, Length
app = Flask(__name__)
app.secret_key = 'your-secret-key'
csrf = CSRFProtect(app)
class TaskForm(FlaskForm):
title = StringField('Title', validators=[
DataRequired(message='Title is required'),
Length(max=200, message='Title must be under 200 characters')
])
description = TextAreaField('Description')
priority = SelectField('Priority',
choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High')])
@app.route('/tasks/create', methods=['GET', 'POST'])
def create_task():
form = TaskForm()
if form.validate_on_submit():
task = {
'title': form.title.data,
'description': form.description.data,
'priority': form.priority.data
}
tasks.append(task)
flash('Task created!', 'success')
return redirect(url_for('task_list'))
return render_template('tasks/create_wtf.html', form=form)<!-- templates/tasks/create_wtf.html -->
{% extends "base.html" %}
{% block content %}
<form method="POST">
{{ form.hidden_tag() }} <!-- Includes CSRF token -->
<div class="form-group">
{{ form.title.label }}
{{ form.title(class="form-control") }}
{% for error in form.title.errors %}
<small class="text-danger">{{ error }}</small>
{% endfor %}
</div>
<div class="form-group">
{{ form.description.label }}
{{ form.description(class="form-control", rows=4) }}
</div>
<div class="form-group">
{{ form.priority.label }}
{{ form.priority(class="form-control") }}
</div>
<button type="submit" class="btn btn-primary">Create</button>
</form>
{% endblock %}10. Static Files
Flask serves static files from the static/ folder by default. Use url_for('static', filename=...) to reference them.
<!-- CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<!-- JavaScript -->
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<!-- Images -->
<img src="{{ url_for('static', filename='img/logo.png') }}" alt="Logo">
<!-- Favicon -->
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">Static File Organization
static/
├── css/
│ ├── style.css
│ └── components/
│ └── navbar.css
├── js/
│ ├── app.js
│ └── utils.js
├── img/
│ ├── logo.png
│ └── icons/
└── favicon.icoCache Busting
Browsers aggressively cache static files. When you update a CSS or JS file, users might still see the old version. A cache-busting strategy appends a version or hash to the file URL.
import hashlib
import os
@app.template_filter('cache_bust')
def cache_bust_filter(filename):
filepath = os.path.join(app.static_folder, filename)
if os.path.isfile(filepath):
with open(filepath, 'rb') as f:
file_hash = hashlib.md5(f.read()).hexdigest()[:8]
return url_for('static', filename=filename) + '?v=' + file_hash
return url_for('static', filename=filename)<!-- Usage in templates -->
<link rel="stylesheet" href="{{ 'css/style.css' | cache_bust }}">
<!-- Output: /static/css/style.css?v=a1b2c3d4 -->11. Practical Example: Task Manager App
Let us build a complete task manager application that demonstrates routes, templates, forms, and flash messages working together. This is the kind of application structure you would use in a real project.
Application Setup
# app.py
from flask import Flask, render_template, request, redirect, url_for, flash
from datetime import datetime
app = Flask(__name__)
app.secret_key = 'dev-secret-key-change-in-production'
# In-memory storage (use a database in production)
tasks = [
{
'id': 1,
'title': 'Learn Flask routing',
'description': 'Study URL rules, methods, and variables',
'priority': 'high',
'done': False,
'created_at': datetime(2026, 2, 1)
},
{
'id': 2,
'title': 'Master Jinja2 templates',
'description': 'Learn inheritance, macros, and filters',
'priority': 'high',
'done': False,
'created_at': datetime(2026, 2, 5)
},
{
'id': 3,
'title': 'Build a CRUD app',
'description': 'Apply routing and templates in a real project',
'priority': 'medium',
'done': False,
'created_at': datetime(2026, 2, 10)
}
]
next_id = 4
def find_task(task_id):
return next((t for t in tasks if t['id'] == task_id), None)
@app.template_filter('dateformat')
def dateformat_filter(value, fmt='%B %d, %Y'):
return value.strftime(fmt)
# --- Routes ---
@app.route('/')
def index():
return redirect(url_for('task_list'))
@app.route('/tasks/')
def task_list():
filter_status = request.args.get('status', 'all')
sort_by = request.args.get('sort', 'created_at')
filtered = tasks[:]
if filter_status == 'done':
filtered = [t for t in filtered if t['done']]
elif filter_status == 'pending':
filtered = [t for t in filtered if not t['done']]
if sort_by == 'priority':
priority_order = {'high': 0, 'medium': 1, 'low': 2}
filtered.sort(key=lambda t: priority_order.get(t['priority'], 1))
elif sort_by == 'title':
filtered.sort(key=lambda t: t['title'].lower())
else:
filtered.sort(key=lambda t: t['created_at'], reverse=True)
return render_template('tasks/list.html',
tasks=filtered,
current_status=filter_status,
current_sort=sort_by)
@app.route('/tasks/create', methods=['GET', 'POST'])
def task_create():
global next_id
if request.method == 'POST':
title = request.form.get('title', '').strip()
description = request.form.get('description', '').strip()
priority = request.form.get('priority', 'medium')
if not title:
flash('Title is required.', 'danger')
return render_template('tasks/form.html',
action='Create',
title=title,
description=description,
priority=priority)
task = {
'id': next_id,
'title': title,
'description': description,
'priority': priority,
'done': False,
'created_at': datetime.now()
}
tasks.append(task)
next_id += 1
flash(f'Task "{title}" created successfully!', 'success')
return redirect(url_for('task_list'))
return render_template('tasks/form.html', action='Create')
@app.route('/tasks/<int:task_id>')
def task_detail(task_id):
task = find_task(task_id)
if task is None:
flash('Task not found.', 'danger')
return redirect(url_for('task_list'))
return render_template('tasks/detail.html', task=task)
@app.route('/tasks/<int:task_id>/edit', methods=['GET', 'POST'])
def task_edit(task_id):
task = find_task(task_id)
if task is None:
flash('Task not found.', 'danger')
return redirect(url_for('task_list'))
if request.method == 'POST':
title = request.form.get('title', '').strip()
description = request.form.get('description', '').strip()
priority = request.form.get('priority', 'medium')
if not title:
flash('Title is required.', 'danger')
return render_template('tasks/form.html',
action='Edit',
task=task,
title=title,
description=description,
priority=priority)
task['title'] = title
task['description'] = description
task['priority'] = priority
flash(f'Task "{title}" updated successfully!', 'success')
return redirect(url_for('task_detail', task_id=task_id))
return render_template('tasks/form.html',
action='Edit',
task=task,
title=task['title'],
description=task['description'],
priority=task['priority'])
@app.route('/tasks/<int:task_id>/toggle', methods=['POST'])
def task_toggle(task_id):
task = find_task(task_id)
if task is None:
flash('Task not found.', 'danger')
return redirect(url_for('task_list'))
task['done'] = not task['done']
status = 'completed' if task['done'] else 'reopened'
flash(f'Task "{task["title"]}" {status}.', 'info')
return redirect(url_for('task_list'))
@app.route('/tasks/<int:task_id>/delete', methods=['POST'])
def task_delete(task_id):
global tasks
task = find_task(task_id)
if task is None:
flash('Task not found.', 'danger')
return redirect(url_for('task_list'))
tasks = [t for t in tasks if t['id'] != task_id]
flash(f'Task "{task["title"]}" deleted.', 'warning')
return redirect(url_for('task_list'))
# Custom error handlers
@app.errorhandler(404)
def not_found(e):
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def server_error(e):
return render_template('errors/500.html'), 500
if __name__ == '__main__':
app.run(debug=True)Base Template
<!-- 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 %}Task Manager{% endblock %}</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="{{ url_for('task_list') }}">Task Manager</a>
<div class="navbar-nav">
<a class="nav-link" href="{{ url_for('task_list') }}">All Tasks</a>
<a class="nav-link" href="{{ url_for('task_create') }}">New Task</a>
</div>
</nav>
<div class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="close" data-dismiss="alert">
<span>×</span>
</button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>Task List Template
<!-- templates/tasks/list.html -->
{% extends "base.html" %}
{% block title %}All Tasks - Task Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Tasks</h1>
<a href="{{ url_for('task_create') }}" class="btn btn-primary">+ New Task</a>
</div>
<!-- Filters -->
<div class="btn-group mb-3">
<a href="{{ url_for('task_list', status='all', sort=current_sort) }}"
class="btn btn-sm {{ 'btn-primary' if current_status == 'all' else 'btn-outline-primary' }}">
All
</a>
<a href="{{ url_for('task_list', status='pending', sort=current_sort) }}"
class="btn btn-sm {{ 'btn-primary' if current_status == 'pending' else 'btn-outline-primary' }}">
Pending
</a>
<a href="{{ url_for('task_list', status='done', sort=current_sort) }}"
class="btn btn-sm {{ 'btn-primary' if current_status == 'done' else 'btn-outline-primary' }}">
Done
</a>
</div>
<!-- Sort -->
<div class="btn-group mb-3 ml-2">
<a href="{{ url_for('task_list', status=current_status, sort='created_at') }}"
class="btn btn-sm {{ 'btn-secondary' if current_sort == 'created_at' else 'btn-outline-secondary' }}">
Newest
</a>
<a href="{{ url_for('task_list', status=current_status, sort='priority') }}"
class="btn btn-sm {{ 'btn-secondary' if current_sort == 'priority' else 'btn-outline-secondary' }}">
Priority
</a>
<a href="{{ url_for('task_list', status=current_status, sort='title') }}"
class="btn btn-sm {{ 'btn-secondary' if current_sort == 'title' else 'btn-outline-secondary' }}">
A-Z
</a>
</div>
{% if tasks %}
<div class="list-group">
{% for task in tasks %}
<div class="list-group-item d-flex justify-content-between align-items-start">
<div>
<h5 class="{{ 'text-muted' if task.done }}">
{% if task.done %}<s>{{ task.title }}</s>{% else %}{{ task.title }}{% endif %}
<span class="badge badge-{{ 'danger' if task.priority == 'high' else 'warning' if task.priority == 'medium' else 'info' }}">
{{ task.priority | capitalize }}
</span>
</h5>
<small class="text-muted">Created {{ task.created_at | dateformat }}</small>
</div>
<div class="btn-group">
<a href="{{ url_for('task_detail', task_id=task.id) }}"
class="btn btn-sm btn-outline-info">View</a>
<a href="{{ url_for('task_edit', task_id=task.id) }}"
class="btn btn-sm btn-outline-secondary">Edit</a>
<form method="POST" action="{{ url_for('task_toggle', task_id=task.id) }}"
style="display:inline">
<button class="btn btn-sm btn-outline-{{ 'success' if not task.done else 'warning' }}">
{{ 'Complete' if not task.done else 'Reopen' }}
</button>
</form>
<form method="POST" action="{{ url_for('task_delete', task_id=task.id) }}"
style="display:inline"
onsubmit="return confirm('Delete this task?')">
<button class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</div>
</div>
{% endfor %}
</div>
<p class="mt-3 text-muted">Showing {{ tasks | length }} task{{ 's' if tasks | length != 1 }}</p>
{% else %}
<div class="text-center py-5">
<h3 class="text-muted">No tasks found</h3>
<p><a href="{{ url_for('task_create') }}">Create your first task</a></p>
</div>
{% endif %}
{% endblock %}Task Form Template (Create and Edit)
<!-- templates/tasks/form.html -->
{% extends "base.html" %}
{% block title %}{{ action }} Task - Task Manager{% endblock %}
{% block content %}
<h1>{{ action }} Task</h1>
<form method="POST">
<div class="form-group">
<label for="title">Title <span class="text-danger">*</span></label>
<input type="text" id="title" name="title"
value="{{ title | default('') }}"
class="form-control" required maxlength="200"
placeholder="What needs to be done?">
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description"
class="form-control" rows="4"
placeholder="Add details (optional)">{{ description | default('') }}</textarea>
</div>
<div class="form-group">
<label for="priority">Priority</label>
<select id="priority" name="priority" class="form-control">
<option value="low" {{ 'selected' if priority == 'low' }}>Low</option>
<option value="medium" {{ 'selected' if priority == 'medium' or not priority }}>Medium</option>
<option value="high" {{ 'selected' if priority == 'high' }}>High</option>
</select>
</div>
<button type="submit" class="btn btn-primary">{{ action }} Task</button>
<a href="{{ url_for('task_list') }}" class="btn btn-secondary">Cancel</a>
</form>
{% endblock %}Task Detail Template
<!-- templates/tasks/detail.html -->
{% extends "base.html" %}
{% block title %}{{ task.title }} - Task Manager{% endblock %}
{% block content %}
<div class="card">
<div class="card-body">
<h2>
{{ task.title }}
<span class="badge badge-{{ 'success' if task.done else 'warning' }}">
{{ 'Done' if task.done else 'Pending' }}
</span>
<span class="badge badge-{{ 'danger' if task.priority == 'high' else 'warning' if task.priority == 'medium' else 'info' }}">
{{ task.priority | capitalize }} Priority
</span>
</h2>
{% if task.description %}
<p class="card-text mt-3">{{ task.description }}</p>
{% endif %}
<p class="text-muted">Created: {{ task.created_at | dateformat }}</p>
<div class="mt-3">
<a href="{{ url_for('task_edit', task_id=task.id) }}"
class="btn btn-secondary">Edit</a>
<form method="POST" action="{{ url_for('task_toggle', task_id=task.id) }}"
style="display:inline">
<button class="btn btn-{{ 'success' if not task.done else 'warning' }}">
Mark {{ 'Complete' if not task.done else 'Incomplete' }}
</button>
</form>
<form method="POST" action="{{ url_for('task_delete', task_id=task.id) }}"
style="display:inline"
onsubmit="return confirm('Are you sure you want to delete this task?')">
<button class="btn btn-danger">Delete</button>
</form>
</div>
</div>
</div>
<a href="{{ url_for('task_list') }}" class="btn btn-link mt-3">← Back to list</a>
{% endblock %}Running the App
# Install Flask
pip install flask
# Run the application
python app.py
# Or use the flask command
export FLASK_APP=app.py
export FLASK_ENV=development
flask run
# Open http://localhost:5000 in your browser12. Common Pitfalls
Here are the mistakes I see developers make most often when working with Flask routes and templates.
Forgetting methods=[‘POST’]
# WRONG - This only handles GET, POST will return 405 Method Not Allowed
@app.route('/submit')
def submit():
data = request.form['name'] # never reached on POST
return 'OK'
# CORRECT
@app.route('/submit', methods=['GET', 'POST'])
def submit():
if request.method == 'POST':
data = request.form['name']
return 'OK'
return render_template('form.html')Template Not Found
# Flask looks for templates in the 'templates/' folder by default.
# Make sure your directory structure is correct:
#
# myapp/
# ├── app.py
# └── templates/ <-- must be named "templates"
# └── index.html
# WRONG - file at myapp/index.html (not in templates folder)
# CORRECT - file at myapp/templates/index.html
# If using blueprints with custom template folders, check template_folder parameter:
bp = Blueprint('auth', __name__, template_folder='templates')
# Template paths are relative: render_template('auth/login.html')url_for() Errors
# WRONG - using the URL path instead of the function name
url_for('/user/profile')
# CORRECT - use the endpoint (function name)
url_for('user_profile')
# WRONG - missing required URL variables
url_for('show_post') # BuildError: missing 'post_id'
# CORRECT
url_for('show_post', post_id=42)
# WRONG - blueprint endpoint without blueprint prefix
url_for('login') # BuildError if login is in auth blueprint
# CORRECT
url_for('auth.login')Circular Imports
# This is a common problem with Flask blueprints.
# WRONG:
# app.py imports blueprints/auth.py
# blueprints/auth.py imports app from app.py -> circular import
# CORRECT - use Flask application factory pattern:
# app/__init__.py
def create_app():
app = Flask(__name__)
from .blueprints.auth import auth_bp
app.register_blueprint(auth_bp)
return appModifying Response After return
# WRONG - code after return never executes
@app.route('/example')
def example():
return 'Hello'
response.headers['X-Custom'] = 'value' # unreachable
# CORRECT - use make_response before returning
@app.route('/example')
def example():
resp = make_response('Hello')
resp.headers['X-Custom'] = 'value'
return resp13. Best Practices
RESTful URL Design
# Use nouns, not verbs. Let HTTP methods express the action.
# WRONG
@app.route('/get-users')
@app.route('/create-user')
@app.route('/delete-user/1')
# CORRECT
@app.route('/users/', methods=['GET']) # list users
@app.route('/users/', methods=['POST']) # create user
@app.route('/users/<int:id>', methods=['GET']) # get user
@app.route('/users/<int:id>', methods=['PUT']) # update user
@app.route('/users/<int:id>', methods=['DELETE']) # delete user
# Use plural nouns for collections
# WRONG: /user/1
# CORRECT: /users/1
# Nest resources logically
# /users/1/posts - posts by user 1
# /users/1/posts/5 - post 5 by user 1Keep Views Thin
# WRONG - business logic crammed into the view
@app.route('/checkout', methods=['POST'])
def checkout():
cart = session.get('cart', [])
total = sum(item['price'] * item['qty'] for item in cart)
tax = total * 0.08
shipping = 5.99 if total < 50 else 0
final_total = total + tax + shipping
# ... 50 more lines of order processing
return render_template('receipt.html', total=final_total)
# CORRECT - delegate to service functions
@app.route('/checkout', methods=['POST'])
def checkout():
cart = get_cart_from_session()
order = create_order(cart)
return render_template('receipt.html', order=order)Use Blueprints Early
Do not wait until your app.py is 2000 lines long. Start with blueprints as soon as you have more than one logical section (auth, API, admin, public pages). The refactoring cost grows with time.
Template Organization
# Organize templates to mirror your application structure
templates/
├── base.html # Master layout
├── macros/
│ ├── forms.html # Reusable form components
│ └── pagination.html # Pagination macro
├── partials/
│ ├── navbar.html # Navigation
│ ├── footer.html # Footer
│ └── flash_messages.html
├── auth/
│ ├── login.html
│ └── register.html
├── tasks/
│ ├── list.html
│ ├── detail.html
│ └── form.html
└── errors/
├── 404.html
└── 500.htmlSecurity Checklist
- Always set
app.secret_keyto a long random value in production. - Use CSRF protection (Flask-WTF) on all forms.
- Never trust user input - validate and sanitize on the server side.
- Use
secure_filename()from Werkzeug for file uploads. - Set
MAX_CONTENT_LENGTHto prevent large file upload attacks. - Jinja2 auto-escapes HTML by default. Only use
| safeon content you trust. - Use environment variables for sensitive configuration, not hard-coded values.
14. Key Takeaways
- Routes map URLs to functions. Use
@app.route()with explicitmethodsparameters. Understand trailing slash behavior. - URL variables capture dynamic segments. Use typed converters (
int,float,path,uuid) to validate input at the routing layer. - url_for() generates URLs from endpoint names. Always use it instead of hard-coding paths. It handles blueprints, encoding, and query parameters.
- Blueprints organize routes into modules. Register them with URL prefixes. Start using them early in your project.
- The request object gives you access to query params (
request.args), form data (request.form), JSON (request.get_json()), and files (request.files). - Responses can be strings, tuples,
make_response()objects,jsonify()for APIs, orredirect()for navigation. - Jinja2 template inheritance (
extends,block) eliminates HTML duplication. Use macros for reusable components and includes for partials. - Filters transform template output. Build custom filters for formatting dates, currencies, and application-specific transformations.
- Flash messages provide one-time user feedback across redirects. Categorize them for proper styling.
- Keep views thin, use RESTful URL design, protect forms with CSRF tokens, and organize templates to mirror your application structure.
With routes and templates mastered, you have the foundation for building any Flask web application. The next steps are adding a database with Flask-SQLAlchemy, user authentication with Flask-Login, and deploying to production. Each of these builds directly on the routing and templating patterns covered here.