← Back to Home

Flask, end to end

Flask is a small Python web framework. It gives you routing, templating and a request object, then stays out of the way. Everything else — database, forms, login — you add when you need it.

This guide goes from an empty folder to a tested, deployable application: routing, Jinja templates, forms, sessions, SQLAlchemy, authentication, a JSON API, and a production checklist. Every snippet runs on Flask 3.x and Python 3.9+.

21 chaptersFlask 3.xPython 3.9+
app.py
from flask import Flask

app = Flask(__name__)

@app.route("/")
def home():
    return "Hello, World!"
flask run → http://127.0.0.1:5000 → Hello, World!
Chapter 1

What Flask is

Flask is a microframework. The "micro" means it ships with a small core and no opinions about your database, your form library or your project layout. It is built on two libraries by the same team:

  • Werkzeug — the WSGI layer: request and response objects, URL routing, the development server.
  • Jinja — the template engine that turns .html files plus data into finished pages.

A Flask app is a Python object that maps URLs to functions. When a request arrives, Flask matches the URL, calls your function, and turns whatever you return into an HTTP response. That is the whole model, and everything in this guide is a variation on it.

When to reach for it

Flask suits APIs, small-to-medium sites, prototypes, internal tools and anything where you want to choose your own pieces. Django is the better fit when you want an admin interface, an ORM and auth decided for you on day one. FastAPI is the better fit when you are building a typed, async-first JSON API and want automatic OpenAPI docs.

Vocabulary you will see

WSGI is the Python standard for talking between a web server and a Python app. Route is a URL pattern. View function is the function that handles a route. Endpoint is the name Flask gives a view so you can build URLs for it.

Chapter 2

Setup and first app

Every project gets its own virtual environment so its dependencies never collide with another project's.

terminal
mkdir myapp && cd myapp

# macOS / Linux
python3 -m venv .venv
source .venv/bin/activate

# Windows (PowerShell)
py -m venv .venv
.venv\Scripts\activate

pip install Flask

Your prompt now shows (.venv). Create app.py:

app.py
from flask import Flask

app = Flask(__name__)


@app.route("/")
def home():
    return "Hello, World!"


@app.route("/about")
def about():
    return "<h1>About this site</h1>"

Flask(__name__) tells Flask where the app lives so it can find your templates/ and static/ folders. @app.route registers the function below it for that URL.

Running it

terminal
flask --app app run --debug

Open http://127.0.0.1:5000. If your file is named app.py or wsgi.py, you can shorten this to flask run --debug.

--debug does two things: it reloads the server when you save a file, and it shows an interactive traceback in the browser when something breaks. That traceback lets anyone who can reach the page execute Python on your machine, so it is strictly a local-development tool.

Recording dependencies

terminal
pip freeze > requirements.txt   # save
pip install -r requirements.txt # restore, on another machine

Add .venv/, __pycache__/, *.db and .env to .gitignore.

Chapter 3

Routing and URLs

Parts of a URL can be captured and passed into the view as arguments. The type in angle brackets is a converter: it both restricts what matches and converts the value.

routes
@app.route("/user/<username>")
def profile(username):
    return f"Profile of {username}"


@app.route("/post/<int:post_id>")
def show_post(post_id):
    return f"Post number {post_id + 1}"   # already an int


@app.route("/files/<path:filepath>")
def serve(filepath):
    return f"You asked for {filepath}"       # slashes allowed
ConverterMatchesExample
stringany text without a slash (the default)/user/ada
intpositive integers/post/42
floatdecimal numbers/price/9.99
pathtext including slashes/files/docs/a.txt
uuidUUID strings/job/8f14e4…

Building URLs with url_for

Never hard-code links. url_for takes the view's name and builds the URL, so changing a route in one place fixes every link to it. Extra keyword arguments become the query string.

url_for
from flask import url_for

url_for("home")                       # /
url_for("show_post", post_id=7)        # /post/7
url_for("profile", username="ada", tab="bio")
                                      # /user/ada?tab=bio
url_for("static", filename="style.css")
                                      # /static/style.css

Trailing slashes

A rule written as /projects/ also answers /projects with a redirect to the canonical slashed form. A rule written as /about returns 404 for /about/. Pick one style and keep to it.

Registering routes without the decorator

add_url_rule
app.add_url_rule("/health", "health", lambda: "ok")
# path, endpoint name, view function
Chapter 4

Requests and methods

By default a route only answers GET. List the methods you want to accept:

methods
from flask import request

@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        user = request.form["username"]        # 400 if missing
        pw   = request.form.get("password", "") # default if missing
        return f"Welcome {user}"
    return "<form method='post'>…</form>"

Flask 2.2+ also has shorthand decorators: @app.get(), @app.post(), @app.put(), @app.patch(), @app.delete().

What the request object holds

AttributeContains
request.argsquery string values — ?page=2
request.formsubmitted form fields
request.jsonparsed JSON body (requires the JSON content type)
request.get_json(silent=True)same, but returns None instead of raising
request.filesuploaded files
request.headersrequest headers
request.cookiescookies sent by the browser
request.method, .path, .urlmethod and address
request.remote_addrclient IP (see the proxy note in chapter 21)

args and form are multi-dicts: use getlist() when a field can repeat, and type= to coerce.

reading query strings safely
page = request.args.get("page", 1, type=int)   # 1 if absent or junk
tags = request.args.getlist("tag")            # ?tag=a&tag=b -> ["a","b"]
Treat everything in request as hostile

Query strings, form fields and headers all come from the client and can be anything. Validate types, check lengths, and never interpolate them into SQL or shell commands.

Chapter 5

Responses and redirects

Whatever a view returns is converted into a response. Flask accepts several shapes:

return values
return "plain text or html"                 # 200
return "Not found", 404                    # body + status
return "Created", 201, {"Location": "/post/7"}
return {"id": 7, "title": "Hello"}         # dict -> JSON
return [1, 2, 3]                            # list -> JSON

Shaping the response object

make_response
from flask import make_response, jsonify

@app.get("/api/time")
def now():
    resp = make_response(jsonify(time="12:00"))
    resp.headers["Cache-Control"] = "no-store"
    resp.set_cookie("seen", "1", max_age=3600, httponly=True, samesite="Lax")
    return resp

Redirects and aborting

redirect / abort
from flask import redirect, url_for, abort

@app.post("/posts")
def create():
    # … save …
    return redirect(url_for("show_post", post_id=7))


@app.get("/admin")
def admin():
    if not user_is_admin():
        abort(403)              # raises, stops here
    return "Dashboard"

Always redirect after a successful POST. It stops the browser from re-submitting the form when the user refreshes.

Sending files

send_file
from flask import send_file, send_from_directory

return send_file("report.pdf", as_attachment=True)
return send_from_directory("uploads", filename)  # safe path join
Chapter 6

Jinja templates

Put HTML files in a templates/ folder next to app.py and render them with render_template. Keyword arguments become variables inside the template.

app.py
from flask import render_template

@app.get("/posts")
def posts():
    items = [{"id": 1, "title": "First"}, {"id": 2, "title": "Second"}]
    return render_template("posts.html", posts=items, user="Ada")

Syntax

Jinja has two delimiters: {{ }} prints a value, {% %} runs a statement.

templates/posts.html
<h1>Hello, {{ user }}</h1>

{% if posts %}
  <ul>
  {% for post in posts %}
    <li>
      <a href="{{ url_for('show_post', post_id=post.id) }}">{{ post.title }}</a>
      {% if loop.first %} <em>newest</em>{% endif %}
    </li>
  {% endfor %}
  </ul>
{% else %}
  <p>Nothing published yet.</p>
{% endif %}

Inside a loop, loop.index (1-based), loop.index0, loop.first, loop.last and loop.length are available.

Filters

A filter transforms a value with |. They chain.

common filters
{{ name|upper }}
{{ price|round(2) }}
{{ body|truncate(140) }}
{{ items|length }}
{{ missing|default("n/a") }}
{{ text|replace("a", "b")|title }}
{{ html_string|safe }}          {# render as HTML, do not escape #}
Autoescaping

Jinja escapes <, > and & in every variable it prints, which is what stops cross-site scripting. |safe switches that off for one value. Only use it on HTML you generated yourself, never on anything a user typed.

Inheritance

One base layout, many pages that fill in its blocks. This is the single most useful Jinja feature.

templates/base.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>{% block title %}My site{% endblock %}</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
  <nav><a href="{{ url_for('home') }}">Home</a></nav>
  <main>{% block content %}{% endblock %}</main>
</body>
</html>
templates/posts.html
{% extends "base.html" %}

{% block title %}Posts · {{ super() }}{% endblock %}

{% block content %}
  <h1>Posts</h1>
  …
{% endblock %}

Includes and macros

include pastes another template in. A macro is a reusable function that outputs markup.

templates/macros.html
{% macro field(name, label, type="text", value="") %}
  <label>{{ label }}
    <input name="{{ name }}" type="{{ type }}" value="{{ value }}">
  </label>
{% endmacro %}

{# use it elsewhere #}
{% from "macros.html" import field %}
{{ field("email", "Email address", type="email") }}

Giving every template the same variable

context processor
@app.context_processor
def inject_globals():
    return {"site_name": "My site", "year": 2026}
# {{ site_name }} now works in every template
Chapter 7

Static files

CSS, JavaScript, images and fonts go in a static/ folder. Flask serves them at /static/… automatically in development.

myapp/ ├── app.py ├── templates/ │ ├── base.html │ └── posts.html └── static/ ├── style.css ├── app.js └── img/logo.svg
linking assets
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script src="{{ url_for('static', filename='app.js') }}" defer></script>
<img src="{{ url_for('static', filename='img/logo.svg') }}" alt="Logo">

Browsers cache aggressively. During development, hard-refresh if a stylesheet change does not appear. In production, put a version in the URL (style.css?v=3) or let a build tool add a content hash to the filename.

Chapter 8

Forms

A plain HTML form posts to a route; you read request.form, validate, then redirect. The pattern below — render on GET, process on POST, redirect on success, re-render with errors on failure — is the backbone of every form in Flask.

app.py
@app.route("/signup", methods=["GET", "POST"])
def signup():
    errors = {}
    if request.method == "POST":
        email = request.form.get("email", "").strip()
        pw    = request.form.get("password", "")

        if "@" not in email:
            errors["email"] = "Enter a valid email address."
        if len(pw) < 8:
            errors["password"] = "Use at least 8 characters."

        if not errors:
            create_user(email, pw)
            return redirect(url_for("home"))

    return render_template("signup.html", errors=errors)

Flask-WTF

For anything beyond a couple of fields, Flask-WTF defines the form once as a class and handles validation, re-rendering and CSRF protection.

terminal
pip install Flask-WTF
forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo


class SignupForm(FlaskForm):
    email = StringField("Email", validators=[DataRequired(), Email()])
    password = PasswordField("Password", validators=[DataRequired(), Length(min=8)])
    confirm = PasswordField("Repeat password", validators=[EqualTo("password")])
    submit = SubmitField("Create account")
view + template
@app.route("/signup", methods=["GET", "POST"])
def signup():
    form = SignupForm()
    if form.validate_on_submit():        # POST + all validators pass
        create_user(form.email.data, form.password.data)
        return redirect(url_for("home"))
    return render_template("signup.html", form=form)
templates/signup.html
<form method="post">
  {{ form.hidden_tag() }}              {# CSRF token #}
  {{ form.email.label }} {{ form.email() }}
  {% for e in form.email.errors %}<p class="error">{{ e }}</p>{% endfor %}

  {{ form.password.label }} {{ form.password() }}
  {% for e in form.password.errors %}<p class="error">{{ e }}</p>{% endfor %}

  {{ form.submit() }}
</form>
What CSRF protection does

Without it, another site can make a logged-in visitor's browser submit a form to yours. Flask-WTF puts a secret token in each form and rejects submissions that do not carry it. It needs SECRET_KEY to be set.

Chapter 9

Sessions and flash messages

HTTP has no memory between requests. The session object gives you one: it behaves like a dict, and Flask stores it in a cookie that is cryptographically signed with your SECRET_KEY.

session
from flask import session

app.secret_key = os.environ["SECRET_KEY"]   # required

@app.post("/login")
def login():
    session["user_id"] = 7
    session.permanent = True                # survives browser restart
    return redirect(url_for("home"))

@app.post("/logout")
def logout():
    session.clear()
    return redirect(url_for("home"))
Signed, not encrypted

Anyone holding the cookie can read its contents; they just cannot change it without the key. Keep passwords, tokens and personal data out of the session — store an ID and look the rest up server-side. Generate a key with python -c "import secrets; print(secrets.token_hex(32))" and keep it out of version control.

Flash messages

A flash is a one-shot message stored in the session and displayed on the next page — the "Saved." banner after a redirect.

flashing
from flask import flash

flash("Your changes were saved.", "success")
flash("That email is already registered.", "error")
templates/base.html
{% with messages = get_flashed_messages(with_categories=true) %}
  {% for category, message in messages %}
    <div class="alert alert-{{ category }}">{{ message }}</div>
  {% endfor %}
{% endwith %}

Cookies directly

cookies
theme = request.cookies.get("theme", "light")

resp = make_response(render_template("page.html"))
resp.set_cookie("theme", "dark", max_age=60*60*24*365,
                httponly=True, secure=True, samesite="Lax")
return resp
Chapter 10

Error handling

Register a handler per status code or exception type. Return a body and the status.

error handlers
@app.errorhandler(404)
def not_found(e):
    return render_template("404.html"), 404


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


@app.errorhandler(ValueError)          # by exception class
def bad_value(e):
    return {"error": str(e)}, 400

Raising HTTP errors on purpose

abort and get_or_404
from flask import abort

post = Post.query.get(post_id)
if post is None:
    abort(404)

# or, with Flask-SQLAlchemy:
post = db.get_or_404(Post, post_id)

Errors for an API

If a client asked for JSON, answer with JSON rather than an HTML error page.

content-aware errors
from werkzeug.exceptions import HTTPException

@app.errorhandler(HTTPException)
def handle_http_error(e):
    if request.path.startswith("/api/"):
        return {"error": e.name, "detail": e.description}, e.code
    return render_template("error.html", error=e), e.code
Chapter 11

Config and the app factory

Settings live on app.config, a dict with a few helpers for loading values from elsewhere.

config.py
import os


class Config:
    SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-not-for-production")
    SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///app.db")
    MAX_CONTENT_LENGTH = 16 * 1024 * 1024     # 16 MB uploads


class DevConfig(Config):
    DEBUG = True


class ProdConfig(Config):
    SESSION_COOKIE_SECURE = True
    SESSION_COOKIE_HTTPONLY = True
    SESSION_COOKIE_SAMESITE = "Lax"
loading config
app.config.from_object(DevConfig)
app.config.from_prefixed_env()        # reads FLASK_* environment vars
app.config["SECRET_KEY"]

Keeping secrets in a .env file

terminal + .env
pip install python-dotenv

# .env  (never commit this file)
SECRET_KEY=6f1c…
DATABASE_URL=postgresql://user:pass@localhost/mydb

With python-dotenv installed, the flask command loads .env automatically. Under a production server, call load_dotenv() yourself or set real environment variables.

The application factory

Instead of creating app at import time, create it inside a function. This lets you build one app configured for production and a different one for tests, and it removes the circular-import problems that appear once a project has more than one module.

app/__init__.py
from flask import Flask
from .extensions import db, migrate, login_manager


def create_app(config_object="config.DevConfig"):
    app = Flask(__name__)
    app.config.from_object(config_object)

    db.init_app(app)
    migrate.init_app(app, db)
    login_manager.init_app(app)

    from .blog import bp as blog_bp
    from .auth import bp as auth_bp
    app.register_blueprint(blog_bp)
    app.register_blueprint(auth_bp, url_prefix="/auth")

    return app
app/extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager

db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
login_manager.login_view = "auth.login"

Extensions are created empty here and bound to an app inside the factory with init_app. Run it with flask --app "app:create_app" run --debug.

Chapter 12

Blueprints and project structure

A blueprint is a group of routes, templates and static files that gets registered onto an app. Think of it as a section of the site — auth, blog, admin — in its own module.

app/blog/__init__.py
from flask import Blueprint, render_template

bp = Blueprint("blog", __name__, template_folder="templates")


@bp.get("/")
def index():
    return render_template("blog/index.html")


@bp.get("/post/<int:post_id>")
def show(post_id):
    return render_template("blog/show.html", post_id=post_id)

Endpoints inside a blueprint are namespaced by its name, so links use url_for("blog.show", post_id=1). Inside a template belonging to the same blueprint you can shorten that to url_for(".show", post_id=1).

A layout that scales

myapp/ ├── app/ │ ├── __init__.py create_app() lives here │ ├── extensions.py db, migrate, login_manager │ ├── models.py database tables │ ├── auth/ │ │ ├── __init__.py blueprint + routes │ │ └── forms.py │ ├── blog/ │ │ ├── __init__.py │ │ └── forms.py │ ├── templates/ │ │ ├── base.html │ │ ├── auth/login.html │ │ └── blog/index.html │ └── static/ ├── migrations/ created by Flask-Migrate ├── tests/ │ ├── conftest.py │ └── test_blog.py ├── config.py ├── requirements.txt ├── .env secrets, untracked └── wsgi.py app = create_app()

Blueprint-local hooks and errors

scoped to one blueprint
@bp.before_request
def require_login():
    if "user_id" not in session:
        return redirect(url_for("auth.login"))


@bp.errorhandler(404)
def blog_404(e):
    return render_template("blog/404.html"), 404
Chapter 13

Databases with SQLAlchemy

Flask-SQLAlchemy maps Python classes to database tables. Start on SQLite, which needs no server, and switch the connection string later to move to Postgres.

terminal
pip install Flask-SQLAlchemy
app/models.py
from datetime import datetime, timezone
from .extensions import db


class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(120), unique=True, nullable=False, index=True)
    password_hash = db.Column(db.String(256), nullable=False)
    posts = db.relationship("Post", back_populates="author", cascade="all, delete-orphan")

    def __repr__(self):
        return f"<User {self.email}>"


class Post(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    body = db.Column(db.Text, default="")
    created = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
    author = db.relationship("User", back_populates="posts")

Creating the tables

first run
with app.app_context():
    db.create_all()

create_all() only creates tables that do not exist; it will not alter an existing one. Once the schema starts changing, move to migrations (next chapter).

Querying

reads
from sqlalchemy import select

# modern SQLAlchemy 2.x style
posts = db.session.scalars(
    select(Post).where(Post.title.ilike("%flask%"))
                .order_by(Post.created.desc())
                .limit(10)
).all()

one = db.session.get(Post, 7)          # by primary key, or None
one = db.get_or_404(Post, 7)           # or raise 404

# paginate (Flask-SQLAlchemy helper)
page = db.paginate(select(Post).order_by(Post.created.desc()), per_page=20)
page.items, page.has_next, page.next_num
writes
post = Post(title="Hello", body="First post", author=current_user)
db.session.add(post)
db.session.commit()

post.title = "Hello, edited"
db.session.commit()

db.session.delete(post)
db.session.commit()
Nothing is saved until you commit

add() stages an object; commit() writes the transaction. If something fails mid-way, call db.session.rollback() before using the session again.

Filter reference

GoalExpression
EqualsPost.title == "Hi"
Case-insensitive containsPost.title.ilike("%hi%")
In a listPost.id.in_([1, 2, 3])
Null checkPost.body.is_(None)
And / orand_(a, b), or_(a, b)
Countdb.session.scalar(select(func.count()).select_from(Post))
Chapter 14

Migrations

A migration is a versioned script that changes the database schema. Flask-Migrate wraps Alembic and compares your models to the live database to generate them.

terminal
pip install Flask-Migrate

flask db init                          # once, creates migrations/
flask db migrate -m "add posts table"  # generate a script
flask db upgrade                       # apply it

flask db downgrade                     # undo the last one
flask db current                       # which version is applied
Always read the generated script

Autogeneration misses some changes — renamed columns look like a drop plus an add, which destroys data. Open the file in migrations/versions/ and correct it before upgrading, and take a backup before running migrations in production.

Custom CLI commands

Register your own commands for seeding data and other chores.

app/__init__.py
import click

@app.cli.command("seed")
def seed():
    """Insert sample data."""
    db.session.add(User(email="ada@example.com", password_hash="…"))
    db.session.commit()
    click.echo("Seeded.")

# flask seed
Chapter 15

Authentication

Two parts: store passwords as hashes, and track who is logged in. Werkzeug supplies the hashing; Flask-Login supplies the session handling.

hashing passwords
from werkzeug.security import generate_password_hash, check_password_hash


class User(db.Model, UserMixin):
    # … columns …

    def set_password(self, raw):
        self.password_hash = generate_password_hash(raw)

    def check_password(self, raw):
        return check_password_hash(self.password_hash, raw)

Never store a raw password, and never email one back to a user. A hash is one-way: you can check a guess against it, but you cannot recover the original.

terminal
pip install Flask-Login
app/auth/__init__.py
from flask_login import login_user, logout_user, login_required, current_user
from ..extensions import db, login_manager
from ..models import User


@login_manager.user_loader
def load_user(user_id):
    return db.session.get(User, int(user_id))


@bp.route("/login", methods=["GET", "POST"])
def login():
    form = LoginForm()
    if form.validate_on_submit():
        user = db.session.scalar(select(User).where(User.email == form.email.data))
        if user and user.check_password(form.password.data):
            login_user(user, remember=form.remember.data)
            return redirect(url_for("blog.index"))
        flash("Email or password is incorrect.", "error")
    return render_template("auth/login.html", form=form)


@bp.post("/logout")
@login_required
def logout():
    logout_user()
    return redirect(url_for("blog.index"))

Give the same message for a wrong email and a wrong password, so the form cannot be used to discover which addresses are registered.

Protecting pages

in views and templates
@bp.get("/dashboard")
@login_required
def dashboard():
    return render_template("dashboard.html")
templates/base.html
{% if current_user.is_authenticated %}
  <span>{{ current_user.email }}</span>
  <form method="post" action="{{ url_for('auth.logout') }}"><button>Log out</button></form>
{% else %}
  <a href="{{ url_for('auth.login') }}">Log in</a>
{% endif %}

Roles

a custom decorator
from functools import wraps


def admin_required(view):
    @wraps(view)
    @login_required
    def wrapped(*args, **kwargs):
        if not current_user.is_admin:
            abort(403)
        return view(*args, **kwargs)
    return wrapped
Chapter 16

Building a JSON API

Return dicts and lists and Flask serialises them. Give the resource a method for turning itself into plain data.

app/api.py
from flask import Blueprint, request

api = Blueprint("api", __name__, url_prefix="/api")


def serialize(post):
    return {
        "id": post.id,
        "title": post.title,
        "body": post.body,
        "created": post.created.isoformat(),
    }


@api.get("/posts")
def list_posts():
    posts = db.session.scalars(select(Post).order_by(Post.created.desc())).all()
    return {"posts": [serialize(p) for p in posts]}


@api.get("/posts/<int:post_id>")
def get_post(post_id):
    return serialize(db.get_or_404(Post, post_id))


@api.post("/posts")
def create_post():
    data = request.get_json(silent=True) or {}
    title = (data.get("title") or "").strip()
    if not title:
        return {"error": "title is required"}, 400

    post = Post(title=title, body=data.get("body", ""), user_id=1)
    db.session.add(post)
    db.session.commit()
    return serialize(post), 201, {"Location": url_for("api.get_post", post_id=post.id)}


@api.delete("/posts/<int:post_id>")
def delete_post(post_id):
    post = db.get_or_404(Post, post_id)
    db.session.delete(post)
    db.session.commit()
    return "", 204

Status codes worth knowing

CodeUse it when
200the request succeeded and there is a body
201a resource was created; add a Location header
204it worked and there is nothing to return
400the body or parameters are malformed
401 / 403not authenticated / authenticated but not allowed
404no such resource
422well-formed but fails validation
429rate limited

Calling it from a browser on another origin

CORS
pip install Flask-Cors

from flask_cors import CORS
CORS(app, resources={r"/api/*": {"origins": ["https://myfrontend.com"]}})

List the origins you actually serve. origins="*" is fine for a public read-only API and wrong for anything that carries credentials.

Chapter 17

File uploads

The form needs enctype="multipart/form-data"; the file arrives in request.files.

upload view
import os, uuid
from werkzeug.utils import secure_filename

ALLOWED = {"png", "jpg", "jpeg", "gif", "pdf"}
app.config["UPLOAD_FOLDER"] = "uploads"
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024


@app.post("/upload")
def upload():
    file = request.files.get("file")
    if not file or file.filename == "":
        flash("Choose a file first.", "error")
        return redirect(request.url)

    ext = file.filename.rsplit(".", 1)[-1].lower()
    if ext not in ALLOWED:
        flash("That file type is not accepted.", "error")
        return redirect(request.url)

    name = f"{uuid.uuid4().hex}.{ext}"          # avoid collisions
    file.save(os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(name)))
    flash("Uploaded.", "success")
    return redirect(url_for("home"))
serving them back
@app.get("/uploads/<name>")
def uploaded_file(name):
    return send_from_directory(app.config["UPLOAD_FOLDER"], name)
Uploads are user input too

Store files outside the folder your web server executes from, never trust the client's filename or content type, and set MAX_CONTENT_LENGTH so a large upload cannot exhaust memory. On most hosts the local disk is temporary — use object storage for anything that must survive a restart.

Chapter 18

Hooks, g and the application context

Hooks run around every request in the app or blueprint they are registered on.

request hooks
import time
from flask import g


@app.before_request
def start_timer():
    g.start = time.perf_counter()


@app.after_request                     # skipped if an exception escaped
def add_headers(response):
    response.headers["X-Runtime"] = f"{time.perf_counter() - g.start:.3f}s"
    response.headers["X-Content-Type-Options"] = "nosniff"
    return response                    # must return it


@app.teardown_request                  # always runs
def cleanup(exception):
    pass

g is a scratchpad that lives for exactly one request — a good place to cache the current user or an open connection, and a bad place for anything that should outlive the response.

The two contexts

Flask pushes an application context (which powers current_app and g) and a request context (which powers request and session). Inside a view both exist. Outside one — in a script, a shell, a background job — you have to push one yourself, which is the cause of the familiar "working outside of application context" error.

outside a request
app = create_app()

with app.app_context():
    db.create_all()
    print(current_app.config["SECRET_KEY"])
a shell with everything imported
@app.shell_context_processor
def shell_objects():
    return {"db": db, "User": User, "Post": Post}

# flask shell
Chapter 19

Logging and debugging

Every app has a logger. Use it instead of print, because it carries levels, timestamps and goes wherever your host collects output.

logging
app.logger.debug("query took %s ms", ms)
app.logger.info("user %s logged in", user.id)
app.logger.warning("payment retry %s", attempt)
app.logger.error("checkout failed", exc_info=True)   # includes traceback
a rotating file log in production
import logging
from logging.handlers import RotatingFileHandler

if not app.debug:
    handler = RotatingFileHandler("logs/app.log", maxBytes=1_000_000, backupCount=5)
    handler.setFormatter(logging.Formatter(
        "%(asctime)s %(levelname)s %(message)s [in %(pathname)s:%(lineno)d]"))
    handler.setLevel(logging.INFO)
    app.logger.addHandler(handler)
    app.logger.setLevel(logging.INFO)

Errors you will meet early

MessageCause
Working outside of application contextUsing db, current_app or g outside a request — wrap it in with app.app_context():
Could not build url for endpointWrong endpoint name in url_for; with blueprints it needs the blueprint.view form
TemplateNotFoundThe file is not in templates/, or the path in render_template is misspelled
The session is unavailable because no secret key was setSECRET_KEY is missing
Method Not Allowed (405)The route does not list that method
Address already in useAn old server is still running; stop it or use --port 5001

flask routes prints every registered rule with its endpoint and methods — the fastest way to diagnose a 404 or a bad url_for.

Chapter 20

Testing

Flask's test client makes requests without a running server. With an app factory, each test gets a fresh app pointed at a throwaway database.

terminal
pip install pytest
tests/conftest.py
import pytest
from app import create_app
from app.extensions import db as _db


@pytest.fixture
def app():
    app = create_app("config.Config")
    app.config.update(TESTING=True,
                      SQLALCHEMY_DATABASE_URI="sqlite:///:memory:",
                      WTF_CSRF_ENABLED=False)
    with app.app_context():
        _db.create_all()
        yield app
        _db.session.remove()
        _db.drop_all()


@pytest.fixture
def client(app):
    return app.test_client()


@pytest.fixture
def runner(app):
    return app.test_cli_runner()
tests/test_blog.py
def test_home_page(client):
    r = client.get("/")
    assert r.status_code == 200
    assert b"Posts" in r.data


def test_create_post_api(client):
    r = client.post("/api/posts", json={"title": "Hi"})
    assert r.status_code == 201
    assert r.get_json()["title"] == "Hi"


def test_missing_title_is_rejected(client):
    assert client.post("/api/posts", json={}).status_code == 400


def test_login_redirects(client):
    r = client.post("/auth/login",
                    data={"email": "ada@example.com", "password": "secret123"},
                    follow_redirects=True)
    assert b"ada@example.com" in r.data

Run everything with pytest -q. To inspect the session inside a test, use with client.session_transaction() as sess:.

Chapter 21

Deployment

The development server handles one thing at a time and is not hardened. In production, a WSGI server runs your app and a reverse proxy sits in front of it terminating TLS and serving static files.

wsgi.py
from app import create_app

app = create_app("config.ProdConfig")
terminal
pip install gunicorn

gunicorn --workers 4 --bind 0.0.0.0:8000 wsgi:app

A reasonable worker count is (2 × CPU cores) + 1. On Windows, use waitress-serve --port=8000 wsgi:app instead.

Behind a proxy

Nginx or your platform's load balancer forwards the original scheme and client IP in headers. Tell Flask to trust them, or url_for(_external=True) will generate http:// links and request.remote_addr will be the proxy.

wsgi.py
from werkzeug.middleware.proxy_fix import ProxyFix

app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)

Docker

Dockerfile
FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
EXPOSE 8000
CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:8000", "wsgi:app"]

Before you go live

  • SECRET_KEY comes from the environment and is long and random.
  • DEBUG is off. The interactive debugger must never be reachable from the internet.
  • Session cookies are Secure, HttpOnly and SameSite=Lax; the whole site is on HTTPS.
  • Migrations have been applied, and you have a tested database backup.
  • Static files are served by the proxy or a CDN, not by Python.
  • MAX_CONTENT_LENGTH is set; uploads go to object storage.
  • Logs go somewhere you will actually read, and unhandled errors reach an error tracker.
  • Login, signup and password reset are rate limited (Flask-Limiter).
  • Dependencies are pinned in requirements.txt.

Where to go next

Useful extensions once the basics are in place: Flask-Limiter for rate limiting, Flask-Mail for outbound email, Flask-Caching for page and query caching, Celery or RQ for background jobs, and Flask-Admin for a quick CRUD interface over your models.