Flask Mastery
The micro-framework that grew up. Every concept, every gotcha, every pattern — from hello world to production, explained with code.
What is Flask?
What is it
Flask is a lightweight WSGI web framework for Python, created by Armin Ronacher and first released on April 1, 2010 — it literally began as an April Fools' joke that turned into one of the most-used web frameworks on the planet. It's part of the Pallets Projects and sits on top of two libraries: Werkzeug (a WSGI toolkit that handles requests, responses, routing, and the dev server) and Jinja2 (the template engine). It also uses itsdangerous (cryptographic signing for sessions) and click (the CLI).
Flask calls itself a "micro" framework — not because it's only for small apps, but because the core is tiny and unopinionated. There's no built-in ORM, no form validation, no auth, no admin panel. You add exactly what you need through extensions (Flask-SQLAlchemy, Flask-Login, Flask-WTF, Flask-Migrate…). Flask gives you the skeleton; you choose the organs.
Key features
- Decorator-based routing:
@app.route("/users")maps a URL to a Python function (a "view function"). - Jinja2 templates: full server-side HTML rendering with inheritance, filters, macros, and automatic XSS escaping.
- Built-in dev server + debugger: auto-reload on save and an interactive in-browser traceback debugger (with a security PIN).
- Sessions out of the box: cryptographically signed cookies via
itsdangerous— no database needed. - Blueprints: split a large app into reusable, prefixable modules (like mini-apps).
- Test client:
app.test_client()for fast in-process testing without a real server. - Massive extension ecosystem: almost every problem has a mature
Flask-Somethingpackage. - Application factory + contexts: patterns that make multiple app instances, testing, and configuration clean.
How it differs
- vs FastAPI: FastAPI is ASGI (async-first) with automatic validation via Pydantic and auto-generated Swagger docs. Flask is WSGI (sync), has no built-in validation or docs — you bolt those on. Flask is older, simpler, and better for server-rendered HTML apps; FastAPI is better for pure JSON APIs at high concurrency.
- vs Django: Django is "batteries-included" — ORM, admin, auth, forms, migrations all built in, with strong conventions. Flask gives you freedom and a smaller mental model, but you make the architectural decisions. Django scales teams; Flask scales flexibility.
- vs Express (Node.js): Nearly identical philosophy — tiny core, middleware/extensions for everything. Flask is Express's closest cousin in Python.
- vs Bottle: Bottle is even smaller (single file, zero deps). Flask has a far bigger ecosystem and better tooling.
- vs Quart: Quart is Flask's official async twin — same API, but ASGI-based with real
async/awaitconcurrency.
Why use it
Flask has a famously gentle learning curve — a working app is 5 lines. It's perfect for server-rendered websites (templates are first-class), small-to-medium APIs, ML model demos, internal dashboards, prototypes that become products, and teaching. Because it's unopinionated, it never fights your architecture — you can structure a Flask project like a microservice, a monolith, or anything between. And with 14+ years of production history, the ecosystem answers essentially every question.
Common gotchas
- Synchronous by default: one blocked request occupies a whole worker. Heavy concurrency needs more workers/threads, caching, or a task queue — not
async def(see topic 22). - No validation built in:
request.form["age"]gives you a string; you convert and validate yourself (or use Flask-WTF / marshmallow / pydantic). - Context magic:
request,session,g, andcurrent_appare proxies that only exist during a request/app context — using them outside one raisesRuntimeError: Working outside of request context. - Structure is on you: beginners often stuff everything into one file and hit circular imports the moment they split it — the app factory pattern (topic 23) is the cure.
Real-world examples
Pinterest ran its API on Flask at massive scale, LinkedIn and Netflix use it for internal tools and services, Apache Airflow's entire web UI is a Flask app, and Reddit-scale numbers of startups, ML demos (every Hugging Face Spaces Gradio-less demo era), dashboards, and university courses run on it. If a Python dev has built a website, odds are their first one was Flask.
Flask vs FastAPI vs Django
| Feature | Flask | FastAPI | Django |
|---|---|---|---|
| Protocol | WSGI (sync) | ASGI (async) | WSGI / ASGI |
| Philosophy | Micro, unopinionated | API-first, typed | Batteries-included |
| Validation | Manual / extensions | Automatic (Pydantic) | Forms / DRF serializers |
| Auto API docs | No (add flasgger/smorest) | Yes (Swagger + ReDoc) | Via DRF plugins |
| Templates | Jinja2 (first-class) | Jinja2 (optional) | Django templates |
| ORM | Pick your own | Pick your own | Built-in |
| Admin panel | No (Flask-Admin) | No | Yes, built-in |
| Learning curve | Very easy | Easy | Steep |
| Best for | Websites, small APIs, prototypes | Modern JSON APIs, ML serving | Large content-driven apps |
Setup & First App
What is it
Setting up Flask means creating a virtual environment, installing the flask package, writing a file that creates a Flask application object, and running it with the built-in Werkzeug development server. Unlike FastAPI (which needs a separate ASGI server like uvicorn even in dev), Flask ships its own dev server — flask run or app.run() just works. In production you swap the dev server for gunicorn or waitress (topic 25).
Installation & running
python -m venv .venvthensource .venv/bin/activate(Windows:.venv\Scripts\activate) — always isolate deps.pip install flask— installs Flask + Werkzeug + Jinja2 + itsdangerous + click + blinker.- Run (modern way):
flask --app main run --debug—mainis the module,--debugenables reload + debugger. - Run (code way): put
app.run(debug=True)underif __name__ == "__main__":and dopython main.py. - Auto-discovery: if your file is named
app.pyorwsgi.py, plainflask runfinds it automatically. - Default port: 5000. Change with
flask run --port 8000 --host 0.0.0.0.
Common gotchas
- macOS port 5000 conflict: AirPlay Receiver squats on port 5000 — you get mystery 403s. Use
--port 5001or disable AirPlay Receiver. debug=Truein production is a critical vulnerability: the Werkzeug debugger lets anyone with the PIN (and sometimes without) execute arbitrary Python on your server. Never ship it.FLASK_ENVis dead: deprecated since Flask 2.2 — use--debugorFLASK_DEBUG=1instead.__name__matters:Flask(__name__)tells Flask where the app lives so it can findtemplates/andstatic/folders relative to it.- Reloader runs your file twice: in debug mode the module imports twice (parent + child process) — module-level side effects fire twice.
Hello World
A complete working web app — routing, JSON, and HTML in a dozen lines.
# Create project + install
python -m venv .venv
source .venv/bin/activate
pip install flaskfrom flask import Flask
app = Flask(__name__) # __name__ = current module; Flask uses it
# to locate templates/ and static/
@app.route("/")
def home():
return "<h1>Hello, World!</h1>" # returning a string = HTML response
@app.route("/api/health")
def health():
return {"status": "ok"} # returning a dict = auto JSON!
@app.route("/about")
def about():
return "This is the about page."
if __name__ == "__main__":
app.run(debug=True) # dev server with auto-reload + debugger# Option 1 — run through the flask CLI (recommended)
flask --app main run --debug
# Option 2 — run the file directly
python main.py
# main = the file main.py
# --debug = auto-restart on changes + interactive debugger (DEV ONLY)
# Now visit:
# http://127.0.0.1:5000 → Hello, World!
# http://127.0.0.1:5000/api/health → {"status": "ok"}What debug mode gives you
| Feature | What it does | Why it matters |
|---|---|---|
| Auto-reloader | Restarts the server when a file changes | No manual restarts while coding |
| Interactive debugger | In-browser traceback with a live Python console at every frame | Inspect variables at the crash site |
| Debugger PIN | Console locked behind a PIN printed at startup | Weak protection — still never expose it publicly |
| Better errors | Full stack traces instead of a bare 500 page | Faster debugging |
Routing & HTTP Methods
What is it
Routing maps an incoming request (method + URL) to a Python view function. In Flask you attach @app.route("/path") to a function. By default a route only answers GET (and HEAD/OPTIONS, which Flask adds automatically); other verbs are declared with methods=["GET", "POST"]. Since Flask 2.0 there are also shortcuts: @app.get(), @app.post(), @app.put(), @app.patch(), @app.delete(). Under the hood, every decorator call becomes app.add_url_rule(rule, endpoint, view_func) registered into Werkzeug's URL map.
HTTP method semantics
GET— read, idempotent, cacheable, no body.GET /users/42.POST— create / submit, not idempotent. Form submissions and resource creation.PUT— replace the entire resource, idempotent.PATCH— partial update, only the fields sent.DELETE— remove, idempotent, often returns204 No Content.- If a request uses a verb the route doesn't allow → Flask automatically returns 405 Method Not Allowed.
Route features
- Endpoint names: every route has an endpoint (defaults to the function name) used by
url_for("home")to build URLs — never hardcode URLs in templates or redirects. - Multiple routes, one function: stack decorators —
@app.route("/")and@app.route("/index")on the same view. - Trailing slash rule (unique to Flask): a rule ending in
/(like/projects/) acts like a folder — visiting/projectsredirects to/projects/. A rule without the slash (/about) acts like a file — visiting/about/is a 404. - Smart matching order: Werkzeug ranks rules by specificity automatically — unlike FastAPI, you don't have to define
/users/mebefore/users/<id>; static parts win over converters. - Class-based views:
MethodViewgroupsget()/post()/delete()methods into one class (topic 21).
How it differs
- vs FastAPI: FastAPI uses one decorator per verb (
@app.get,@app.post) and matches routes in registration order. Flask traditionally uses one decorator with amethodslist and Werkzeug ranks routes by specificity. - vs Django: Django separates routing into
urls.pyfiles withpath()entries — more ceremony, but one place to audit all URLs. - vs Express:
app.get('/users', handler)— same feel; Express also matches in registration order.
Common gotchas
- Forgetting
methods=["POST"]: your form POST hits a GET-only route → 405. The #1 beginner error. - Duplicate endpoint names: two view functions with the same name (e.g., after copy-paste) →
AssertionError: View function mapping is overwriting an existing endpoint. - Trailing slash surprises: API clients that don't follow redirects can break on
/projects→ 308 redirect →/projects/. Pick a convention and stick to it. - Using GET for mutations: breaks caching, prefetching, and CSRF assumptions — mutations belong in POST/PUT/PATCH/DELETE.
from flask import Flask, request
app = Flask(__name__)
# ── Classic style: one decorator, methods list ──
@app.route("/users", methods=["GET", "POST"])
def users():
if request.method == "POST":
return {"created": True}, 201 # handle create
return {"users": ["a", "b"]} # handle list
# ── Modern shortcuts (Flask 2.0+) — one function per verb ──
@app.get("/items")
def list_items():
return {"items": []}
@app.post("/items")
def create_item():
return {"created": True}, 201
@app.put("/items/<int:item_id>")
def replace_item(item_id):
return {"replaced": item_id}
@app.patch("/items/<int:item_id>")
def update_item(item_id):
return {"patched": item_id}
@app.delete("/items/<int:item_id>")
def delete_item(item_id):
return "", 204
# ── Multiple URLs → one view ──
@app.route("/")
@app.route("/home")
def home():
return "Home page"
# ── url_for: build URLs from endpoint names ──
from flask import url_for, redirect
@app.get("/old-page")
def old_page():
return redirect(url_for("home")) # → "/"
# url_for("list_items") → "/items"
# url_for("replace_item", item_id=7) → "/items/7"
# url_for("home", _external=True) → "http://localhost:5000/"url_for(). If you ever rename a path, every template, redirect, and test keeps working because they reference the endpoint, not the string.
URL Variables (Path Parameters)
What is it
URL variables are dynamic segments in a route declared with angle brackets: /users/<user_id>. Flask captures the segment and passes it to your view function as a keyword argument. A converter prefix like <int:user_id> tells Werkzeug to only match that pattern and to convert the value to that Python type before your function runs. Unlike FastAPI, the type lives in the URL rule, not in the function's type hints — and a non-matching value gives a 404 (route doesn't match), not a 422 validation error.
Built-in converters
string— (default) any text without slashes./user/<username>.int— positive integers./post/<int:id>matches/post/42, 404s on/post/abc.float— positive floats./price/<float:amount>.path— like string but accepts slashes./files/<path:subpath>capturesdocs/a/b.txt.uuid— UUID strings, converted to auuid.UUIDobject.any— one of a fixed set:/<any(css, js, img):folder>.
How it differs
- vs FastAPI: FastAPI declares type on the function (
user_id: int) and returns422with an error body on mismatch. Flask declares type in the rule and returns404— the route simply never matches. - vs Django:
path("users/<int:user_id>/", view)— Django copied Flask's converter style almost exactly. - vs Express:
/users/:id— everything arrives as a string; youparseIntmanually.
Common gotchas
intmeans non-negative:<int:id>won't match-5; it 404s. Useint(signed=True)in a custom rule or accept a string.- Parameter names must match: the name in
<int:user_id>must equal the function argumentuser_id, or Flask raises a TypeError at request time. pathconverter security: capturing slashes means../../etc/passwd-style input can arrive — never join it directly to a filesystem path; usesend_from_directory()orwerkzeug.utils.safe_join().- Validation beyond type: converters check format only. Range checks (
id > 0and exists in DB) are on you — typicallydb.get_or_404().
# ── Basic capture (default = string) ──
@app.get("/user/<username>")
def show_user(username):
return f"User: {username}"
# GET /user/yatin → "User: yatin"
# ── Typed converters ──
@app.get("/post/<int:post_id>")
def show_post(post_id):
return {"post_id": post_id, "type": str(type(post_id).__name__)}
# GET /post/42 → {"post_id": 42, "type": "int"}
# GET /post/abc → 404 Not Found (route never matches)
@app.get("/files/<path:subpath>")
def show_file(subpath):
return f"Path: {subpath}" # /files/docs/a/b.txt → "docs/a/b.txt"
@app.get("/order/<uuid:order_id>")
def show_order(order_id):
return {"order": str(order_id)} # order_id is a real uuid.UUID object
# ── Multiple variables ──
@app.get("/repos/<owner>/<repo>/issues/<int:number>")
def issue(owner, repo, number):
return {"owner": owner, "repo": repo, "issue": number}
# ── Fixed choices ──
@app.get("/<any(about, contact, help):page>")
def static_page(page):
return f"Rendering the {page} page"
# ── Custom converter (advanced) ──
from werkzeug.routing import BaseConverter
class SlugConverter(BaseConverter):
regex = r"[a-z0-9]+(?:-[a-z0-9]+)*" # lowercase-words-with-dashes
app.url_map.converters["slug"] = SlugConverter
@app.get("/blog/<slug:post_slug>")
def blog_post(post_slug):
return f"Post: {post_slug}"Query, Form & JSON Data
What is it
Everything the client sends besides the path lives on the global request object (imported from flask). The three big buckets are: query string data in request.args (?page=2&q=phone), HTML form data in request.form (POST bodies with application/x-www-form-urlencoded or multipart/form-data), and JSON bodies via request.get_json(). Unlike FastAPI, nothing is declared in the function signature — you reach into request and pull values out manually, converting and validating yourself.
The data buckets
request.args— query string params. AMultiDict(a dict where keys can repeat).request.form— form body fields from POST/PUT.request.values—args+formcombined (use sparingly; be explicit).request.get_json()— parses a JSON body into Python dict/list.request.json— property version; raises 415 if Content-Type isn'tapplication/json.request.files— uploaded files (topic 20).request.data— raw body bytes when nothing else fits.
Safe access patterns
request.args.get("page", default=1, type=int)— the golden pattern: default + automatic conversion; returns the default if missing or if conversion fails.request.args.getlist("tag")—?tag=a&tag=b→["a", "b"].request.form["email"]— bracket access on a missing key doesn't raise KeyError to you — Werkzeug converts it into an automatic 400 Bad Request response.request.get_json(silent=True)— returnsNoneinstead of raising on bad/missing JSON.request.get_json(force=True)— parse even if the Content-Type header is wrong (useful for sloppy clients; use consciously).
How it differs
- vs FastAPI: FastAPI turns function parameters into query params and Pydantic models into validated bodies — declarative, automatic 422s, auto docs. Flask is imperative: you fetch, you convert, you validate, you decide the error format.
- vs Django:
request.GET/request.POST— same idea, different names; Django forms add validation. - vs Express:
req.query,req.body(withexpress.json()middleware) — nearly identical manual style.
Common gotchas
- Everything is a string:
request.args.get("page")returns"2", not2. Always passtype=intor convert explicitly. request.formvs JSON confusion: a fetch/axios call sending JSON puts nothing inrequest.form— it's inget_json(). Mixing these up is the most common "my data is empty" bug.type=swallows errors:?page=abcwithtype=intsilently returns the default — great for UX, bad if you needed to reject it. Validate explicitly for APIs.- Checkbox absence: unchecked HTML checkboxes send nothing at all — test with
"remember" in request.form.
from flask import Flask, request
app = Flask(__name__)
# ── Query parameters: GET /search?q=laptop&page=2&tag=new&tag=sale ──
@app.get("/search")
def search():
q = request.args.get("q", default="", type=str)
page = request.args.get("page", default=1, type=int) # "2" → 2
limit = request.args.get("limit", default=10, type=int)
tags = request.args.getlist("tag") # ["new", "sale"]
if not q:
return {"error": "q is required"}, 400 # manual validation!
return {"q": q, "page": page, "limit": limit, "tags": tags}
# ── Form data: POST with <form method="post"> ──
@app.post("/register")
def register():
name = request.form.get("name", "").strip()
email = request.form["email"] # missing key → automatic 400 response
age = request.form.get("age", type=int)
errors = []
if len(name) < 2:
errors.append("name too short")
if age is not None and not (0 <= age <= 150):
errors.append("age out of range")
if errors:
return {"errors": errors}, 400
return {"name": name, "email": email, "age": age}, 201
# ── JSON body: POST /api/users {"name": "Yatin", "email": "y@dev.com"} ──
@app.post("/api/users")
def create_user():
data = request.get_json(silent=True) # None if body isn't valid JSON
if data is None:
return {"error": "expected JSON body"}, 400
name = data.get("name")
email = data.get("email")
if not name or not email:
return {"error": "name and email are required"}, 400
return {"created": {"name": name, "email": email}}, 201Which bucket has my data?
| Client sends | Content-Type | Read it with |
|---|---|---|
?page=2 in the URL | — | request.args |
HTML <form> submit | x-www-form-urlencoded | request.form |
| Form with file input | multipart/form-data | request.form + request.files |
| fetch/axios JSON | application/json | request.get_json() |
| Raw payload (webhooks, XML) | anything | request.data (bytes) |
The Request Object
What is it
request is Flask's global object representing the current incoming HTTP request. You import it once (from flask import request) and it magically always refers to the request being handled right now — even with multiple concurrent requests in different threads. This works because request is not a normal object but a context-local proxy: each worker/thread/context gets its own real request object behind the shared proxy (modern Werkzeug implements this with Python contextvars). This design is why Flask views don't need a request parameter like Django/Express handlers do.
Most useful attributes
request.method—"GET","POST", …request.path—/users/42·request.full_path— with query string ·request.url— absolute URL.request.headers— case-insensitive dict of headers:request.headers.get("Authorization").request.cookies— dict of cookies sent by the client.request.remote_addr— client IP (see gotcha about proxies).request.is_json— True when Content-Type is JSON ·request.content_type·request.content_length.request.endpoint/request.view_args/request.blueprint— which route matched and with what values.request.args / form / files / get_json() / data— payload buckets (topic 05).
How it differs
- vs Django / Express: both pass
request(orreq) explicitly into every handler. Flask injects nothing — the proxy import gives cleaner signatures at the cost of "magic." - vs FastAPI: FastAPI mostly hides the request behind declared parameters; you can still ask for the raw
Requestobject when needed. Flask is the opposite: raw request first, sugar optional.
Common gotchas
- "Working outside of request context": touching
requestin a background thread, at import time, or in the shell raises RuntimeError. Useapp.test_request_context()in tests/scripts, and pass plain values into threads/tasks instead of the proxy. remote_addrbehind a proxy: with nginx/a load balancer in front,remote_addris the proxy's IP. ApplyProxyFixmiddleware (topic 15) soX-Forwarded-Foris trusted correctly — and only trust it from your own proxy.- The proxy isn't the object:
requestis a stand-in. If a library needs the real object, userequest._get_current_object()(rare, e.g., passing into a closure that outlives the request).
from flask import Flask, request
app = Flask(__name__)
@app.route("/inspect", methods=["GET", "POST"])
def inspect():
return {
"method": request.method,
"path": request.path,
"url": request.url,
"query": request.args.to_dict(),
"headers": {
"user_agent": request.headers.get("User-Agent"),
"auth": request.headers.get("Authorization"),
"content_type": request.content_type,
},
"cookies": request.cookies.to_dict() if request.cookies else {},
"client_ip": request.remote_addr,
"is_json": request.is_json,
"endpoint": request.endpoint, # "inspect"
"view_args": request.view_args, # matched URL variables
}
# ── Using the request in helper functions (no parameter passing!) ──
def current_locale():
# request works anywhere *during* a request — even in helpers
return request.headers.get("Accept-Language", "en")[:2]
@app.get("/greet")
def greet():
lang = current_locale()
return {"hello": "hola" if lang == "es" else "hello"}request, session, g, and current_app as per-request global variables. Flask pushes a fresh set when a request starts and pops them when it ends. Topic 14 goes deep on how.
Responses & JSON
What is it
Whatever a view function returns becomes the HTTP response. Flask is famously flexible here: return a string (HTML body, 200), a dict or list (auto-serialized to JSON — dicts since Flask 1.1, lists since 2.2), a tuple of (body, status) or (body, status, headers), a full Response object, or a generator (streaming). Helpers like jsonify(), make_response(), redirect(), abort(), send_file(), and render_template() all ultimately produce a Response.
Return value cheat-sheet
return "hi"→ 200,text/html.return {"ok": True}→ 200,application/json(auto-jsonify).return {"err": "nope"}, 404→ JSON with a status code.return body, 201, {"X-Total": "5"}→ body + status + extra headers.return jsonify(items)→ explicit JSON (needed for top-level non-dict types on old Flask, and to be unambiguous).return redirect(url_for("home"))→ 302 redirect.abort(404)→ raise an HTTP error immediately (handled by error handlers, topic 10).return send_file("report.pdf")→ file download / inline file.
How it differs
- vs FastAPI: FastAPI serializes through a declared
response_model, filtering fields and validating output. Flask serializes exactly what you return — nothing is filtered, so leaking apassword_hashfield is on you. - vs Express:
res.status(201).json({...})— Express mutates a response object; Flask builds one from the return value. Same power, different ergonomics. - vs Django: you must construct
JsonResponse(...)/HttpResponse(...)explicitly every time.
Common gotchas
- Returning
None: forgetting a return in some branch →TypeError: The view function did not return a valid response. - jsonify sorts/escapes: Flask's JSON is UTF-8 safe and (configurably) key-sorted; don't string-build JSON by hand.
- Streaming needs the context: a generator response runs after the view returns — wrap it with
stream_with_context()if it touchesrequest. - Datetime objects: Flask's JSON encoder handles datetimes (HTTP date format) but not arbitrary classes — convert models to dicts first or add a custom provider.
from flask import Flask, jsonify, make_response, redirect, url_for, abort, send_file, Response, stream_with_context
app = Flask(__name__)
# ── The five common shapes ──
@app.get("/text")
def text():
return "<h1>Plain HTML string</h1>" # 200 text/html
@app.get("/json")
def as_json():
return {"name": "Yatin", "role": "dev"} # 200 application/json
@app.post("/created")
def created():
return {"id": 7}, 201 # status code
@app.get("/with-headers")
def with_headers():
return {"data": []}, 200, {"X-Total-Count": "0",
"Cache-Control": "no-store"}
@app.get("/old")
def old():
return redirect(url_for("as_json")) # 302 → /json
# ── make_response: when you need to tweak before sending ──
@app.get("/custom")
def custom():
resp = make_response({"ok": True}, 200)
resp.headers["X-Request-Id"] = "abc-123"
resp.set_cookie("seen", "1", httponly=True)
return resp
# ── abort: bail out with an HTTP error ──
FAKE_DB = {1: {"name": "Yatin"}}
@app.get("/users/<int:user_id>")
def get_user(user_id):
user = FAKE_DB.get(user_id)
if user is None:
abort(404, description="User not found") # → error handler
return user
# ── Files ──
@app.get("/invoice")
def invoice():
return send_file("files/invoice.pdf",
as_attachment=True, # force download
download_name="invoice-2026.pdf")
# ── Streaming large responses (CSV export, logs, SSE) ──
@app.get("/export")
def export():
def generate():
yield "id,name\n"
for i in range(100_000):
yield f"{i},user{i}\n" # sent chunk by chunk
return Response(stream_with_context(generate()),
mimetype="text/csv")Jinja2 Templates
What is it
Jinja2 is Flask's template engine (written by the same author) — it renders HTML on the server by combining template files with Python data. Templates live in a templates/ folder next to your app, and render_template("page.html", user=user) loads, compiles, and fills them. Jinja's superpower is template inheritance: define a base.html skeleton once, and every page extends it, overriding only the blocks it needs. Jinja also auto-escapes all variables, which kills most XSS attacks by default.
Syntax essentials
{{ variable }}— output an expression (auto-escaped).{% if %} / {% for %} / {% endif %}— statements and control flow.{# comment #}— not rendered.- Filters:
{{ name|title }},{{ items|length }},{{ price|round(2) }},{{ text|truncate(80) }},{{ html|safe }}(disables escaping — dangerous). - Inheritance:
{% extends "base.html" %}+{% block content %}…{% endblock %}. - Reuse:
{% include "nav.html" %}and{% macro card(item) %}(functions for HTML). - URL building:
{{ url_for('profile', username=user.name) }}— never hardcode paths.
How it differs
- vs Django templates: Django's engine is intentionally logic-poor (no arbitrary expressions); Jinja allows real Python-ish expressions, math, and method calls — more power, more rope.
- vs React/Vue (client-side): Jinja renders on the server — great SEO, fast first paint, no build step; interactivity then comes from sprinkles of JS or htmx.
- vs FastAPI: FastAPI can use Jinja too, but it's an add-on; in Flask, templates are a core, first-class workflow.
Common gotchas
TemplateNotFound: the folder must be named exactlytemplates/and sit next to the module you passed asFlask(__name__)(or configuretemplate_folder=).|safeon user input = XSS: only mark content safe when you generated the HTML. Never on anything a user typed.- Undefined variables render as empty: a typo like
{{ usre.name }}silently prints nothing (or errors on attribute access) — enableapp.jinja_env.undefined = StrictUndefinedin dev to make typos loud. - Logic creep: if a template needs complex branching/queries, move it into the view or a context processor — templates should present, not compute.
from flask import Flask, render_template
app = Flask(__name__)
@app.get("/profile/<username>")
def profile(username):
user = {"name": username, "role": "admin", "score": 92}
posts = [
{"title": "Learning Flask", "likes": 14},
{"title": "Jinja is neat", "likes": 31},
]
return render_template("profile.html", user=user, posts=posts)
# ── Custom filter available in ALL templates ──
@app.template_filter("shout")
def shout(text):
return str(text).upper() + "!"
# ── Context processor: inject variables into every template ──
@app.context_processor
def inject_globals():
return {"site_name": "Yatin Notes", "year": 2026}<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{{ site_name }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<nav>
<a href="{{ url_for('profile', username='yatin') }}">Profile</a>
</nav>
{% block content %}{% endblock %} {# pages fill this in #}
<footer>© {{ year }} {{ site_name }}</footer>
</body>
</html>{% extends "base.html" %}
{% block title %}{{ user.name }} — {{ site_name }}{% endblock %}
{% block content %}
<h1>{{ user.name|title }} <small>({{ user.role }})</small></h1>
{% if user.score > 90 %}
<p>{{ "top performer"|shout }}</p> {# custom filter #}
{% elif user.score > 50 %}
<p>Doing well.</p>
{% else %}
<p>Keep going!</p>
{% endif %}
<ul>
{% for post in posts %}
<li>{{ loop.index }}. {{ post.title }} — {{ post.likes }} likes</li>
{% else %}
<li>No posts yet.</li> {# runs when the list is empty #}
{% endfor %}
</ul>
{% endblock %}{{ "<script>alert(1)</script>" }} renders as harmless text. You only lose that protection when you write |safe — so treat |safe like a loaded weapon.
Static Files
What is it
Static files are assets that don't change per request — CSS, JavaScript, images, fonts. Flask automatically serves anything inside a static/ folder (next to your app module) at the URL prefix /static/. In templates you reference them with url_for('static', filename='css/style.css'), which builds the correct URL even if you later change the prefix or move behind a CDN.
Key details
- Default layout:
static/style.css→ served at/static/style.css. Subfolders work:static/img/logo.png. - Custom folder / URL:
Flask(__name__, static_folder="assets", static_url_path="/assets"). - Cache busting: add a version query —
url_for('static', filename='app.js', v='2')— or hash filenames in your build step so browsers fetch new versions. - User-generated files: don't dump uploads into
static/; serve them explicitly withsend_from_directory()so you control access (topic 20). - Blueprints can carry their own static folders — handy for reusable components (topic 13).
Common gotchas
- Hardcoding
/static/...works until you deploy under a URL prefix or CDN — alwaysurl_for. - Browser caching during dev: your CSS "isn't updating"? It's cached — hard refresh, or rely on debug mode's short cache headers.
- Production performance: Flask can serve static files, but each one occupies a Python worker. In production, let nginx serve
/static/directly, or use WhiteNoise on platforms without nginx.
myapp/
├── app.py
├── static/
│ ├── css/style.css
│ ├── js/app.js
│ └── img/logo.png
└── templates/
└── index.html<link rel="stylesheet"
href="{{ url_for('static', filename='css/style.css') }}">
<script src="{{ url_for('static', filename='js/app.js') }}" defer></script>
<img src="{{ url_for('static', filename='img/logo.png') }}" alt="logo"># Let nginx serve static files directly — Python never sees them
location /static/ {
alias /srv/myapp/static/;
expires 30d;
add_header Cache-Control "public, immutable";
}Status Codes & Error Handling
What is it
Flask gives you three tools for errors: status codes in return tuples (return body, 404), abort(code) to raise an HTTP error from anywhere in the call stack (it raises a Werkzeug HTTPException), and @app.errorhandler() to define what the client sees when a given status code or exception type occurs. By default Flask renders plain HTML error pages — real apps override them with branded pages or, for APIs, consistent JSON error envelopes.
The toolkit
return {"error": "..."}, 400— inline error with a status.abort(404)/abort(403, description="No access")— jump straight to the error handler; great inside helper functions.@app.errorhandler(404)— customize a status code globally.@app.errorhandler(SomeException)— map your own exception classes to responses, keeping business logic HTTP-free.@app.errorhandler(HTTPException)— one handler to JSON-ify every HTTP error (perfect for APIs).- 500 handling: unhandled exceptions become 500; in production the traceback is hidden and (with logging configured) written to logs.
How it differs
- vs FastAPI: FastAPI raises
HTTPException(status_code=…, detail=…)and returns JSON by default; validation failures are automatic 422s. Flask returns HTML by default and has no automatic validation errors — you design the error contract. - vs Express: Express error middleware (
(err, req, res, next)) is positional and easy to misorder; Flask's decorator registry is more declarative.
Common gotchas
abort()raises, it doesn't return: code afterabort(404)never runs — andreturn abort(404)is redundant.- Broad
except Exceptionswallows aborts:HTTPExceptionis an Exception — re-raise it first or catch narrower types. - Handlers must return a status: forgetting
, 404in the handler's return makes your "not found" page ship with 200 — bad for SEO and clients. - Leaking internals: never include stack traces, SQL, or config values in error responses. Log the details; return a generic message + an error id.
from flask import Flask, abort, jsonify, render_template, request
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
# ── abort() from anywhere ──
USERS = {1: "Yatin"}
@app.get("/users/<int:user_id>")
def get_user(user_id):
if user_id not in USERS:
abort(404, description=f"User {user_id} not found")
return {"id": user_id, "name": USERS[user_id]}
# ── Custom HTML error pages (for websites) ──
@app.errorhandler(404)
def not_found(error):
return render_template("errors/404.html", msg=error.description), 404
@app.errorhandler(500)
def server_error(error):
return render_template("errors/500.html"), 500
# ── JSON errors for EVERY HTTP status (for APIs) ──
@app.errorhandler(HTTPException)
def handle_http_error(e):
return jsonify({
"error": e.name, # "Not Found"
"detail": e.description, # your abort() message
"status": e.code, # 404
}), e.code
# ── Map domain exceptions → HTTP ──
class OutOfStock(Exception):
def __init__(self, product_id):
self.product_id = product_id
@app.errorhandler(OutOfStock)
def handle_out_of_stock(e):
return {"error": "out_of_stock", "product": e.product_id}, 409
@app.post("/buy/<int:pid>")
def buy(pid):
raise OutOfStock(pid) # business code stays HTTP-free
# ── Last-resort 500 with logging ──
@app.errorhandler(Exception)
def handle_unexpected(e):
if isinstance(e, HTTPException): # let real HTTP errors pass through
raise e
app.logger.exception("Unhandled error on %s", request.path)
return {"error": "internal_error"}, 500Status codes you'll actually use
| Code | Meaning | When to use |
|---|---|---|
200 | OK | Successful GET / PUT / PATCH |
201 | Created | Successful POST that made a resource |
204 | No Content | Successful DELETE (empty body) |
301 / 302 / 308 | Redirects | Moved pages; Flask's trailing-slash redirect is 308 |
400 | Bad Request | Invalid input / failed validation |
401 | Unauthorized | Not logged in / missing token |
403 | Forbidden | Logged in but not allowed |
404 | Not Found | Resource or route doesn't exist |
405 | Method Not Allowed | Wrong verb for the route (Flask auto) |
409 | Conflict | Duplicate / state conflict |
413 | Payload Too Large | Upload over MAX_CONTENT_LENGTH |
429 | Too Many Requests | Rate limit hit |
500 | Server Error | Unhandled exception |
Headers & Cookies
What is it
Headers are key-value metadata on every request and response (Content-Type, Authorization, User-Agent, custom X-… headers). Cookies are small pieces of state the server asks the browser to store and send back on every request to that domain — the foundation of sessions and login. In Flask you read both from request.headers / request.cookies, and set both on a response object built with make_response() (or on the tuple's header dict).
Cookie security flags
httponly=True— JavaScript can't read the cookie → protects against XSS token theft. Use for anything auth-related.secure=True— only sent over HTTPS. Mandatory in production.samesite="Lax"— not sent on most cross-site requests → strong CSRF mitigation."Strict"is stricter;"None"requiressecure.max_age=3600— lifetime in seconds; omit for a session cookie (dies with the browser).domain=".example.com"— share across subdomains; default is the exact host only.- Limits: ~4KB per cookie, sent on every request — keep them tiny.
Common gotchas
- Setting a cookie needs a response object: you can't
request.cookies["x"] = ...— cookies are set viaresp.set_cookie()on the way out. - Deleting = expiring:
resp.delete_cookie("name")just sets it with an expiry in the past; path/domain must match the original. - Header names are case-insensitive:
request.headers.get("authorization")and"Authorization"both work — Werkzeug handles it. - Cookies are not secret storage: a plain cookie is user-editable. For tamper-proof state, use Flask's signed
session(topic 12); for secrets, keep them server-side.
from flask import Flask, request, make_response
app = Flask(__name__)
# ── Reading headers ──
@app.get("/whoami")
def whoami():
return {
"user_agent": request.headers.get("User-Agent"),
"token": request.headers.get("Authorization"), # "Bearer xyz"
"language": request.headers.get("Accept-Language"),
"custom": request.headers.get("X-Client-Version", "unknown"),
}
# ── Reading cookies ──
@app.get("/prefs")
def prefs():
theme = request.cookies.get("theme", "light")
return {"theme": theme}
# ── Setting headers + cookies ──
@app.post("/prefs")
def set_prefs():
theme = request.get_json().get("theme", "light")
resp = make_response({"saved": True})
resp.set_cookie(
"theme", theme,
max_age=60 * 60 * 24 * 365, # 1 year
httponly=False, # UI cookie — JS may read it
secure=True,
samesite="Lax",
)
resp.headers["X-App-Version"] = "3.2.1"
return resp
# ── Auth-style cookie (locked down) ──
@app.post("/login")
def login():
resp = make_response({"message": "logged in"})
resp.set_cookie("auth_token", "signed-token-here",
httponly=True, secure=True, samesite="Lax",
max_age=60 * 30)
return resp
@app.post("/logout")
def logout():
resp = make_response({"message": "bye"})
resp.delete_cookie("auth_token")
return respSessions
What is it
The session object lets you store per-user data across requests — the classic use is "who is logged in." Flask's default implementation is a client-side signed cookie: the whole session dict is serialized, cryptographically signed with your SECRET_KEY (via itsdangerous), and stored in the browser. On each request Flask verifies the signature — if a user tampers with even one byte, the session is rejected and comes back empty. Crucially, the data is signed, not encrypted: users can read everything in their session cookie (it's just base64), they just can't modify it.
Key behaviors
- Dict API:
session["user_id"] = 7,session.get("user_id"),session.pop("user_id", None),session.clear(). - Requires
SECRET_KEY: no key → RuntimeError the moment you touchsession. - Lifetime: by default a browser-session cookie. Set
session.permanent = Trueto usePERMANENT_SESSION_LIFETIME(default 31 days). - Size limit: it's a cookie → ~4KB total. Store IDs and flags, never objects or lists of data.
- Flash messages:
flash("Saved!")+get_flashed_messages()ride on the session — one-shot notices between a redirect and the next page. - Server-side alternative: the
Flask-Sessionextension stores the data in Redis/DB and puts only a random session ID in the cookie — revocable, unlimited size.
How it differs
- vs Django: Django defaults to server-side sessions (DB-backed) with only an ID in the cookie. Flask defaults to client-side — zero infrastructure, but readable by the user and not revocable until expiry.
- vs JWT auth: a Flask session cookie is conceptually similar to a JWT (signed client-held state) but browser-managed, HttpOnly-able by default, and simpler. JWTs shine for non-browser clients and cross-service auth (topic 16).
Common gotchas
- Never store secrets in the session: it's readable! No passwords, no API keys, no sensitive PII — an attacker can base64-decode the cookie in seconds.
- Rotating
SECRET_KEYlogs everyone out: old signatures stop verifying. Plan rotations. - Hardcoded dev keys leak: a leaked
SECRET_KEYlets attackers forge sessions ("I am user_id 1"). Load it from the environment, make it long and random. - Mutating nested structures:
session["cart"].append(x)may not persist — Flask can't detect in-place mutation; setsession.modified = Trueor reassign the key.
from flask import Flask, session, redirect, url_for, request, flash
app = Flask(__name__)
app.secret_key = "change-me-load-from-env" # signs the session cookie
# production: app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]
# ── Login: put the user id in the session ──
@app.post("/login")
def login():
username = request.form["username"]
password = request.form["password"]
user = verify_credentials(username, password) # your DB check
if not user:
flash("Invalid credentials")
return redirect(url_for("login_page"))
session.clear() # prevent session fixation
session["user_id"] = user.id
session["role"] = user.role
session.permanent = True # survive browser restarts
return redirect(url_for("dashboard"))
# ── Protect pages ──
@app.get("/dashboard")
def dashboard():
if "user_id" not in session:
return redirect(url_for("login_page"))
return f"Welcome back, user #{session['user_id']} ({session['role']})"
# ── Logout ──
@app.post("/logout")
def logout():
session.clear()
return redirect(url_for("login_page"))
# ── Non-auth uses: preferences, wizards, carts (small!) ──
@app.post("/theme/<name>")
def set_theme(name):
session["theme"] = name
return {"theme": name}# pip install flask-session redis
from flask_session import Session
import redis
app.config.update(
SESSION_TYPE="redis",
SESSION_REDIS=redis.from_url("redis://localhost:6379"),
SESSION_PERMANENT=False,
)
Session(app)
# Now the cookie holds only a random ID; the data lives in Redis.
# → revocable logins, no 4KB limit, nothing readable client-side.session, assume the user can read.
Blueprints
What is it
A Blueprint is a "mini application" — a bundle of routes, templates, static files, error handlers, and hooks that you define in its own module and then register onto the real app, optionally under a URL prefix. Blueprints are how Flask apps scale past one file: instead of a 2,000-line app.py, you get auth/, blog/, api/ packages, each owning its slice of the site. They're also how the application factory pattern (topic 23) avoids circular imports: blueprints don't need the app object to define routes.
Key features
- Prefixing:
Blueprint("blog", __name__, url_prefix="/blog")— every route inside is mounted under/blog. - Namespaced endpoints:
url_for("blog.show_post", post_id=3)— the blueprint name prefixes endpoint names, so two blueprints can both have anindexview. - Own templates/static:
template_folder="templates",static_folder="static"per blueprint. - Scoped hooks & errors:
@bp.before_requestand@bp.errorhandler(404)only apply inside that blueprint — e.g., force login for every admin route in one place. - Nesting (Flask 2.0+):
parent.register_blueprint(child)— build/api/v1/usersstyle trees. - Reuse & versioning: register the same blueprint twice with different prefixes, or ship blueprints as installable packages.
How it differs
- vs FastAPI's APIRouter: nearly the same concept — prefix + tags + include. Blueprints additionally carry templates/static/error handlers because Flask serves whole websites, not just JSON.
- vs Django apps: Django apps are heavier (models + admin + migrations per app). Blueprints are just organizational — no DB coupling.
- vs Express Router:
express.Router()mounted withapp.use("/blog", router)— same idea, same benefits.
Common gotchas
- Forgetting to register: a defined-but-unregistered blueprint's routes silently don't exist. Registration lives in
create_app(). url_forneeds the namespace: inside blueprintblog,url_for(".show_post")(leading dot = same blueprint) or the full"blog.show_post"— plain"show_post"fails.- Registering after the first request: not allowed — wire everything before serving.
- Template shadowing: app-level
templates/wins over a blueprint's template of the same name — namespace blueprint templates in a subfolder (templates/blog/…).
from flask import Blueprint, render_template, abort
# name, import location, options
bp = Blueprint("blog", __name__, url_prefix="/blog",
template_folder="templates")
POSTS = {1: {"title": "Hello Flask", "body": "..."}}
@bp.get("/") # → GET /blog/
def index():
return render_template("blog/index.html", posts=POSTS)
@bp.get("/<int:post_id>") # → GET /blog/<id>
def show_post(post_id):
post = POSTS.get(post_id) or abort(404)
return render_template("blog/post.html", post=post)
# Hook scoped to THIS blueprint only
@bp.before_request
def count_blog_hits():
... # runs before every /blog/* request
# Error page scoped to THIS blueprint only
@bp.errorhandler(404)
def blog_404(e):
return render_template("blog/404.html"), 404# api/routes.py — a versioned JSON API as a blueprint
from flask import Blueprint
api = Blueprint("api", __name__, url_prefix="/api/v1")
@api.get("/users")
def list_users():
return {"users": []}
@api.get("/users/<int:uid>")
def get_user(uid):
return {"id": uid}
# main.py — wire everything together
from flask import Flask
from blog.routes import bp as blog_bp
from api.routes import api as api_bp
app = Flask(__name__)
app.register_blueprint(blog_bp) # /blog/...
app.register_blueprint(api_bp) # /api/v1/...
# Same blueprint, second mount point (e.g., legacy path)
# app.register_blueprint(api_bp, url_prefix="/api/latest", name="api_latest")
# Nested blueprints (Flask 2.0+)
# parent = Blueprint("admin", __name__, url_prefix="/admin")
# parent.register_blueprint(reports_bp) # → /admin/reports/...
# app.register_blueprint(parent)App & Request Context (g, current_app)
What is it
Flask's "globals" — request, session, g, current_app — are context locals: proxies that resolve to different real objects depending on which context is active. Two context types exist. The application context is pushed whenever Flask is "working on behalf of an app" and powers current_app and g. The request context is pushed for each HTTP request and powers request and session (pushing a request context automatically pushes an app context too). This machinery is what lets you import request at module level yet safely handle many simultaneous requests — each execution context sees only its own objects.
The four proxies
current_app— the runningFlaskinstance. Use it inside blueprints/extensions instead of importing the app object (which causes circular imports with the factory pattern).g— a per-request scratchpad ("globals"). Stash anything expensive you compute once per request: a DB connection, the authenticated user, a request id. Wiped after every request.request— the incoming request (topic 06).session— the signed per-user store (topic 12).
When you push contexts manually
- Scripts & jobs:
with app.app_context():before usingcurrent_app, extensions, or the DB outside a request (seeding data, cron scripts). - Tests:
with app.test_request_context("/path?x=1"):to fake a request sorequest/url_forwork. - Cleanup hooks:
@app.teardown_appcontextruns when the app context pops — the canonical place to close per-request DB connections stored ong. - CLI commands:
@app.cli.command()functions run inside an app context automatically.
Common gotchas
gis per-request, not per-user: it does NOT persist between requests — that'ssession's job. Confusing the two is the classic mistake.- Background threads lose the context: a thread spawned inside a view does not inherit it. Pass plain values in, or capture the app and open a fresh
app.app_context()inside the thread. - "Working outside of application context": using
current_appat import time. Move the code into a function, a hook, or wrap inapp_context().
from flask import Flask, g, current_app, request
import sqlite3, time, uuid
app = Flask(__name__)
app.config["DATABASE"] = "app.db"
# ── g: compute once per request, reuse everywhere ──
def get_db():
if "db" not in g: # first call this request?
g.db = sqlite3.connect(current_app.config["DATABASE"])
return g.db # later calls reuse it
@app.teardown_appcontext
def close_db(exc):
db = g.pop("db", None)
if db is not None:
db.close() # always runs, even on errors
@app.before_request
def stamp_request():
g.request_id = uuid.uuid4().hex[:8]
g.start_time = time.perf_counter()
@app.get("/report")
def report():
rows = get_db().execute("SELECT count(*) FROM users").fetchone()
return {"request_id": g.request_id, "users": rows[0]}
# ── current_app inside a blueprint (no circular import) ──
from flask import Blueprint
bp = Blueprint("tools", __name__)
@bp.get("/config-peek")
def config_peek():
return {"debug": current_app.debug,
"db": current_app.config["DATABASE"]}
# ── Using the app OUTSIDE a request (script / shell / cron) ──
def seed():
with app.app_context(): # manually push the app context
db = get_db()
db.execute("INSERT INTO users(name) VALUES ('admin')")
db.commit()
if __name__ == "__main__":
seed()g vs session vs current_app
| Object | Lifetime | Lives where | Typical use |
|---|---|---|---|
g | One request | Server memory | DB handle, current user object, request id |
session | Many requests (per user) | Signed cookie (client) | user_id, role, flash messages |
current_app | App context | Server | Config, logger, extensions |
request | One request | Server | Incoming data |
Middleware & Request Hooks
What is it
Cross-cutting code — logging, timing, auth checks, security headers — runs the same way for every request. Flask offers two levels. Request hooks are decorators inside Flask: @app.before_request (runs before the view; returning a response here short-circuits the view), @app.after_request (receives and can modify the outgoing response), and @app.teardown_request / @app.teardown_appcontext (cleanup that runs even after exceptions). WSGI middleware wraps the whole app from the outside (app.wsgi_app = Middleware(app.wsgi_app)) — framework-agnostic, and where things like ProxyFix live.
The hook lifecycle
before_request— auth gates, opening resources, request logging. Return a response → view is skipped (perfect for "401 if no token").- view function runs
after_request(response)— must take and return the response; add headers, set cookies, record timing. Skipped if an unhandled exception occurred.teardown_request(exc)/teardown_appcontext(exc)— always run (exception or not); close DB connections, release locks. Cannot change the response.- Multiple hooks of a type run in registration order; blueprint-level hooks (
@bp.before_request) scope to that blueprint.
How it differs
- vs FastAPI: FastAPI's
@app.middleware("http")wraps the call in one async function, and itsDepends()system covers most per-route needs. Flask splits the phases into separate decorators and uses plain decorators for per-route logic. - vs Express: Express middleware is a pipeline of
(req, res, next)functions where order is everything. Flask's named phases are harder to misorder. - vs Django: Django middleware are classes in a settings list — closest to WSGI-style wrapping.
Common gotchas
- Forgetting to return the response in
after_request: instantTypeError— the hook must return it. - Doing heavy work in hooks: they run on every request, including static files served by Flask in dev — keep them cheap.
- ProxyFix misconfigured: trusting
X-Forwarded-Forwhen not actually behind a proxy lets clients spoof their IP; set thex_for/x_protocounts to match your real proxy chain. - State between hooks: share via
g, not module globals (topic 14).
from flask import Flask, request, g, abort
import time, logging
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
# ── before_request: timing + a simple auth gate ──
PUBLIC_ENDPOINTS = {"health", "login", "static"}
@app.before_request
def start_timer():
g.start = time.perf_counter()
@app.before_request
def require_token():
if request.endpoint in PUBLIC_ENDPOINTS or request.endpoint is None:
return # allowed through
token = request.headers.get("Authorization", "")
if not token.startswith("Bearer "):
abort(401, description="Missing bearer token")
# returning/raising here SKIPS the view entirely
# ── after_request: security headers + timing header + access log ──
@app.after_request
def finalize(response):
elapsed = (time.perf_counter() - g.get("start", time.perf_counter())) * 1000
response.headers["X-Response-Time"] = f"{elapsed:.1f}ms"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
app.logger.info("%s %s → %s (%.1fms)",
request.method, request.path, response.status_code, elapsed)
return response # MUST return it
# ── teardown: guaranteed cleanup ──
@app.teardown_appcontext
def cleanup(exc):
db = g.pop("db", None)
if db is not None:
db.close()
@app.get("/health")
def health():
return {"status": "ok"}# ── ProxyFix: REQUIRED when running behind nginx / a load balancer ──
# Makes request.remote_addr and url scheme reflect the real client,
# by trusting X-Forwarded-* headers from exactly N proxies.
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
# ── Writing your own WSGI middleware (framework-agnostic) ──
class SimpleWSGILogger:
def __init__(self, wsgi_app):
self.wsgi_app = wsgi_app
def __call__(self, environ, start_response):
print("WSGI sees:", environ["REQUEST_METHOD"], environ["PATH_INFO"])
return self.wsgi_app(environ, start_response)
app.wsgi_app = SimpleWSGILogger(app.wsgi_app)Authentication (Flask-Login + JWT)
What is it
Authentication is proving who the user is; authorization is what they may do. Flask has no built-in auth — you compose it. Two dominant patterns: session-based auth with the Flask-Login extension (browser apps: login form → signed session cookie → @login_required pages), and token-based auth with flask-jwt-extended (APIs and SPAs: login → JWT → Authorization: Bearer … on every call). Password storage in both cases uses werkzeug.security's generate_password_hash() / check_password_hash() — salted, slow, one-way hashes.
Flask-Login essentials
LoginManager(app)+login_manager.user_loader— a callback that loads a user by ID from the session on each request.UserMixinon your model — addsis_authenticated,get_id(), etc.login_user(user, remember=True)/logout_user()— start/end the session (remember = long-lived cookie).@login_required— protect any view; anonymous users get redirected tologin_manager.login_view.current_user— proxy to the logged-in user (or an anonymous stand-in) anywhere, including templates.
JWT flow (APIs)
- Login: client POSTs credentials → server verifies hash → returns
create_access_token(identity=user.id)(and usually a refresh token). - Request: client sends
Authorization: Bearer <token>;@jwt_required()verifies the signature + expiry;get_jwt_identity()yields the user id. - Structure: a JWT is
header.payload.signature— base64 pieces signed with your secret. Readable by anyone; forgeable by no one (without the key). - Refresh: short access tokens (15–30 min) + a longer refresh token to mint new ones.
Security gotchas
- Never store plain passwords — and never MD5/SHA1. Werkzeug's default (scrypt/pbkdf2 depending on version) or bcrypt/argon2 only.
- JWT payloads are public: base64 ≠ encryption. No secrets inside.
- JWTs can't be revoked before expiry without a server-side denylist (Redis) — one reason plain sessions are often simpler and safer for browser apps.
- Where the SPA stores the token matters:
localStorageis XSS-stealable; HttpOnly cookies are safer but reintroduce CSRF concerns (pair with SameSite). - Identical error messages: return "invalid credentials" for both wrong-user and wrong-password — don't leak which one failed.
- Session fixation: call
session.clear()(or rely on Flask-Login's fresh login) before establishing the new identity.
# pip install flask-login flask-sqlalchemy
from flask import Flask, request, redirect, url_for, render_template, flash
from flask_login import (LoginManager, UserMixin, login_user,
logout_user, login_required, current_user)
from werkzeug.security import generate_password_hash, check_password_hash
app = Flask(__name__)
app.secret_key = "load-from-env"
login_manager = LoginManager(app)
login_manager.login_view = "login" # where @login_required redirects
# ── User model (SQLAlchemy version in topic 17) ──
class User(UserMixin): # UserMixin adds is_authenticated etc.
def __init__(self, id, email, pw_hash):
self.id, self.email, self.pw_hash = id, email, pw_hash
USERS = {"1": User("1", "y@dev.com", generate_password_hash("secret123"))}
@login_manager.user_loader
def load_user(user_id): # called on EVERY request
return USERS.get(user_id) # id comes from the session cookie
# ── Routes ──
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
user = next((u for u in USERS.values()
if u.email == request.form["email"]), None)
if user and check_password_hash(user.pw_hash, request.form["password"]):
login_user(user, remember=True)
return redirect(url_for("dashboard"))
flash("Invalid credentials") # same message for both failures
return render_template("login.html")
@app.get("/dashboard")
@login_required # anonymous → redirect to /login
def dashboard():
return f"Hello {current_user.email}!"
@app.post("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("login"))# pip install flask-jwt-extended
from flask import Flask, request
from flask_jwt_extended import (JWTManager, create_access_token,
create_refresh_token, jwt_required,
get_jwt_identity)
from werkzeug.security import check_password_hash
app = Flask(__name__)
app.config["JWT_SECRET_KEY"] = "load-from-env"
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = 60 * 30 # 30 minutes
jwt = JWTManager(app)
@app.post("/api/login")
def api_login():
data = request.get_json()
user = find_user_by_email(data.get("email")) # your DB lookup
if not user or not check_password_hash(user.pw_hash, data.get("password", "")):
return {"error": "invalid credentials"}, 401
return {
"access_token": create_access_token(identity=str(user.id)),
"refresh_token": create_refresh_token(identity=str(user.id)),
}
@app.get("/api/me")
@jwt_required() # verifies Authorization: Bearer <jwt>
def me():
user_id = get_jwt_identity()
return {"user_id": user_id}
@app.post("/api/refresh")
@jwt_required(refresh=True) # only refresh tokens allowed here
def refresh():
return {"access_token": create_access_token(identity=get_jwt_identity())}
# Client usage:
# POST /api/login {"email": ..., "password": ...} → tokens
# GET /api/me Authorization: Bearer <access_token>Sessions vs JWT — which one?
| Session cookie (Flask-Login) | JWT (flask-jwt-extended) | |
|---|---|---|
| Best for | Server-rendered browser apps | APIs, SPAs, mobile, microservices |
| Revocation | Instant (server-side / new key) | Hard — needs a denylist |
| State | Cookie auto-sent by browser | Client attaches header manually |
| Cross-domain | Awkward | Natural |
| Complexity | Low | Medium (expiry, refresh, storage) |
Database (Flask-SQLAlchemy)
What is it
SQLAlchemy is Python's standard ORM, and Flask-SQLAlchemy is the extension that wires it into Flask: it manages the engine, gives you a request-scoped db.session (opened per request, removed on teardown), a declarative db.Model base, and helpers like db.get_or_404() and db.paginate(). You define tables as Python classes, and query them with either the modern 2.0 style (db.session.execute(db.select(User))) or the classic User.query API.
Core pieces
- Config:
SQLALCHEMY_DATABASE_URI—"sqlite:///app.db"in dev,"postgresql+psycopg2://user:pass@host/db"in prod. - Models: classes inheriting
db.Model; columns viadb.Column(...)or 2.0-styleMapped[int] = mapped_column(...). - Relationships:
db.relationship("Post", back_populates="author")+ adb.ForeignKeycolumn. - Session = unit of work:
db.session.add(obj)stages,db.session.commit()writes,db.session.rollback()undoes. - Flask helpers:
db.get_or_404(User, id),db.first_or_404(select),db.paginate(select, page=…, per_page=…). - App factory friendly: create
db = SQLAlchemy()once inextensions.py, bind later withdb.init_app(app).
How it differs
- vs raw SQLAlchemy (as used with FastAPI): with FastAPI you hand-roll the engine,
SessionLocal, and aget_db()dependency. Flask-SQLAlchemy does that plumbing for you and scopes the session to the request automatically. - vs Django ORM: Django's ORM is built-in and simpler for CRUD; SQLAlchemy is more powerful for complex queries and isn't tied to one framework.
- vs SQLModel/Tortoise: those target FastAPI's typed/async world; Flask-SQLAlchemy is the sync WSGI workhorse.
Common gotchas
- Forgetting
db.session.commit(): your changes vanish at request end. Add/delete only stages. - N+1 queries: looping users and touching
user.postsfires one query per user — eager-load withdb.selectinload(User.posts)in the select'soptions(). create_all()is not migrations: it creates missing tables but never alters existing ones — schema changes need Flask-Migrate (topic 18).- Committing after every loop iteration: slow — batch, then commit once.
- App context in scripts: DB access outside a request needs
with app.app_context():.
# pip install flask-sqlalchemy
from flask import Flask, request, abort
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime, timezone
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///app.db"
db = SQLAlchemy(app) # (factory apps: db = SQLAlchemy() + db.init_app(app))
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
email = db.Column(db.String(255), unique=True, index=True, nullable=False)
created_at = db.Column(db.DateTime,
default=lambda: datetime.now(timezone.utc))
posts = db.relationship("Post", back_populates="author",
cascade="all, delete-orphan")
class Post(db.Model):
__tablename__ = "posts"
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
author = db.relationship("User", back_populates="posts")
with app.app_context():
db.create_all() # dev convenience; real schema changes → migrations# ── CREATE ──
@app.post("/users")
def create_user():
data = request.get_json()
if db.session.execute(
db.select(User).filter_by(email=data["email"])
).scalar_one_or_none():
return {"error": "email already registered"}, 409
user = User(name=data["name"], email=data["email"])
db.session.add(user)
db.session.commit()
return {"id": user.id, "name": user.name}, 201
# ── READ (list, paginated) ──
@app.get("/users")
def list_users():
page = db.paginate(db.select(User).order_by(User.id),
page=request.args.get("page", 1, type=int),
per_page=10, error_out=False)
return {
"items": [{"id": u.id, "name": u.name} for u in page.items],
"page": page.page, "pages": page.pages, "total": page.total,
}
# ── READ (single, auto-404) ──
@app.get("/users/<int:user_id>")
def get_user(user_id):
user = db.get_or_404(User, user_id)
return {"id": user.id, "name": user.name, "email": user.email,
"posts": [p.title for p in user.posts]}
# ── UPDATE (partial) ──
@app.patch("/users/<int:user_id>")
def update_user(user_id):
user = db.get_or_404(User, user_id)
data = request.get_json()
if "name" in data: user.name = data["name"]
if "email" in data: user.email = data["email"]
db.session.commit()
return {"id": user.id, "name": user.name}
# ── DELETE ──
@app.delete("/users/<int:user_id>")
def delete_user(user_id):
user = db.get_or_404(User, user_id)
db.session.delete(user)
db.session.commit()
return "", 204
# ── Query patterns cheat-sheet ──
# db.session.execute(db.select(User)).scalars().all() # all
# db.session.execute(db.select(User).filter_by(name="Yatin")
# ).scalar_one_or_none() # one or None
# db.session.execute(db.select(User)
# .where(User.email.like("%@dev.com"))
# .order_by(User.created_at.desc()).limit(5)).scalars().all()
# Legacy style (still everywhere): User.query.filter_by(...).first()Migrations (Flask-Migrate)
What is it
Migrations are version control for your database schema. db.create_all() can only create missing tables — it will never add a column to an existing one. Flask-Migrate wraps Alembic (SQLAlchemy's migration engine) into three CLI commands: it diffs your models against the live database, generates a timestamped migration script (with upgrade()/downgrade() functions), and applies it. Every schema change becomes a reviewable, replayable file in git — so dev, staging, and prod databases all evolve identically.
The workflow
flask db init— once per project; creates themigrations/folder.flask db migrate -m "add users table"— autogenerate a script from model changes. Always read it before applying.flask db upgrade— apply pending migrations (run this on deploy).flask db downgrade— roll back one revision.flask db current/history— where am I / what exists.
Common gotchas
- Autogenerate misses renames: renaming a column looks like drop+add (data loss!). Hand-edit the script to use
op.alter_column. - SQLite limitations: SQLite can't ALTER much — Flask-Migrate needs
render_as_batch=True(batch mode) for it; another reason to develop against Postgres if prod is Postgres. - Models must be imported: if a model file is never imported by the app, autogenerate can't see it and may generate DROPs.
- Merge conflicts in heads: two branches creating migrations →
flask db merge headsto reconcile.
# pip install flask-migrate
from flask_migrate import Migrate
migrate = Migrate(app, db) # factory apps: migrate.init_app(app, db)# One-time setup
flask db init
# 1) You edit models.py — e.g., add User.bio column
# 2) Generate the migration
flask db migrate -m "add bio to users"
# 3) REVIEW migrations/versions/xxxx_add_bio_to_users.py
# def upgrade():
# op.add_column('users', sa.Column('bio', sa.String(500)))
# def downgrade():
# op.drop_column('users', 'bio')
# 4) Apply it
flask db upgrade
# On the server, deploys just run: flask db upgrade
# Oops? flask db downgradedrop_ — before it ever touches production data.
Forms & Validation (Flask-WTF)
What is it
For HTML form workflows, Flask-WTF (a wrapper around WTForms) turns forms into Python classes: each field declares its type and validators, the class knows how to render itself in Jinja, how to validate a POST, and how to display errors — and it gives you CSRF protection for free (a hidden signed token that must round-trip with every submit, powered by your SECRET_KEY). For JSON APIs, the same "schema" role is usually played by marshmallow or plain pydantic models validated by hand.
Key features
- Fields:
StringField,PasswordField,IntegerField,BooleanField,SelectField,TextAreaField,FileField,SubmitField… - Validators:
DataRequired(),Length(min, max),Email(),NumberRange(),EqualTo("password"),Regexp(), plus customvalidate_<field>methods. - One-liner flow:
form.validate_on_submit()— True only when the request is a POST and every validator passes and the CSRF token checks out. - CSRF: render
{{ form.hidden_tag() }}inside the<form>; forged cross-site posts then fail with 400. - Errors:
form.email.errors— per-field messages ready for the template.
How it differs
- vs FastAPI/Pydantic: Pydantic validates JSON in the function signature and auto-returns 422s. Flask-WTF is aimed at HTML round-trips — re-rendering the page with inline errors, which Pydantic doesn't do.
- vs Django forms: almost the same design (Django inspired WTForms); Django adds ModelForms bound to the ORM.
- vs manual
request.form: topic 05's hand-rolled validation works, but grows into spaghetti fast — forms centralize rules, messages, and rendering.
Common gotchas
- Missing
hidden_tag(): every submit fails with "The CSRF token is missing." It must be inside the form. - CSRF needs
SECRET_KEY: no key, no tokens. - APIs + CSRF: pure token-authenticated JSON APIs typically exempt CSRF (
CSRFProtectexemptions) — CSRF protects cookie-authenticated endpoints. - Validation ≠ sanitization: WTForms checks shape; Jinja's autoescaping handles output safety. Both matter.
# pip install flask-wtf email-validator
from flask import Flask, render_template, redirect, url_for, flash
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, IntegerField, SubmitField
from wtforms.validators import (DataRequired, Length, Email,
EqualTo, NumberRange, ValidationError)
app = Flask(__name__)
app.secret_key = "load-from-env" # powers CSRF tokens too
class RegisterForm(FlaskForm):
name = StringField("Name",
validators=[DataRequired(), Length(min=2, max=50)])
email = StringField("Email",
validators=[DataRequired(), Email()])
age = IntegerField("Age",
validators=[NumberRange(min=13, max=120)])
password = PasswordField("Password",
validators=[DataRequired(), Length(min=8)])
confirm = PasswordField("Confirm",
validators=[EqualTo("password", message="Passwords must match")])
submit = SubmitField("Create account")
def validate_email(self, field): # custom rule: auto-runs for `email`
if email_taken(field.data): # your DB check
raise ValidationError("That email is already registered.")
@app.route("/register", methods=["GET", "POST"])
def register():
form = RegisterForm()
if form.validate_on_submit(): # POST + all validators + CSRF ok
create_user(form.name.data, form.email.data, form.password.data)
flash("Welcome aboard!")
return redirect(url_for("login"))
return render_template("register.html", form=form) # GET or errors<form method="post" novalidate>
{{ form.hidden_tag() }} {# ← the CSRF token. NEVER omit. #}
{{ form.name.label }} {{ form.name(size=30) }}
{% for err in form.name.errors %}<small class="err">{{ err }}</small>{% endfor %}
{{ form.email.label }} {{ form.email() }}
{% for err in form.email.errors %}<small class="err">{{ err }}</small>{% endfor %}
{{ form.password.label }} {{ form.password() }}
{{ form.confirm.label }} {{ form.confirm() }}
{{ form.submit() }}
</form># pip install marshmallow
from marshmallow import Schema, fields, validate, ValidationError
class UserSchema(Schema):
name = fields.Str(required=True, validate=validate.Length(min=2, max=50))
email = fields.Email(required=True)
age = fields.Int(load_default=None,
validate=validate.Range(min=0, max=150))
user_schema = UserSchema()
@app.post("/api/users")
def api_create_user():
try:
data = user_schema.load(request.get_json() or {}) # validate + coerce
except ValidationError as err:
return {"errors": err.messages}, 400 # {"email": ["Not a valid email."]}
return {"created": data}, 201File Uploads
What is it
File uploads arrive as multipart/form-data requests — the encoding HTML file inputs use (the form MUST declare enctype="multipart/form-data" or the file never leaves the browser). Flask exposes them in request.files, a dict of FileStorage objects with .filename, .content_type, a stream, and a .save(path) method. The two iron rules: never trust the client's filename (sanitize with secure_filename() or replace with a UUID) and cap the size with MAX_CONTENT_LENGTH (oversize requests get an automatic 413).
The safe pipeline
- Limit size:
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024→ 413 beyond 16 MB. - Check presence:
"file" in request.filesandfile.filename != ""(empty submit sends a blank part). - Allow-list extensions: check the suffix against a set — and remember extensions can lie; for images, verify content (e.g., open with Pillow).
- Sanitize the name:
secure_filename("../../etc/passwd")→"etc_passwd"— kills path traversal. Even better: store asuuid4().hex + ext. - Store outside
static/and serve viasend_from_directory()so you control access; in real deployments, push to S3/GCS instead of local disk.
Common gotchas
- Missing
enctype:request.filesis empty and the filename lands inrequest.form— the #1 upload bug. - Trusting
content_type: it's client-supplied. Sniff real bytes for anything security-relevant. - Same-name collisions: two users upload
photo.jpg— the second overwrites the first unless you uniquify names. - Serving user files from Flask at scale: works, but each download ties up a worker — offload to nginx (
X-Accel-Redirect) or object-storage URLs.
import uuid
from pathlib import Path
from flask import Flask, request, abort, send_from_directory
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 # 16 MB → else 413
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
ALLOWED = {".png", ".jpg", ".jpeg", ".gif", ".pdf"}
def allowed(filename: str) -> bool:
return Path(filename).suffix.lower() in ALLOWED
# ── Single file ──
@app.post("/upload")
def upload():
if "file" not in request.files:
return {"error": "no file part (did you set enctype?)"}, 400
file = request.files["file"]
if file.filename == "":
return {"error": "no file selected"}, 400
if not allowed(file.filename):
return {"error": "file type not allowed"}, 400
ext = Path(secure_filename(file.filename)).suffix.lower()
stored = f"{uuid.uuid4().hex}{ext}" # unguessable, collision-proof
file.save(UPLOAD_DIR / stored)
return {"stored_as": stored,
"original": file.filename,
"content_type": file.content_type}, 201
# ── Multiple files: <input type="file" name="files" multiple> ──
@app.post("/upload-many")
def upload_many():
saved = []
for file in request.files.getlist("files"):
if file and allowed(file.filename):
stored = f"{uuid.uuid4().hex}{Path(file.filename).suffix.lower()}"
file.save(UPLOAD_DIR / stored)
saved.append(stored)
return {"saved": saved}, 201
# ── Serving uploads back (path-traversal safe) ──
@app.get("/files/<name>")
def get_file(name):
return send_from_directory(UPLOAD_DIR, name) # 404s outside the dir
# ── Friendly 413 ──
@app.errorhandler(413)
def too_big(e):
return {"error": "file too large (max 16 MB)"}, 413<form method="post" action="/upload" enctype="multipart/form-data">
<input type="file" name="file">
<button>Upload</button>
</form>REST APIs & CORS
What is it
A REST API in Flask is just routes that speak JSON with proper verbs and status codes — a blueprint under /api/v1, dict returns, JSON error handlers, and validation. For grouping the verbs of one resource, Flask ships MethodView: a class whose get/post/put/delete methods become the handlers. CORS (Cross-Origin Resource Sharing) is the browser rule that blocks JavaScript on app.example.com from calling api.example.com unless the API sends explicit Access-Control-Allow-* headers — in Flask you enable that with the flask-cors extension (it also answers the preflight OPTIONS requests browsers send before "non-simple" calls like JSON POSTs).
REST design checklist
- Nouns in URLs, verbs in methods:
GET /api/v1/users,POST /api/v1/users,GET/PATCH/DELETE /api/v1/users/<id>. - Version from day one: a
/api/v1blueprint makes v2 painless. - Consistent envelopes: one error shape everywhere —
{"error": {...}}via anHTTPExceptionhandler (topic 10). - Right codes: 201 on create, 204 on delete, 400/422 on bad input, 401/403 on auth, 409 on conflicts.
- Pagination, filtering, sorting via query params:
?page=2&per_page=20&sort=-created_at. - Ecosystem:
Flask-RESTful(classic Resource classes),flask-smorest/APIFlask(marshmallow + auto OpenAPI docs — the closest to FastAPI's DX).
CORS gotchas
- CORS is a browser thing: curl/Postman ignore it — "works in Postman, fails in React" = CORS.
origins="*"+ credentials is forbidden: to send cookies cross-origin you must list explicit origins AND setsupports_credentials=TrueAND the frontend must usecredentials: "include".- Preflight failures look weird: the browser sends
OPTIONSfirst; if your auth middleware 401s OPTIONS requests, every call dies before it starts — flask-cors handles OPTIONS for you, don't block it. - Don't reflect arbitrary origins in production — that's equivalent to
*with extra steps.
# pip install flask-cors
from flask import Flask, Blueprint, request, jsonify
from flask.views import MethodView
from flask_cors import CORS
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
# ── CORS: allow the frontend origin(s) on /api/* ──
CORS(app,
resources={r"/api/*": {"origins": ["http://localhost:3000",
"https://myapp.com"]}},
supports_credentials=True)
api = Blueprint("api", __name__, url_prefix="/api/v1")
USERS = {1: {"id": 1, "name": "Yatin", "email": "y@dev.com"}}
NEXT_ID = 2
# ── MethodView: one class per resource ──
class UserListAPI(MethodView):
def get(self):
return jsonify(list(USERS.values()))
def post(self):
global NEXT_ID
data = request.get_json(silent=True) or {}
if not data.get("name") or not data.get("email"):
return {"error": "name and email required"}, 400
user = {"id": NEXT_ID, **data}
USERS[NEXT_ID] = user
NEXT_ID += 1
return user, 201
class UserAPI(MethodView):
def get(self, user_id):
user = USERS.get(user_id)
return (user, 200) if user else ({"error": "not found"}, 404)
def patch(self, user_id):
user = USERS.get(user_id)
if not user:
return {"error": "not found"}, 404
user.update(request.get_json(silent=True) or {})
return user
def delete(self, user_id):
USERS.pop(user_id, None)
return "", 204
api.add_url_rule("/users",
view_func=UserListAPI.as_view("user_list"))
api.add_url_rule("/users/<int:user_id>",
view_func=UserAPI.as_view("user_detail"))
# ── One JSON error shape for the whole API ──
@app.errorhandler(HTTPException)
def api_error(e):
return {"error": {"status": e.code, "message": e.description or e.name}}, e.code
app.register_blueprint(api)What CORS actually sends
# Browser preflight (automatic, before your JSON POST):
OPTIONS /api/v1/users
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
# flask-cors replies:
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Credentials: true
# Only THEN does the browser send the real POST.Async in Flask
What is it
Since Flask 2.0 you can write async def views (install flask[async]). But there's a crucial truth: Flask remains a WSGI framework. When an async view runs, Flask spins up an event loop for that one request inside its worker thread, awaits your coroutine, and returns. You get to use await (handy for calling async libraries like httpx concurrently within one request), but you do not get ASGI-style concurrency — a worker still handles one request at a time. Async Flask ≠ async FastAPI.
What async views are (and aren't) good for
- Good: fanning out several external HTTP calls in parallel inside one request (
asyncio.gather), reusing async-only SDKs. - Not good: serving more concurrent users. Throughput still comes from more workers/threads (gunicorn), not from
async def. - Real async needs ASGI: if the app is fundamentally about long-lived connections (WebSockets, SSE, thousands of slow clients) use Quart (Flask's official ASGI twin — nearly the same API, migration is mostly
flask→quartimports +await) or FastAPI. - Slow background work (emails, reports, video processing) belongs in Celery (topic 27), regardless of sync/async.
Common gotchas
- Expecting Node-like scaling: the loop lives and dies with each request — no shared event loop, no free concurrency.
- Missing extra: plain
pip install flask→ async views raise; you needpip install "flask[async]"(installs asgiref). - Async extensions are rare: Flask-SQLAlchemy etc. are sync — mixing async views with sync DB calls just blocks inside the loop.
- Per-request loop overhead: a tiny cost on every async view; don't mark views async "for style."
# pip install "flask[async]" httpx
import asyncio, httpx
from flask import Flask
app = Flask(__name__)
# ── The legit use case: parallel outbound calls in ONE request ──
@app.get("/dashboard-data")
async def dashboard_data():
async with httpx.AsyncClient(timeout=5) as client:
users_task = client.get("https://api.internal/users/stats")
sales_task = client.get("https://api.internal/sales/stats")
alerts_task = client.get("https://api.internal/alerts")
users, sales, alerts = await asyncio.gather(
users_task, sales_task, alerts_task
) # 3 calls in the time of the slowest one
return {
"users": users.json(),
"sales": sales.json(),
"alerts": alerts.json(),
}
# Sync version would take t1 + t2 + t3.
# Async version takes max(t1, t2, t3).
# But NOTE: the worker is still busy for this whole request —
# other users' requests wait for a free worker either way.Choosing your concurrency story
| Need | Right tool |
|---|---|
| Handle more simultaneous users | More gunicorn workers/threads + caching (topics 25/26) |
| Several slow API calls in one request | Flask async view + asyncio.gather |
| Long tasks after the response | Celery + Redis (topic 27) |
| WebSockets / SSE / massive concurrency | Quart or FastAPI (ASGI) |
Project Structure (App Factory)
What is it
The application factory is the pattern every serious Flask project converges on: instead of creating the app at import time (app = Flask(__name__) at the top of a module), you write a create_app(config) function that builds and returns a fully configured app. Extensions are instantiated empty in a shared module (db = SQLAlchemy()) and bound later inside the factory (db.init_app(app)). This solves the three problems that kill single-file apps as they grow: circular imports (models need db, db needed app, routes need models…), testing (each test builds a fresh app with test config), and multiple configurations (dev/test/prod from one codebase).
Why the factory wins
- No circular imports: blueprints and models import
dbfromextensions.py— nobody imports the app object. - Testable:
create_app(TestConfig)gives every test an isolated app + in-memory DB. - Config as classes:
DevConfig/TestConfig/ProdConfiginherit a base; secrets come from environment variables (python-dotenvloads.envin dev). - Deploy-friendly: gunicorn can call the factory directly:
gunicorn "app:create_app()". - Layered: routes stay thin; business logic lives in
services/; DB shape lives inmodels/— everything swappable and testable.
Production request flow
# How a request flows in production:
#
# Client → nginx (TLS, static files, rate limit)
# │
# └→ gunicorn (WSGI process manager)
# ├── worker 1 ─┐
# ├── worker 2 ─┼── your Flask app (create_app())
# ├── worker 3 ─┤
# └── worker 4 ─┘
# │
# PostgreSQL / Redis / Celery workers
#
# Run command:
# gunicorn "app:create_app()" -w 4 -b 0.0.0.0:8000Full folder structure
The complete layout for a production Flask app — config, extensions, blueprints, services, templates, and tests all separated.
my-flask-app/
│
├── app/ # all application code (the package)
│ ├── __init__.py # create_app() factory lives here
│ ├── config.py # Dev/Test/Prod config classes
│ ├── extensions.py # db, migrate, login_manager (unbound)
│ │
│ ├── models/ # SQLAlchemy models (DB tables)
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── post.py
│ │
│ ├── blueprints/ # route handlers, grouped by feature
│ │ ├── __init__.py
│ │ ├── main.py # /, /about — public pages
│ │ ├── auth.py # /auth/login, /auth/register
│ │ └── api.py # /api/v1/* — JSON endpoints
│ │
│ ├── services/ # business logic (no HTTP in here)
│ │ ├── __init__.py
│ │ └── user_service.py
│ │
│ ├── templates/ # Jinja2 templates
│ │ ├── base.html
│ │ ├── auth/login.html
│ │ └── errors/404.html
│ │
│ └── static/ # css / js / images
│ └── css/style.css
│
├── migrations/ # Flask-Migrate / Alembic scripts
├── tests/ # mirrors app/ structure
│ ├── conftest.py # fixtures: app, client, db
│ ├── test_auth.py
│ └── test_api.py
│
├── wsgi.py # entry point: app = create_app()
├── .env # secrets (NEVER commit)
├── .env.example # template for .env (commit this)
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
└── .gitignoreEvery file explained
# One class per environment. Secrets always come from env vars.
import os
from dotenv import load_dotenv
load_dotenv() # reads .env in development
class BaseConfig:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-change-me")
SQLALCHEMY_TRACK_MODIFICATIONS = False
class DevConfig(BaseConfig):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL", "sqlite:///dev.db")
class TestConfig(BaseConfig):
TESTING = True
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
WTF_CSRF_ENABLED = False # simpler form tests
class ProdConfig(BaseConfig):
DEBUG = False
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"] # required!
SESSION_COOKIE_SECURE = True
REMEMBER_COOKIE_SECURE = True
config_map = {"dev": DevConfig, "test": TestConfig, "prod": ProdConfig}# Instantiate extensions WITHOUT an app.
# Everyone imports from here → zero circular imports.
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" # blueprint.endpointimport os
from flask import Flask, render_template
from .config import config_map
from .extensions import db, migrate, login_manager
def create_app(config_name: str | None = None) -> Flask:
config_name = config_name or os.environ.get("FLASK_CONFIG", "dev")
app = Flask(__name__)
app.config.from_object(config_map[config_name])
# ── 1. Bind extensions ──
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
# ── 2. Register blueprints (import here → no cycles) ──
from .blueprints.main import bp as main_bp
from .blueprints.auth import bp as auth_bp
from .blueprints.api import bp as api_bp
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp, url_prefix="/auth")
app.register_blueprint(api_bp, url_prefix="/api/v1")
# ── 3. Error pages ──
@app.errorhandler(404)
def not_found(e):
return render_template("errors/404.html"), 404
# ── 4. Shell context (flask shell gets db + models for free) ──
from .models.user import User
@app.shell_context_processor
def shell_ctx():
return {"db": db, "User": User}
return appfrom flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from ..extensions import db, login_manager
class User(UserMixin, db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, index=True, nullable=False)
pw_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(20), default="user")
# Never store the plain password — property blocks reads too
@property
def password(self):
raise AttributeError("password is write-only")
@password.setter
def password(self, plain):
self.pw_hash = generate_password_hash(plain)
def check_password(self, plain) -> bool:
return check_password_hash(self.pw_hash, plain)
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))# Pure business logic — no request/response objects.
# Reusable from routes, CLI commands, Celery tasks, and tests.
from ..extensions import db
from ..models.user import User
class EmailTaken(Exception):
pass
def register_user(email: str, password: str) -> User:
exists = db.session.execute(
db.select(User).filter_by(email=email)
).scalar_one_or_none()
if exists:
raise EmailTaken(email)
user = User(email=email, password=password) # setter hashes it
db.session.add(user)
db.session.commit()
return user
def authenticate(email: str, password: str) -> User | None:
user = db.session.execute(
db.select(User).filter_by(email=email)
).scalar_one_or_none()
if user and user.check_password(password):
return user
return None# Routes stay THIN: parse → call service → respond.
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required
from ..services import user_service
bp = Blueprint("auth", __name__)
@bp.route("/register", methods=["GET", "POST"])
def register():
if request.method == "POST":
try:
user_service.register_user(request.form["email"],
request.form["password"])
flash("Account created — log in!")
return redirect(url_for("auth.login"))
except user_service.EmailTaken:
flash("Email already registered")
return render_template("auth/register.html")
@bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
user = user_service.authenticate(request.form["email"],
request.form["password"])
if user:
login_user(user)
return redirect(url_for("main.index"))
flash("Invalid credentials")
return render_template("auth/login.html")
@bp.post("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("auth.login"))# wsgi.py — what servers import
from app import create_app
app = create_app()
# Run dev: flask --app wsgi run --debug
# Run prod: gunicorn wsgi:app -w 4 -b 0.0.0.0:8000
# ── .env (NEVER commit — real secrets) ──
# SECRET_KEY=long-random-string-here
# DATABASE_URL=postgresql://myuser:mypass@localhost:5432/mydb
# FLASK_CONFIG=dev
# ── .env.example (commit this template) ──
# SECRET_KEY=change-me
# DATABASE_URL=postgresql://user:password@localhost:5432/dbname
# FLASK_CONFIG=devHow a request flows through the layers
# POST /auth/register (email=y@dev.com, password=secret123)
#
# 1. gunicorn worker hands the WSGI request to Flask
# 2. Werkzeug routing → blueprint "auth", view register()
# 3. before_request hooks run (auth gates, timers…)
# 4. View parses request.form → calls user_service.register_user()
# 5. Service enforces business rules (email unique),
# hashes password, db.session.add + commit
# 6. View redirects → after_request hooks add headers
# 7. teardown removes the DB session
#
# Request: Client → nginx → gunicorn → routing → blueprint → service → db
# Response: db → service → blueprint → hooks → gunicorn → nginx → ClientTesting
What is it
Flask testing means instantiating your app in-process and firing fake requests at it — no real server, no network. The core tool is app.test_client(), which returns a client with .get() / .post() / .patch() / .delete() methods that run the full request cycle (routing, hooks, views, error handlers) and hand back a response you can assert on. Paired with pytest fixtures and the app factory, every test gets a fresh app configured with TestConfig (in-memory SQLite, CSRF off) and a clean database.
Core tools
create_app("test")— a dedicated config:TESTING=True, in-memory DB,WTF_CSRF_ENABLED=False.app.test_client()— the fake browser.client.post("/api/users", json={...})sends JSON;data={...}sends form data;follow_redirects=Truechases 302s.response.status_code,response.get_json(),response.data(bytes),response.text,response.headers— what you assert on.app.test_cli_runner()— invoke customflaskCLI commands in tests.app.test_request_context()— fake a request context to unit-test helpers that touchrequest/url_for.client.session_transaction()— read/write the session inside tests (e.g., pre-log-in a user).
How it differs
- vs FastAPI: FastAPI's
TestClientis httpx-based and swaps dependencies viadependency_overrides. Flask swaps behavior via config + factory — same goal, different lever. - vs Django: Django's
TestCaseauto-wraps each test in a DB transaction; in Flask you build that yourself with fixtures (create/drop or rollback per test).
Common gotchas
- Shared DB state between tests: use a function-scoped fixture that creates and drops tables (or rolls back a transaction) per test — flaky "passes alone, fails in suite" bugs come from here.
- Forgetting the app context: touching
dbin a fixture outsideapp.app_context()→ RuntimeError. - CSRF in form tests: with CSRF on, every POST 400s — disable it in TestConfig or send the token.
- Testing redirects: assert
302+response.headers["Location"], or passfollow_redirects=Trueand assert the final page.
# pip install pytest
import pytest
from app import create_app
from app.extensions import db as _db
@pytest.fixture
def app():
"""Fresh app + clean in-memory DB for every test."""
app = create_app("test")
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 user(app):
"""A ready-made user for auth tests."""
from app.models.user import User
u = User(email="y@dev.com", password="secret123")
_db.session.add(u)
_db.session.commit()
return u
@pytest.fixture
def logged_in(client, user):
client.post("/auth/login",
data={"email": "y@dev.com", "password": "secret123"})
return clientdef test_health(client):
resp = client.get("/api/v1/health")
assert resp.status_code == 200
assert resp.get_json() == {"status": "ok"}
def test_create_user(client):
resp = client.post("/api/v1/users",
json={"name": "Yatin", "email": "new@dev.com"})
assert resp.status_code == 201
body = resp.get_json()
assert body["name"] == "Yatin"
assert "id" in body
def test_validation_error(client):
resp = client.post("/api/v1/users", json={"name": ""})
assert resp.status_code == 400
assert "error" in resp.get_json()
def test_missing_user_404(client):
assert client.get("/api/v1/users/9999").status_code == 404
def test_protected_requires_login(client):
resp = client.get("/dashboard")
assert resp.status_code == 302 # bounced to login
assert "/auth/login" in resp.headers["Location"]
def test_dashboard_when_logged_in(logged_in):
resp = logged_in.get("/dashboard")
assert resp.status_code == 200
assert b"y@dev.com" in resp.data
def test_login_flow_end_to_end(client, user):
resp = client.post("/auth/login",
data={"email": "y@dev.com", "password": "secret123"},
follow_redirects=True)
assert resp.status_code == 200
# ── Unit-testing a helper that needs the request context ──
def test_helper_with_request_context(app):
from flask import request
with app.test_request_context("/search?q=flask"):
assert request.args["q"] == "flask"
# ── Pre-seeding the session directly ──
def test_session_write(client):
with client.session_transaction() as sess:
sess["user_id"] = 42
resp = client.get("/whoami")
assert resp.get_json()["user_id"] == 42pytest -v # run everything
pytest tests/test_api.py -k login # just login tests
pip install pytest-cov
pytest --cov=app --cov-report=term-missing # coverage reportDeployment
What is it
Deployment means replacing the single-threaded dev server with a real WSGI server and putting the pieces around it: a process manager running multiple workers, a reverse proxy for TLS and static files, environment-based config, migrations on release, and logging/monitoring. The startup log literally warns you: "This is a development server. Do not use it in a production deployment." The standard stack is nginx → gunicorn → Flask on Linux (or waitress on Windows), usually wrapped in Docker.
The process model
- gunicorn:
gunicorn "app:create_app()" -w 4 -b 0.0.0.0:8000— 4 worker processes, each an independent copy of your app. - Worker count: rule of thumb
(2 × CPU cores) + 1. I/O-heavy apps can add threads:--threads 4(or use-k geventfor greenlet workers). - Timeouts:
--timeout 30kills stuck workers; long jobs belong in Celery, not in a request. - Statelessness: with N workers, in-process globals/caches diverge per worker — shared state (sessions, caches, rate limits) must live in Redis/DB.
- Windows: gunicorn is Unix-only — use
waitress-serve --port=8000 wsgi:app.
Common gotchas
- Debug mode in prod = remote code execution risk.
DEBUG=False, always. - Forgetting ProxyFix: behind nginx,
remote_addris 127.0.0.1 andurl_for(_external=True)builds http:// URLs — applyProxyFix(topic 15). - Secrets in git:
SECRET_KEY/DB passwords come from env vars or a secrets manager — never the repo. - Skipping migrations on deploy: app boots, first query 500s. Run
flask db upgradein the release step. - Static via Flask: wastes workers — nginx or WhiteNoise should serve
/static/.
Dockerfile
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Install deps first → Docker layer caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Non-root user (security best practice)
RUN adduser --disabled-password --no-create-home appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "wsgi:app", \
"-w", "4", \
"--threads", "2", \
"-b", "0.0.0.0:8000", \
"--access-logfile", "-"]docker-compose — full stack
# docker compose up --build → app + Postgres + Redis
services:
web:
build: .
ports:
- "8000:8000"
env_file: .env
depends_on: [db, redis]
command: >
sh -c "flask db upgrade &&
gunicorn wsgi:app -w 4 -b 0.0.0.0:8000"
db:
image: postgres:16
environment:
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
pgdata:nginx in front
server {
listen 80;
server_name myapp.com;
location /static/ { # nginx serves assets directly
alias /srv/myapp/app/static/;
expires 30d;
}
location / { # everything else → gunicorn
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Then in Flask: app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)Production checklist
| Item | How |
|---|---|
| Real WSGI server | gunicorn "app:create_app()" -w 4 (waitress on Windows) |
| Debug off | DEBUG=False, no dev server, no --reload |
| Secrets | Env vars / secret manager; long random SECRET_KEY |
| HTTPS | nginx/Caddy or cloud LB terminates TLS; SESSION_COOKIE_SECURE=True |
| Proxy awareness | ProxyFix middleware |
| Migrations | flask db upgrade in the release step |
| Static files | nginx alias or WhiteNoise |
| Logging | JSON/structured logs to stdout; Sentry for exceptions |
| Health check | /health endpoint for the LB / orchestrator |
| Rate limiting | Flask-Limiter + Redis (topic 26) |
| Background jobs | Celery workers, not request threads (topic 27) |
Where people host Flask
Render / Railway / Fly.io — push code, get a URL (simplest). A VPS (Hetzner, DigitalOcean, Linode) with nginx + gunicorn + systemd — the classic, cheapest at scale. Docker on ECS / Cloud Run / Kubernetes — containerized fleets. PythonAnywhere — beginner-friendly Flask hosting. Serverless (Lambda via Zappa/serverless-wsgi) exists but cold starts + WSGI make it a niche choice.
Redis with Flask
What is it
Redis (REmote DIctionary Server) is an in-memory data store — all data lives in RAM, so typical operations finish in well under a millisecond. In a Flask stack it sits beside your real database and plays four huge roles: cache (skip expensive queries), rate limiter (count requests per client), server-side session store (revocable logins), and message broker for Celery (topic 27). It's not just key→value: Redis has strings, hashes, lists, sets, sorted sets (leaderboards), streams, and pub/sub.
The Flask power trio
- Flask-Caching:
@cache.cached(timeout=300)caches a whole view;@cache.memoize()caches a function per argument set;cache.delete_memoized()invalidates. - Flask-Limiter:
@limiter.limit("10/minute")per route with counters stored in Redis so limits hold across all gunicorn workers. - Flask-Session:
SESSION_TYPE="redis"moves session data server-side — instant logout-everywhere, no 4KB cookie ceiling. - redis-py direct:
r = redis.from_url(...)for counters, locks, queues, and custom caching (cache-aside pattern below).
Common gotchas
- No TTL = memory leak: every cache key needs
ex=/timeout, or Redis fills until eviction/OOM. - Stale cache after writes: updating the DB without deleting the cached key serves old data — invalidate on every write path.
- Strings only: Redis stores bytes/strings —
json.dumps/loadsyour objects (avoid pickle for anything untrusted). KEYS *in production blocks the server — useSCAN.- Per-worker in-memory "caches" aren't caches: with 4 gunicorn workers you'd have 4 disagreeing dicts — this is exactly why the shared store must be Redis.
# pip install flask-caching redis
from flask import Flask
from flask_caching import Cache
app = Flask(__name__)
app.config.update(
CACHE_TYPE="RedisCache",
CACHE_REDIS_URL="redis://localhost:6379/0",
CACHE_DEFAULT_TIMEOUT=300,
)
cache = Cache(app) # factory apps: cache.init_app(app)
# ── Cache an entire view for 5 minutes ──
@app.get("/stats")
@cache.cached(timeout=300)
def stats():
return {"totals": run_expensive_report()} # hits DB 1×/5min
# ── Cache per-argument (memoize) ──
@cache.memoize(timeout=600)
def get_user_profile(user_id):
return db.session.get(User, user_id) # cached per user_id
@app.post("/users/<int:user_id>")
def update_user(user_id):
... # write to DB
cache.delete_memoized(get_user_profile, user_id) # invalidate!
return {"ok": True}# pip install redis flask-limiter
import json, redis
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
r = redis.from_url("redis://localhost:6379/0", decode_responses=True)
# ── Cache-aside pattern: check → miss → DB → store ──
@app.get("/products/<int:pid>")
def product(pid):
key = f"product:{pid}"
cached = r.get(key)
if cached:
return json.loads(cached) # cache HIT — no DB
prod = fetch_product_from_db(pid) # cache MISS
if prod is None:
return {"error": "not found"}, 404
r.set(key, json.dumps(prod), ex=300) # store with TTL
return prod
# Invalidate on write:
# r.delete(f"product:{pid}")
# ── Rate limiting (shared across ALL workers) ──
limiter = Limiter(
get_remote_address, # limit per client IP
app=app,
storage_uri="redis://localhost:6379/1",
default_limits=["200 per hour"],
)
@app.get("/api/search")
@limiter.limit("10 per minute") # stricter on this route
def search():
return {"results": []}
# Over the limit → automatic 429 Too Many RequestsRedis commands you'll actually use
| Operation | redis-py | Use case |
|---|---|---|
| Set with TTL | r.set("k", v, ex=300) | Cache for 5 min |
| Get | r.get("k") | Read cache |
| Delete | r.delete("k") | Invalidate |
| Increment | r.incr("counter") | Counters, rate limits |
| Expire / TTL | r.expire("k", 60) / r.ttl("k") | Lifetimes |
| Hash | r.hset("user:1", mapping={...}) / r.hgetall | Object storage |
| List push/pop | r.lpush("q", x) / r.rpop("q") | Simple queue |
| Sorted set | r.zadd("board", {"yatin": 92}) / r.zrevrange | Leaderboards |
| Publish | r.publish("events", msg) | Pub/sub fan-out |
Celery (Background Tasks)
What is it
Celery is a distributed task queue: your Flask view drops a job description onto a broker (Redis or RabbitMQ) and returns instantly; separate worker processes pick jobs up and run them, optionally storing results in a result backend. This is how Flask apps do anything slow — sending email, generating PDFs, resizing images, calling flaky third-party APIs, nightly reports — without freezing a web worker for the duration. Flask has no FastAPI-style BackgroundTasks; in the Flask world, Celery (or the lighter RQ) is the answer, and it brings retries, scheduling (beat), and horizontal scaling for free.
Core concepts
- Task: a plain function decorated with
@shared_task/@celery.task. - Calling:
task.delay(args)(shortcut) ortask.apply_async(args, countdown=60, retry=…)— both return anAsyncResultwith an.id. - Broker: the queue itself —
redis://localhost:6379/0is the easy start. - Worker:
celery -A make_celery worker --loglevel INFO— run as many as you need, on as many machines as you need. - Result backend: where return values/status live so the web app can poll
AsyncResult(id).state. - Beat: Celery's cron — periodic tasks on a schedule.
- Flask twist: tasks often need the DB/config → wrap execution in
app.app_context()(the officialFlaskTaskpattern below).
Common gotchas
- Passing ORM objects to tasks: arguments are JSON-serialized — pass
user_id, notuser, and re-fetch inside the task. - No app context in the worker: "Working outside of application context" — the
FlaskTaskwrapper exists precisely for this. - Forgetting to run the worker:
.delay()succeeds (job queued!) but nothing ever executes — a classic "why is my email not sending." - Non-idempotent retries: retries mean a task may run twice — make tasks safe to repeat (e.g., "send if not already sent").
- Windows: Celery's prefork pool struggles on Windows — dev with
--pool=soloor use WSL/Docker.
# pip install celery redis
# app/celery_utils.py — the official Flask docs pattern
from celery import Celery, Task
def celery_init_app(app):
class FlaskTask(Task):
def __call__(self, *args, **kwargs):
with app.app_context(): # DB + config work in tasks
return self.run(*args, **kwargs)
celery_app = Celery(app.name, task_cls=FlaskTask)
celery_app.config_from_object(app.config["CELERY"])
celery_app.set_default()
app.extensions["celery"] = celery_app
return celery_app
# In create_app():
app.config["CELERY"] = dict(
broker_url="redis://localhost:6379/0",
result_backend="redis://localhost:6379/1",
task_ignore_result=False,
)
celery_init_app(app)
# make_celery.py — what the worker command imports
from app import create_app
flask_app = create_app()
celery_app = flask_app.extensions["celery"]# app/tasks.py
from celery import shared_task
@shared_task(ignore_result=True)
def send_welcome_email(user_id):
user = db.session.get(User, user_id) # re-fetch by id
smtp_send(to=user.email, subject="Welcome!")
@shared_task(bind=True, max_retries=3)
def call_flaky_api(self, order_id):
try:
return post_to_partner(order_id)
except TimeoutError as exc:
# retry in 30s, then 60s, then 120s
raise self.retry(exc=exc, countdown=30 * (2 ** self.request.retries))
@shared_task
def generate_report(month):
data = crunch_numbers(month) # takes minutes — who cares!
return {"rows": len(data)}
# app/blueprints/api.py — fire and track
from celery.result import AsyncResult
from ..tasks import send_welcome_email, generate_report
@bp.post("/signup")
def signup():
user = user_service.register_user(...)
send_welcome_email.delay(user.id) # returns in microseconds
return {"id": user.id}, 201 # response is instant
@bp.post("/reports")
def start_report():
result = generate_report.delay("2026-08")
return {"task_id": result.id}, 202 # 202 Accepted
@bp.get("/reports/<task_id>")
def report_status(task_id):
res = AsyncResult(task_id)
return {
"state": res.state, # PENDING / STARTED / SUCCESS / FAILURE
"result": res.result if res.successful() else None,
}# Terminal 1 — Redis (broker + results)
docker run -p 6379:6379 redis:7-alpine
# Terminal 2 — the web app
gunicorn wsgi:app -w 4
# Terminal 3 — celery worker(s)
celery -A make_celery worker --loglevel INFO --concurrency 4
# Optional — scheduled tasks (Celery beat)
celery -A make_celery beat --loglevel INFO
# Optional — web dashboard for tasks
pip install flower
celery -A make_celery flower # → http://localhost:5555When to use what
| Need | Tool | Why |
|---|---|---|
| Anything > ~1s in a request | Celery task | Web workers stay free |
| Retries / scheduling / fan-out | Celery (+ beat) | Built in |
| Simple queue, less machinery | RQ | Redis-only, tiny API |
| Fire several HTTP calls in one request | Flask async view | No infra needed (topic 22) |
Interview Questions
The questions that actually get asked — tap to reveal crisp, interview-ready answers.
Q1What is Flask, and why is it called a "micro" framework?
Flask is a lightweight WSGI web framework built on Werkzeug (HTTP/routing) and Jinja2 (templates). "Micro" refers to the core being small and unopinionated — no built-in ORM, forms, or auth — not to the size of apps you can build. Functionality is added via extensions, so you assemble exactly the stack you need.
Q2Flask vs Django — when would you choose each?
Django when you want batteries included — ORM, admin, auth, migrations — and strong conventions for a large, content-heavy app or a big team. Flask when you want a small footprint, full architectural control, a service/API, a prototype, or to wrap something unusual (ML models, custom storage). Flask starts faster; Django decides more for you.
Q3Flask vs FastAPI?
FastAPI is ASGI/async-first with automatic Pydantic validation and auto-generated OpenAPI docs — ideal for high-concurrency JSON APIs. Flask is WSGI/sync, has first-class templates, a huge mature ecosystem, and total freedom, but validation and docs are DIY. Rendered websites and classic apps → Flask; typed modern APIs → FastAPI.
Q4What is WSGI? How does it differ from ASGI?
WSGI (Web Server Gateway Interface) is the standard synchronous contract between Python web servers (gunicorn) and apps (Flask): the server calls app(environ, start_response) once per request, one request per worker at a time. ASGI is its asynchronous successor — an event-driven interface supporting await, WebSockets, and thousands of concurrent connections per process. Flask speaks WSGI; FastAPI/Quart speak ASGI.
Q5What are Werkzeug and Jinja2?
Werkzeug is the WSGI toolkit under Flask: request/response objects, URL routing and converters, the dev server, the debugger, and utilities like secure_filename and password hashing. Jinja2 is the template engine: {{ }} expressions, {% %} statements, inheritance, filters, and auto-escaping. Flask is essentially an elegant glue layer over the two.
Q6Explain the application context and request context.
Flask pushes a request context for each HTTP request (powering request and session) and an application context whenever it works on behalf of an app (powering current_app and g); pushing a request context pushes an app context automatically. These "globals" are context-local proxies — each thread/execution context resolves them to its own real objects, which is why concurrent requests don't collide. Outside a request (scripts, shells, workers) you push one manually: with app.app_context():.
Q7What's the difference between g and session?
g lives in server memory for exactly one request — a scratchpad for things like the DB connection or the loaded user; it's gone when the response is sent. session persists across requests per user, stored client-side in a signed cookie — user_id, role, flash messages. Mixing them up is the classic Flask interview trap.
Q8How do Flask sessions work internally? Are they encrypted?
The session dict is serialized, signed with SECRET_KEY via itsdangerous, and stored in the browser cookie. On each request Flask verifies the signature — tampering invalidates the session. But the data is only base64-encoded: signed ≠ encrypted — users can read it, so never put secrets in it. Server-side sessions (Flask-Session + Redis) fix readability, size limits, and revocation.
Q9Why is SECRET_KEY so important?
It signs session cookies, CSRF tokens (Flask-WTF), and anything using itsdangerous (password-reset tokens, remember-me cookies). If it leaks, an attacker can forge sessions — e.g., mint a cookie claiming user_id=1 — and forge CSRF tokens. It must be long, random, per-environment, loaded from env vars, and rotating it logs everyone out.
Q10What is the application factory pattern and why use it?
Instead of a module-level app = Flask(__name__), you define create_app(config) that builds, configures, and returns the app; extensions are created unbound in extensions.py and attached with init_app(app). Benefits: no circular imports, per-test fresh apps with test config, multiple environments from one codebase, and clean deployment (gunicorn "app:create_app()").
Q11What are blueprints and why do they matter?
Blueprints are modular bundles of routes (plus templates, static files, hooks, and error handlers) registered onto the app, optionally under a URL prefix — auth, blog, api/v1. They give large apps structure, namespaced endpoints (url_for("blog.index")), scoped before_request/error handlers, versioned APIs, and they're the other half of the factory pattern's answer to circular imports.
Q12Explain before_request, after_request, and teardown_request.
before_request runs before the view — returning a response there skips the view (auth gates). The view runs. after_request(response) receives and must return the response — add headers, logging (skipped on unhandled exceptions). teardown_request/teardown_appcontext run always, even after errors — resource cleanup like closing DB connections. Blueprint-level versions scope to that blueprint.
Q13What does url_for do and why use it over hardcoded URLs?
url_for("endpoint", param=value) builds a URL from the endpoint name (function/blueprint name), not the path string. Rename a route and every template, redirect, and test keeps working; it handles URL prefixes, escaping, query args, blueprint namespaces, and _external=True absolute URLs. Hardcoding paths breaks silently the day anything moves.
Q14How does Flask's trailing-slash behavior work?
A rule ending in a slash (/projects/) behaves like a directory: requesting /projects gets a redirect to the canonical slashed URL. A rule without a slash (/about) behaves like a file: /about/ is a 404. It keeps URLs canonical (good for SEO), but API clients that don't follow redirects can trip on it.
Q15request.args vs request.form vs request.get_json() vs request.data?
args = query-string params (?page=2). form = urlencoded/multipart POST bodies (HTML forms). get_json() = parsed JSON bodies (fetch/axios). data = raw bytes when nothing else fits (webhooks, XML). values combines args+form. The classic bug: sending JSON from the frontend and reading request.form — it'll be empty.
Q16What is CSRF and how does Flask defend against it?
Cross-Site Request Forgery: a malicious site makes the victim's browser send a state-changing request to your app, riding on their cookies. Defense: Flask-WTF's CSRF tokens — a signed token embedded in each form (form.hidden_tag()) that attackers can't read or forge; requests without a valid token get 400. Plus SameSite=Lax cookies as a second layer. Pure token-header APIs (no cookies) are inherently CSRF-safe.
Q17How do you handle errors globally in Flask?
abort(code) raises a Werkzeug HTTPException from anywhere; @app.errorhandler(404) customizes a status; @app.errorhandler(MyDomainError) maps your own exceptions to responses; and a single @app.errorhandler(HTTPException) can JSON-ify every HTTP error for APIs. Unhandled exceptions become 500s — log them (Sentry) and return a generic body, never a traceback.
Q18Why can't you use the Flask dev server in production, and what do you use?
The dev server is single-process, not hardened, not performance-tuned — and debug mode's interactive debugger is remote code execution if exposed. Production: gunicorn with multiple workers ((2×CPU)+1) behind nginx for TLS/static/buffering, with ProxyFix applied — or waitress on Windows — typically in Docker.
Q19How do you scale a Flask application?
Vertically: more gunicorn workers/threads per box. Horizontally: multiple stateless app instances behind a load balancer — which requires moving all shared state out of process: sessions/caches/rate-limits into Redis, files into object storage, long work into Celery workers, and the DB tuned with pooling, indexes, and read replicas. Cache aggressively (Flask-Caching), serve static via nginx/CDN.
Q20Can Flask do async? What are the limits?
Flask 2.0+ supports async def views (pip install "flask[async]"), but Flask remains WSGI: each async view gets a temporary event loop inside its worker thread. Useful for awaiting several external calls concurrently within one request (asyncio.gather) — but it does not increase how many users you can serve. True async concurrency (WebSockets, SSE, huge fan-in) needs ASGI: Quart (Flask's async twin) or FastAPI.
Q21How do you test a Flask app?
pytest + the app factory: a fixture builds create_app("test") (in-memory DB, CSRF off), creates tables in an app context, and yields app.test_client(). Tests call client.post("/api/users", json={...}) and assert on status_code / get_json(). Extras: test_request_context() for helpers, session_transaction() to preload sessions, follow_redirects=True for flows, coverage via pytest-cov.
Q22How would you add background jobs to Flask?
Flask has no built-in background task system, so anything slow goes to a task queue: Celery (or RQ) with Redis as broker — the view calls task.delay(id) and returns 202 immediately; separate worker processes execute with an app context (the FlaskTask wrapper), with retries and beat scheduling built in. Spawning raw threads from views is fragile (no retries, dies with the process) and belongs only in throwaway scripts.