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
.htmlfiles 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.
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.
Setup and first app
Every project gets its own virtual environment so its dependencies never collide with another project's.
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:
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
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
pip freeze > requirements.txt # save pip install -r requirements.txt # restore, on another machine
Add .venv/, __pycache__/, *.db and .env to .gitignore.
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.
@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
| Converter | Matches | Example |
|---|---|---|
string | any text without a slash (the default) | /user/ada |
int | positive integers | /post/42 |
float | decimal numbers | /price/9.99 |
path | text including slashes | /files/docs/a.txt |
uuid | UUID 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.
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
app.add_url_rule("/health", "health", lambda: "ok") # path, endpoint name, view function
Requests and methods
By default a route only answers GET. List the methods you want to accept:
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
| Attribute | Contains |
|---|---|
request.args | query string values — ?page=2 |
request.form | submitted form fields |
request.json | parsed JSON body (requires the JSON content type) |
request.get_json(silent=True) | same, but returns None instead of raising |
request.files | uploaded files |
request.headers | request headers |
request.cookies | cookies sent by the browser |
request.method, .path, .url | method and address |
request.remote_addr | client 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.
page = request.args.get("page", 1, type=int) # 1 if absent or junk tags = request.args.getlist("tag") # ?tag=a&tag=b -> ["a","b"]
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.
Responses and redirects
Whatever a view returns is converted into a response. Flask accepts several shapes:
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
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
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
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
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.
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.
<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.
{{ 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 #}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.
<!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>{% 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.
{% 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
@app.context_processor def inject_globals(): return {"site_name": "My site", "year": 2026} # {{ site_name }} now works in every template
Static files
CSS, JavaScript, images and fonts go in a static/ folder. Flask serves them at /static/… automatically in development.
<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.
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.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.
pip install Flask-WTF
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")
@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)
<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>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.
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.
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"))
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.
from flask import flash flash("Your changes were saved.", "success") flash("That email is already registered.", "error")
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}Cookies directly
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
Error handling
Register a handler per status code or exception type. Return a body and the status.
@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
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.
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
Config and the app factory
Settings live on app.config, a dict with a few helpers for loading values from elsewhere.
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"
app.config.from_object(DevConfig) app.config.from_prefixed_env() # reads FLASK_* environment vars app.config["SECRET_KEY"]
Keeping secrets in a .env file
pip install python-dotenv
# .env (never commit this file)
SECRET_KEY=6f1c…
DATABASE_URL=postgresql://user:pass@localhost/mydbWith 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.
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
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.
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.
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
Blueprint-local hooks and errors
@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
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.
pip install Flask-SQLAlchemy
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
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
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
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()
add() stages an object; commit() writes the transaction. If something fails mid-way, call db.session.rollback() before using the session again.
Filter reference
| Goal | Expression |
|---|---|
| Equals | Post.title == "Hi" |
| Case-insensitive contains | Post.title.ilike("%hi%") |
| In a list | Post.id.in_([1, 2, 3]) |
| Null check | Post.body.is_(None) |
| And / or | and_(a, b), or_(a, b) |
| Count | db.session.scalar(select(func.count()).select_from(Post)) |
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.
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
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.
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
Authentication
Two parts: store passwords as hashes, and track who is logged in. Werkzeug supplies the hashing; Flask-Login supplies the session handling.
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.
pip install Flask-Login
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
@bp.get("/dashboard") @login_required def dashboard(): return render_template("dashboard.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
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
Building a JSON API
Return dicts and lists and Flask serialises them. Give the resource a method for turning itself into plain data.
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
| Code | Use it when |
|---|---|
| 200 | the request succeeded and there is a body |
| 201 | a resource was created; add a Location header |
| 204 | it worked and there is nothing to return |
| 400 | the body or parameters are malformed |
| 401 / 403 | not authenticated / authenticated but not allowed |
| 404 | no such resource |
| 422 | well-formed but fails validation |
| 429 | rate limited |
Calling it from a browser on another origin
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.
File uploads
The form needs enctype="multipart/form-data"; the file arrives in request.files.
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"))
@app.get("/uploads/<name>") def uploaded_file(name): return send_from_directory(app.config["UPLOAD_FOLDER"], name)
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.
Hooks, g and the application context
Hooks run around every request in the app or blueprint they are registered on.
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.
app = create_app() with app.app_context(): db.create_all() print(current_app.config["SECRET_KEY"])
@app.shell_context_processor def shell_objects(): return {"db": db, "User": User, "Post": Post} # flask shell
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.
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
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
| Message | Cause |
|---|---|
| Working outside of application context | Using db, current_app or g outside a request — wrap it in with app.app_context(): |
| Could not build url for endpoint | Wrong endpoint name in url_for; with blueprints it needs the blueprint.view form |
| TemplateNotFound | The file is not in templates/, or the path in render_template is misspelled |
| The session is unavailable because no secret key was set | SECRET_KEY is missing |
| Method Not Allowed (405) | The route does not list that method |
| Address already in use | An 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.
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.
pip install pytest
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()
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:.
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.
from app import create_app app = create_app("config.ProdConfig")
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.
from werkzeug.middleware.proxy_fix import ProxyFix app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
Docker
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_KEYcomes from the environment and is long and random.DEBUGis off. The interactive debugger must never be reachable from the internet.- Session cookies are
Secure,HttpOnlyandSameSite=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_LENGTHis 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.