Covering Flask 3.1.3 and Python 3.12+ Table of Contents 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 1. Introduction 1.1 What Flask Is Flask is a lig…
Covering Flask 3.1.3 and Python 3.12+
Table of Contents
- Introduction
- Environment Setup
- Your First App
- Routing
- Templates
- Request and Response Handling
- Sessions and Cookies
- Application Structure
- Databases
- Forms and Validation
- Authentication
- REST APIs with Flask
- Error Handling and Logging
- Testing
- Deployment
- Appendix
1. Introduction
1.1 What Flask Is
Flask is a lightweight WSGI (Web Server Gateway Interface) web application framework for Python. It was created by Armin Ronacher and first released in 2010, built on top of two other Pallets Projects libraries: Werkzeug (a WSGI toolkit that handles the low-level HTTP plumbing) and Jinja2 (a templating engine). Flask itself does very little — it wires these two pieces together and gives you a small, well-designed API for defining routes, handling requests, and returning responses.
Flask is often described as a "microframework." That word doesn't mean the framework is limited or that your applications must be small — it means Flask's core deliberately stays minimal, and functionality that isn't universally needed (database access, form validation, authentication, admin panels) is left to extensions you choose to add. This is the opposite philosophy of Django's "batteries included" approach, where most of that functionality ships in the box.
1.2 Flask vs. Django vs. FastAPI
A quick comparison to help you decide when Flask is the right tool:
|
Flask |
Django |
FastAPI |
| Philosophy |
Minimal core, add what you need |
Batteries included |
Minimal core, async-first |
| Built-in ORM |
No (commonly SQLAlchemy) |
Yes (Django ORM) |
No (commonly SQLAlchemy) |
| Admin panel |
No (extension) |
Yes, built in |
No |
| Async support |
Yes (since 2.0), opt-in |
Partial, improving |
Native, first-class |
| Learning curve |
Gentle |
Steeper |
Gentle to moderate |
| Best for |
Small-to-medium apps, APIs, prototypes, full control |
Large apps that want structure out of the box |
High-performance async APIs |
Flask sits in a sweet spot: it's simple enough to understand end-to-end in an afternoon, but flexible enough to scale up to large production applications when combined with the right extensions.
1.3 A Note on Versions
This book targets Flask 3.1.3, the current stable release as of early 2026. Flask 3.x dropped support for Python versions older than 3.9 and cleaned up several APIs that had accumulated cruft since Flask 1.0 (released 2018). If you're following an older tutorial written for Flask 1.x or early 2.x, watch for these differences:
flask.escape, flask.Markup, and several other re-exports from earlier versions have been removed; import from markupsafe directly where needed.
- The
before_first_request hook was removed in Flask 2.3 — use application factories and run setup code at app creation time instead.
FLASK_ENV is deprecated; use FLASK_DEBUG=1 for debug mode.
- Async views (
async def view functions) have been supported natively since Flask 2.0, no extra extension required.
2. Environment Setup
2.1 Installing Python
Flask 3.1.3 requires Python 3.9 or newer; this book assumes Python 3.12+. Check your version:
python3 --version
2.2 Creating a Virtual Environment
Always isolate your project's dependencies in a virtual environment rather than installing packages globally.
mkdir flask-book-project
cd flask-book-project
python3 -m venv venv
Activate it:
# macOS / Linux
source venv/bin/activate
# Windows (PowerShell)
venv\Scripts\Activate.ps1
Your shell prompt should now show (venv) at the start of the line.
2.3 Installing Flask
pip install flask
Verify the installed version:
python3 -c "import flask; print(flask.__version__)"
# 3.1.3
2.4 Recommended Project Layout
For this book we'll grow a single project step by step. A typical layout looks like this once it matures (Chapter 8 explains why):
flask-book-project/
├── venv/
├── app.py # entry point (small apps)
├── requirements.txt
├── myapp/ # package (larger apps, from Ch. 8 onward)
│ ├── __init__.py # application factory
│ ├── config.py
│ ├── models.py
│ ├── routes/
│ ├── templates/
│ └── static/
└── tests/
Freeze your dependencies as you add them:
pip freeze > requirements.txt
Try it yourself: Create the virtual environment, install Flask, and confirm the version prints 3.1.3.
3. Your First App
3.1 Hello, Flask
Create app.py:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello():
return "Hello, Flask!"
if __name__ == "__main__":
app.run(debug=True)
Run it:
python3 app.py
Visit http://127.0.0.1:5000/ in your browser. You should see "Hello, Flask!".
3.2 What Just Happened
Flask(__name__) creates an application object. __name__ tells Flask where to look for resources like templates and static files relative to.
@app.route("/") is a decorator that registers the function below it as the view (handler) for requests to /.
app.run(debug=True) starts Flask's built-in development server. Debug mode should never be used in production — it enables an interactive debugger in the browser that can execute arbitrary Python code if someone reaches an error page.
3.3 The flask run Command
Instead of calling app.run() in a script, the idiomatic way to start a Flask app is via its CLI:
export FLASK_APP=app.py # macOS/Linux
set FLASK_APP=app.py # Windows cmd
$env:FLASK_APP="app.py" # Windows PowerShell
export FLASK_DEBUG=1 # enable debug mode
flask run
This separates "how the app is defined" from "how it's launched," which becomes important once you have an application factory (Chapter 8).
Try it yourself: Modify the hello view to return your name, then restart the server using flask run instead of python3 app.py.
4. Routing
4.1 Static and Dynamic Routes
@app.route("/about")
def about():
return "About this site."
@app.route("/user/<username>")
def show_user(username):
return f"User: {username}"
@app.route("/post/<int:post_id>")
def show_post(post_id):
return f"Post number {post_id}"
Flask supports converters in dynamic segments: string (default), int, float, path (accepts slashes), and uuid.
4.2 HTTP Methods
By default a route only accepts GET. Specify others explicitly:
from flask import request
@app.route("/submit", methods=["GET", "POST"])
def submit():
if request.method == "POST":
return "Form submitted!"
return "Show the form."
4.3 Building URLs with url_for
Hard-coding URLs in templates and redirects is brittle. Use url_for with the view function's name instead:
from flask import url_for
@app.route("/")
def index():
return f'<a href="{url_for("show_user", username="mike")}">Profile</a>'
If you later change the route's path, every url_for call updates automatically — no find-and-replace needed.
4.4 Redirects
from flask import redirect, url_for
@app.route("/old-page")
def old_page():
return redirect(url_for("index"))
Try it yourself: Add a route /square/<int:n> that returns the square of n as plain text.
5. Templates
5.1 Jinja2 Basics
Flask uses Jinja2 for templating. Templates live in a templates/ folder next to your app.
templates/index.html:
<!doctype html>
<html>
<head><title>{{ title }}</title></head>
<body>
<h1>Hello, {{ name }}!</h1>
</body>
</html>
from flask import render_template
@app.route("/hello/<name>")
def hello_name(name):
return render_template("index.html", title="Greeting", name=name)
{{ ... }} outputs a value (auto-escaped for HTML safety). {% ... %} runs control-flow statements.
5.2 Control Structures
{% if items %}
<ul>
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
{% else %}
<p>No items.</p>
{% endif %}
5.3 Template Inheritance
templates/base.html:
<!doctype html>
<html>
<head><title>{% block title %}My Site{% endblock %}</title></head>
<body>
<nav>...</nav>
{% block content %}{% endblock %}
</body>
</html>
templates/page.html:
{% extends "base.html" %}
{% block title %}My Page{% endblock %}
{% block content %}
<p>Page-specific content goes here.</p>
{% endblock %}
5.4 Filters
{{ name|upper }}
{{ price|round(2) }}
{{ description|truncate(100) }}
5.5 Static Files
Place CSS, JS, and images in a static/ folder; reference them with url_for:
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
Try it yourself: Build a base.html with a nav bar, and two pages that extend it with different content blocks.
6. Request and Response Handling
6.1 The request Object
from flask import request
@app.route("/search")
def search():
query = request.args.get("q", "") # query string: /search?q=flask
return f"Searching for: {query}"
@app.route("/login", methods=["POST"])
def login():
username = request.form.get("username") # form data
return f"Welcome, {username}"
@app.route("/api/data", methods=["POST"])
def api_data():
data = request.get_json() # JSON body
return {"received": data}
Other useful attributes: request.method, request.headers, request.cookies, request.files (uploaded files).
6.2 Returning Responses
Flask accepts several return types from a view:
return "plain text" # text/html
return "<h1>HTML</h1>" # text/html
return {"key": "value"} # automatically JSON-encoded
return jsonify(key="value") # explicit JSON with correct headers
return "Not found", 404 # (body, status code)
return Response("data", mimetype="text/plain")
6.3 File Uploads
from werkzeug.utils import secure_filename
@app.route("/upload", methods=["POST"])
def upload():
file = request.files["photo"]
filename = secure_filename(file.filename)
file.save(f"uploads/{filename}")
return "Uploaded!"
secure_filename strips path components and dangerous characters from user-supplied filenames — always use it before saving to disk.
Try it yourself: Build a /echo endpoint that accepts JSON via POST and returns the same JSON back with an added "received_at" timestamp field.
7. Sessions and Cookies
7.1 Setting a Secret Key
Flask's session object stores data in a signed cookie on the client. Signing requires a secret key:
app.config["SECRET_KEY"] = "replace-with-a-random-secret-value"
Never hard-code a real secret key in source control; load it from an environment variable in production (Chapter 8 covers configuration properly).
7.2 Using Sessions
from flask import session
@app.route("/set-name/<name>")
def set_name(name):
session["username"] = name
return "Name saved."
@app.route("/whoami")
def whoami():
return session.get("username", "Anonymous")
7.3 Cookies Directly
from flask import make_response
@app.route("/set-cookie")
def set_cookie():
resp = make_response("Cookie set")
resp.set_cookie("visited", "true", max_age=60 * 60 * 24)
return resp
7.4 Flash Messages
Flash messages are one-time notifications stored in the session and displayed on the next request:
from flask import flash, redirect, url_for
@app.route("/do-something")
def do_something():
flash("Action completed successfully!")
return redirect(url_for("index"))
In the template:
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
Try it yourself: Build a tiny "visit counter" that increments a value in the session each time /count is visited.
8. Application Structure
As an app grows past a handful of routes, a single app.py becomes unwieldy. Flask offers two tools for scaling structure: blueprints and application factories.
8.1 Blueprints
A blueprint groups related routes, templates, and static files that can be registered on an app later.
myapp/routes/auth.py:
from flask import Blueprint
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
@auth_bp.route("/login")
def login():
return "Login page"
@auth_bp.route("/logout")
def logout():
return "Logged out"
8.2 The Application Factory Pattern
Instead of creating the Flask instance at module import time, wrap creation in a function. This makes testing and configuration much cleaner.
myapp/__init__.py:
from flask import Flask
def create_app(config_object="myapp.config.DevelopmentConfig"):
app = Flask(__name__)
app.config.from_object(config_object)
from myapp.routes.auth import auth_bp
app.register_blueprint(auth_bp)
return app
myapp/config.py:
import os
class BaseConfig:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-key-change-me")
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///app.db")
SQLALCHEMY_TRACK_MODIFICATIONS = False
class DevelopmentConfig(BaseConfig):
DEBUG = True
class ProductionConfig(BaseConfig):
DEBUG = False
class TestingConfig(BaseConfig):
TESTING = True
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
Running the factory-based app:
export FLASK_APP="myapp:create_app"
flask run
Try it yourself: Split your growing app into an auth blueprint and a main blueprint, then wire both into an application factory.
9. Databases
9.1 Installing Flask-SQLAlchemy
pip install flask-sqlalchemy flask-migrate
9.2 Defining Models
myapp/models.py:
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
def __repr__(self):
return f"<User {self.username}>"
Wire it into the factory:
from myapp.models import db
def create_app(config_object="myapp.config.DevelopmentConfig"):
app = Flask(__name__)
app.config.from_object(config_object)
db.init_app(app)
return app
9.3 Basic CRUD
from myapp.models import db, User
# Create
new_user = User(username="mike", email="mike@example.com")
db.session.add(new_user)
db.session.commit()
# Read
user = User.query.filter_by(username="mike").first()
all_users = User.query.all()
# Update
user.email = "new@example.com"
db.session.commit()
# Delete
db.session.delete(user)
db.session.commit()
9.4 Migrations with Flask-Migrate
export FLASK_APP="myapp:create_app"
flask db init
flask db migrate -m "create user table"
flask db upgrade
flask db migrate inspects the difference between your models and the current database schema and generates a migration script; flask db upgrade applies it. Repeat this cycle every time you change a model.
Try it yourself: Add a Post model with a foreign key to User, then generate and apply the migration.
10. Forms and Validation
10.1 Installing Flask-WTF
pip install flask-wtf
10.2 Defining a Form
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length
class RegistrationForm(FlaskForm):
username = StringField("Username", validators=[DataRequired(), Length(min=3, max=25)])
email = StringField("Email", validators=[DataRequired(), Email()])
password = PasswordField("Password", validators=[DataRequired(), Length(min=8)])
submit = SubmitField("Register")
10.3 Handling the Form in a View
@app.route("/register", methods=["GET", "POST"])
def register():
form = RegistrationForm()
if form.validate_on_submit():
# form.username.data, form.email.data, form.password.data
return redirect(url_for("index"))
return render_template("register.html", form=form)
10.4 Rendering the Form
<form method="POST">
{{ form.hidden_tag() }}
{{ form.username.label }} {{ form.username() }}
{% for error in form.username.errors %}<span>{{ error }}</span>{% endfor %}
{{ form.email.label }} {{ form.email() }}
{{ form.password.label }} {{ form.password() }}
{{ form.submit() }}
</form>
10.5 CSRF Protection
{{ form.hidden_tag() }} automatically includes a CSRF token generated from your SECRET_KEY. Flask-WTF validates it on submission and rejects the request if it's missing or invalid — protecting you from cross-site request forgery without any extra code.
Try it yourself: Add a LoginForm with username and password fields, plus a "remember me" checkbox using BooleanField.
11. Authentication
11.1 Installing Flask-Login
pip install flask-login
11.2 Setting Up
from flask_login import LoginManager
login_manager = LoginManager()
login_manager.login_view = "auth.login"
def create_app(...):
...
login_manager.init_app(app)
return app
11.3 A User Model Compatible with Flask-Login
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
generate_password_hash/check_password_hash (from Werkzeug) hash passwords with a salted algorithm — never store plaintext passwords.
11.4 Login and Logout Views
from flask_login import login_user, logout_user, login_required, current_user
@app.route("/login", methods=["GET", "POST"])
def login():
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user and user.check_password(form.password.data):
login_user(user)
return redirect(url_for("index"))
flash("Invalid credentials")
return render_template("login.html", form=form)
@app.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("index"))
@app.route("/profile")
@login_required
def profile():
return f"Logged in as {current_user.username}"
@login_required blocks anonymous access; unauthenticated users are redirected to login_manager.login_view.
Try it yourself: Add a registration flow that hashes the password with set_password before saving the new User.
12. REST APIs with Flask
12.1 A Minimal JSON API
You don't need an extra framework to build a JSON API in Flask — the pieces from earlier chapters compose directly:
from flask import jsonify, request, abort
users = {}
next_id = 1
@app.route("/api/users", methods=["GET"])
def list_users():
return jsonify(list(users.values()))
@app.route("/api/users/<int:user_id>", methods=["GET"])
def get_user(user_id):
user = users.get(user_id)
if user is None:
abort(404)
return jsonify(user)
@app.route("/api/users", methods=["POST"])
def create_user():
global next_id
data = request.get_json(silent=True)
if not data or "name" not in data:
return jsonify(error="name is required"), 400
user = {"id": next_id, "name": data["name"]}
users[next_id] = user
next_id += 1
return jsonify(user), 201
@app.route("/api/users/<int:user_id>", methods=["DELETE"])
def delete_user(user_id):
users.pop(user_id, None)
return "", 204
12.2 Consistent Error Responses
@app.errorhandler(404)
def not_found(e):
return jsonify(error="Resource not found"), 404
@app.errorhandler(400)
def bad_request(e):
return jsonify(error="Bad request"), 400
12.3 Versioning and Blueprints for APIs
For a real project, group API routes under a versioned blueprint:
api_v1 = Blueprint("api_v1", __name__, url_prefix="/api/v1")
This keeps room to introduce /api/v2 later without breaking existing clients.
12.4 CORS
If your API will be called from a browser-based frontend on a different origin, install flask-cors:
pip install flask-cors
from flask_cors import CORS
CORS(app, resources={r"/api/*": {"origins": "https://yourfrontend.com"}})
Try it yourself: Extend the users API with a PUT /api/users/<id> endpoint that updates a user's name.
13. Error Handling and Logging
13.1 Custom Error Pages
@app.errorhandler(404)
def page_not_found(e):
return render_template("404.html"), 404
@app.errorhandler(500)
def internal_error(e):
db.session.rollback() # important if the error happened mid-transaction
return render_template("500.html"), 500
13.2 Raising HTTP Errors Deliberately
from flask import abort
@app.route("/admin")
def admin():
if not current_user.is_admin:
abort(403)
return "Admin panel"
13.3 Logging
Flask apps get a configured logger via app.logger:
app.logger.info("User %s logged in", user.username)
app.logger.warning("Failed login attempt for %s", username)
app.logger.error("Unexpected error: %s", str(e))
For production, attach a rotating file handler so logs don't grow unbounded:
import logging
from logging.handlers import RotatingFileHandler
if not app.debug:
handler = RotatingFileHandler("app.log", maxBytes=1_000_000, backupCount=3)
handler.setLevel(logging.INFO)
app.logger.addHandler(handler)
Try it yourself: Add a custom 403 error page and log every unauthorized access attempt.
14. Testing
14.1 The Flask Test Client
Flask ships a built-in test client that simulates requests without running a real server.
import pytest
from myapp import create_app
from myapp.models import db
@pytest.fixture
def app():
app = create_app("myapp.config.TestingConfig")
with app.app_context():
db.create_all()
yield app
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
14.2 Writing Tests
def test_home_page(client):
response = client.get("/")
assert response.status_code == 200
assert b"Hello" in response.data
def test_create_user(client):
response = client.post("/api/users", json={"name": "Mike"})
assert response.status_code == 201
assert response.get_json()["name"] == "Mike"
def test_requires_login(client):
response = client.get("/profile")
assert response.status_code == 302 # redirected to login
14.3 Running Tests
pip install pytest
pytest
14.4 Testing with an Authenticated Session
def test_authenticated_profile(client, app):
with client.session_transaction() as sess:
sess["_user_id"] = "1" # Flask-Login session key
response = client.get("/profile")
assert response.status_code == 200
Try it yourself: Write a test that registers a user, logs in, and then accesses a protected route successfully.
15. Deployment
15.1 Why Not the Development Server
The flask run / app.run() server is single-threaded, unoptimized, and explicitly documented as unfit for production. For real deployments, use a production-grade WSGI server in front of your app.
15.2 Gunicorn (Linux/macOS)
pip install gunicorn
gunicorn "myapp:create_app()" --bind 0.0.0.0:8000 --workers 4
15.3 Waitress (Cross-platform, including Windows)
pip install waitress
waitress-serve --port=8000 "myapp:create_app()"
15.4 Environment-Based Configuration
Never hard-code secrets or database URLs. Load them from environment variables and keep a .env file (excluded from version control) for local development:
pip install python-dotenv
# myapp/config.py
import os
from dotenv import load_dotenv
load_dotenv()
class BaseConfig:
SECRET_KEY = os.environ["SECRET_KEY"]
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
15.5 A Minimal Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["gunicorn", "myapp:create_app()", "--bind", "0.0.0.0:8000", "--workers", "4"]
15.6 A Reverse Proxy
In production, Gunicorn/Waitress typically sit behind a reverse proxy (Nginx or a cloud load balancer) that handles TLS termination, static file serving, and request buffering. A full Nginx configuration is outside this book's scope, but keep in mind: set ProxyFix from Werkzeug if you're behind a proxy so Flask sees the correct client IP and scheme:
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
Try it yourself: Containerize your app with the Dockerfile above and run it locally with docker build and docker run.
16. Appendix
16.1 Useful Flask CLI Commands
flask run # start the dev server
flask shell # open a Python shell with app context
flask routes # list all registered routes
flask db init/migrate/upgrade # database migrations (Flask-Migrate)
16.2 Common Extensions
| Extension |
Purpose |
| Flask-SQLAlchemy |
ORM integration |
| Flask-Migrate |
Database migrations (via Alembic) |
| Flask-WTF |
Forms and CSRF protection |
| Flask-Login |
User session management |
| Flask-Mail |
Sending email |
| Flask-CORS |
Cross-origin resource sharing |
| Flask-RESTful / Flask-Smorest |
Structured REST API building |
| Flask-Caching |
Response and data caching |
| Flask-Limiter |
Rate limiting |
16.3 Further Resources
16.4 Where to Go Next
Once comfortable with everything in this book, natural next steps include: learning Alembic migrations in depth, exploring async views and background task queues (Celery or RQ) for long-running work, adding structured API schemas with Marshmallow or Pydantic, and setting up CI/CD for automated testing and deployment.