Python · Web Framework

Flask Mastery

The micro-framework that grew up. Every concept, every gotcha, every pattern — from hello world to production, explained with code.

28 topics Flask 3.x Python 3.9+ Werkzeug + Jinja2 WSGI
01

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-Something package.
  • 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/await concurrency.
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, and current_app are proxies that only exist during a request/app context — using them outside one raises RuntimeError: 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

FeatureFlaskFastAPIDjango
ProtocolWSGI (sync)ASGI (async)WSGI / ASGI
PhilosophyMicro, unopinionatedAPI-first, typedBatteries-included
ValidationManual / extensionsAutomatic (Pydantic)Forms / DRF serializers
Auto API docsNo (add flasgger/smorest)Yes (Swagger + ReDoc)Via DRF plugins
TemplatesJinja2 (first-class)Jinja2 (optional)Django templates
ORMPick your ownPick your ownBuilt-in
Admin panelNo (Flask-Admin)NoYes, built-in
Learning curveVery easyEasySteep
Best forWebsites, small APIs, prototypesModern JSON APIs, ML servingLarge content-driven apps
Mental model Flask = Werkzeug (HTTP plumbing) + Jinja2 (HTML rendering) + a thin, elegant glue layer with routing, contexts, sessions, and blueprints. Everything else is an extension.
02

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 .venv then source .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 — main is the module, --debug enables reload + debugger.
  • Run (code way): put app.run(debug=True) under if __name__ == "__main__": and do python main.py.
  • Auto-discovery: if your file is named app.py or wsgi.py, plain flask run finds 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 5001 or disable AirPlay Receiver.
  • debug=True in 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_ENV is dead: deprecated since Flask 2.2 — use --debug or FLASK_DEBUG=1 instead.
  • __name__ matters: Flask(__name__) tells Flask where the app lives so it can find templates/ and static/ 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.

bash
# Create project + install
python -m venv .venv
source .venv/bin/activate
pip install flask
python · main.py
from 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
bash
# 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"}
That's it No config files, no project generator, no boilerplate. One import, one object, one decorator per route. This simplicity is the entire reason Flask won a generation of Python developers.

What debug mode gives you

FeatureWhat it doesWhy it matters
Auto-reloaderRestarts the server when a file changesNo manual restarts while coding
Interactive debuggerIn-browser traceback with a live Python console at every frameInspect variables at the crash site
Debugger PINConsole locked behind a PIN printed at startupWeak protection — still never expose it publicly
Better errorsFull stack traces instead of a bare 500 pageFaster debugging
03

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 returns 204 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 /projects redirects 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/me before /users/<id>; static parts win over converters.
  • Class-based views: MethodView groups get()/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 a methods list and Werkzeug ranks routes by specificity.
  • vs Django: Django separates routing into urls.py files with path() 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.
python
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/"
Rule of thumb Always generate URLs with url_for(). If you ever rename a path, every template, redirect, and test keeps working because they reference the endpoint, not the string.
04

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> captures docs/a/b.txt.
  • uuid — UUID strings, converted to a uuid.UUID object.
  • 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 returns 422 with an error body on mismatch. Flask declares type in the rule and returns 404 — 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; you parseInt manually.
Common gotchas
  • int means non-negative: <int:id> won't match -5; it 404s. Use int(signed=True) in a custom rule or accept a string.
  • Parameter names must match: the name in <int:user_id> must equal the function argument user_id, or Flask raises a TypeError at request time.
  • path converter security: capturing slashes means ../../etc/passwd-style input can arrive — never join it directly to a filesystem path; use send_from_directory() or werkzeug.utils.safe_join().
  • Validation beyond type: converters check format only. Range checks (id > 0 and exists in DB) are on you — typically db.get_or_404().
python
# ── 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}"
05

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. A MultiDict (a dict where keys can repeat).
  • request.form — form body fields from POST/PUT.
  • request.values — args + form combined (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't application/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) — returns None instead 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 (with express.json() middleware) — nearly identical manual style.
Common gotchas
  • Everything is a string: request.args.get("page") returns "2", not 2. Always pass type=int or convert explicitly.
  • request.form vs JSON confusion: a fetch/axios call sending JSON puts nothing in request.form — it's in get_json(). Mixing these up is the most common "my data is empty" bug.
  • type= swallows errors: ?page=abc with type=int silently 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.
python
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}}, 201

Which bucket has my data?

Client sendsContent-TypeRead it with
?page=2 in the URL—request.args
HTML <form> submitx-www-form-urlencodedrequest.form
Form with file inputmultipart/form-datarequest.form + request.files
fetch/axios JSONapplication/jsonrequest.get_json()
Raw payload (webhooks, XML)anythingrequest.data (bytes)
06

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 (or req) 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 Request object when needed. Flask is the opposite: raw request first, sugar optional.
Common gotchas
  • "Working outside of request context": touching request in a background thread, at import time, or in the shell raises RuntimeError. Use app.test_request_context() in tests/scripts, and pass plain values into threads/tasks instead of the proxy.
  • remote_addr behind a proxy: with nginx/a load balancer in front, remote_addr is the proxy's IP. Apply ProxyFix middleware (topic 15) so X-Forwarded-For is trusted correctly — and only trust it from your own proxy.
  • The proxy isn't the object: request is a stand-in. If a library needs the real object, use request._get_current_object() (rare, e.g., passing into a closure that outlives the request).
python
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"}
Mental model Think of 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.
07

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 a password_hash field 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 touches request.
  • 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.
python
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")
08

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 exactly templates/ and sit next to the module you passed as Flask(__name__) (or configure template_folder=).
  • |safe on 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) — enable app.jinja_env.undefined = StrictUndefined in 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.
python · app.py
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}
html · templates/base.html
<!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>
html · templates/profile.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 %}
Security freebie Because Jinja auto-escapes, {{ "<script>alert(1)</script>" }} renders as harmless text. You only lose that protection when you write |safe — so treat |safe like a loaded weapon.
09

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 with send_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 — always url_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.
text · layout
myapp/
├── app.py
├── static/
│   ├── css/style.css
│   ├── js/app.js
│   └── img/logo.png
└── templates/
    └── index.html
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">
nginx · production
# Let nginx serve static files directly — Python never sees them
location /static/ {
    alias /srv/myapp/static/;
    expires 30d;
    add_header Cache-Control "public, immutable";
}
10

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 after abort(404) never runs — and return abort(404) is redundant.
  • Broad except Exception swallows aborts: HTTPException is an Exception — re-raise it first or catch narrower types.
  • Handlers must return a status: forgetting , 404 in 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.
python
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"}, 500

Status codes you'll actually use

CodeMeaningWhen to use
200OKSuccessful GET / PUT / PATCH
201CreatedSuccessful POST that made a resource
204No ContentSuccessful DELETE (empty body)
301 / 302 / 308RedirectsMoved pages; Flask's trailing-slash redirect is 308
400Bad RequestInvalid input / failed validation
401UnauthorizedNot logged in / missing token
403ForbiddenLogged in but not allowed
404Not FoundResource or route doesn't exist
405Method Not AllowedWrong verb for the route (Flask auto)
409ConflictDuplicate / state conflict
413Payload Too LargeUpload over MAX_CONTENT_LENGTH
429Too Many RequestsRate limit hit
500Server ErrorUnhandled exception
11

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" requires secure.
  • 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 via resp.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.
python
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 resp
12

Sessions

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 touch session.
  • Lifetime: by default a browser-session cookie. Set session.permanent = True to use PERMANENT_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-Session extension 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_KEY logs everyone out: old signatures stop verifying. Plan rotations.
  • Hardcoded dev keys leak: a leaked SECRET_KEY lets 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; set session.modified = True or reassign the key.
python
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}
python · server-side sessions (Redis)
# 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.
Remember Default Flask sessions are tamper-proof, not private. Signed ≠ encrypted. Anything you put in session, assume the user can read.
13

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 an index view.
  • Own templates/static: template_folder="templates", static_folder="static" per blueprint.
  • Scoped hooks & errors: @bp.before_request and @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/users style 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 with app.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_for needs the namespace: inside blueprint blog, 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/…).
python · blog/routes.py
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
python · api/routes.py + main.py
# 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)
14

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 running Flask instance. 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 using current_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 so request/url_for work.
  • Cleanup hooks: @app.teardown_appcontext runs when the app context pops — the canonical place to close per-request DB connections stored on g.
  • CLI commands: @app.cli.command() functions run inside an app context automatically.
Common gotchas
  • g is per-request, not per-user: it does NOT persist between requests — that's session'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_app at import time. Move the code into a function, a hook, or wrap in app_context().
python
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

ObjectLifetimeLives whereTypical use
gOne requestServer memoryDB handle, current user object, request id
sessionMany requests (per user)Signed cookie (client)user_id, role, flash messages
current_appApp contextServerConfig, logger, extensions
requestOne requestServerIncoming data
15

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 its Depends() 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: instant TypeError — 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-For when not actually behind a proxy lets clients spoof their IP; set the x_for/x_proto counts to match your real proxy chain.
  • State between hooks: share via g, not module globals (topic 14).
python
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"}
python · WSGI middleware
# ── 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)
16

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.
  • UserMixin on your model — adds is_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 to login_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: localStorage is 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.
python · session auth with Flask-Login
# 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"))
python · JWT auth for APIs
# 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 forServer-rendered browser appsAPIs, SPAs, mobile, microservices
RevocationInstant (server-side / new key)Hard — needs a denylist
StateCookie auto-sent by browserClient attaches header manually
Cross-domainAwkwardNatural
ComplexityLowMedium (expiry, refresh, storage)
17

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 via db.Column(...) or 2.0-style Mapped[int] = mapped_column(...).
  • Relationships: db.relationship("Post", back_populates="author") + a db.ForeignKey column.
  • 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 in extensions.py, bind later with db.init_app(app).
How it differs
  • vs raw SQLAlchemy (as used with FastAPI): with FastAPI you hand-roll the engine, SessionLocal, and a get_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.posts fires one query per user — eager-load with db.selectinload(User.posts) in the select's options().
  • 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():.
python · models + setup
# 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
python · CRUD routes
# ── 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()
18

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 the migrations/ 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 heads to reconcile.
python + bash
# pip install flask-migrate
from flask_migrate import Migrate

migrate = Migrate(app, db)        # factory apps: migrate.init_app(app, db)
bash · daily workflow
# 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 downgrade
Golden rule Autogenerated migrations are a draft, not a truth. Review every script — especially anything containing drop_ — before it ever touches production data.
19

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 custom validate_<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 (CSRFProtect exemptions) — CSRF protects cookie-authenticated endpoints.
  • Validation ≠ sanitization: WTForms checks shape; Jinja's autoescaping handles output safety. Both matter.
python · forms.py + view
# 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
html · templates/register.html
<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>
python · JSON validation with marshmallow (API style)
# 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}, 201
20

File 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.files and file.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 as uuid4().hex + ext.
  • Store outside static/ and serve via send_from_directory() so you control access; in real deployments, push to S3/GCS instead of local disk.
Common gotchas
  • Missing enctype: request.files is empty and the filename lands in request.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.
python
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
html
<form method="post" action="/upload" enctype="multipart/form-data">
  <input type="file" name="file">
  <button>Upload</button>
</form>
21

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/v1 blueprint makes v2 painless.
  • Consistent envelopes: one error shape everywhere — {"error": {...}} via an HTTPException handler (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 set supports_credentials=True AND the frontend must use credentials: "include".
  • Preflight failures look weird: the browser sends OPTIONS first; 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.
python · class-based resources + CORS
# 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

http
# 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.
22

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→quart imports + 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 need pip 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."
python
# 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

NeedRight tool
Handle more simultaneous usersMore gunicorn workers/threads + caching (topics 25/26)
Several slow API calls in one requestFlask async view + asyncio.gather
Long tasks after the responseCelery + Redis (topic 27)
WebSockets / SSE / massive concurrencyQuart or FastAPI (ASGI)
23

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 db from extensions.py — nobody imports the app object.
  • Testable: create_app(TestConfig) gives every test an isolated app + in-memory DB.
  • Config as classes: DevConfig / TestConfig / ProdConfig inherit a base; secrets come from environment variables (python-dotenv loads .env in 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 in models/ — everything swappable and testable.

Production request flow

text
# 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:8000

Full folder structure

The complete layout for a production Flask app — config, extensions, blueprints, services, templates, and tests all separated.

text
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
└── .gitignore

Every file explained

python · app/config.py
# 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}
python · app/extensions.py
# 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.endpoint
python · app/__init__.py — the factory
import 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 app
python · app/models/user.py
from 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))
python · app/services/user_service.py
# 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
python · app/blueprints/auth.py
# 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"))
python · wsgi.py + .env
# 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=dev
Key principle Blueprints are thin (HTTP only) → services hold the logic (pure Python) → models define the data → extensions.py breaks the import cycle. Follow those four rules and a Flask codebase stays pleasant at any size.

How a request flows through the layers

text
# 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 → Client
24

Testing

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=True chases 302s.
  • response.status_code, response.get_json(), response.data (bytes), response.text, response.headers — what you assert on.
  • app.test_cli_runner() — invoke custom flask CLI commands in tests.
  • app.test_request_context() — fake a request context to unit-test helpers that touch request/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 TestClient is httpx-based and swaps dependencies via dependency_overrides. Flask swaps behavior via config + factory — same goal, different lever.
  • vs Django: Django's TestCase auto-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 db in a fixture outside app.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 pass follow_redirects=True and assert the final page.
python · tests/conftest.py
# 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 client
python · tests/test_api.py
def 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"] == 42
bash
pytest -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 report
Coverage sanity Aim for solid coverage of services and error paths, not a vanity 100%. The valuable tests are: auth boundaries, validation rejects, and the unhappy paths users will absolutely find.
25

Deployment

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 gevent for greenlet workers).
  • Timeouts: --timeout 30 kills 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_addr is 127.0.0.1 and url_for(_external=True) builds http:// URLs — apply ProxyFix (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 upgrade in the release step.
  • Static via Flask: wastes workers — nginx or WhiteNoise should serve /static/.

Dockerfile

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

yaml
# 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

nginx
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

ItemHow
Real WSGI servergunicorn "app:create_app()" -w 4 (waitress on Windows)
Debug offDEBUG=False, no dev server, no --reload
SecretsEnv vars / secret manager; long random SECRET_KEY
HTTPSnginx/Caddy or cloud LB terminates TLS; SESSION_COOKIE_SECURE=True
Proxy awarenessProxyFix middleware
Migrationsflask db upgrade in the release step
Static filesnginx alias or WhiteNoise
LoggingJSON/structured logs to stdout; Sentry for exceptions
Health check/health endpoint for the LB / orchestrator
Rate limitingFlask-Limiter + Redis (topic 26)
Background jobsCelery 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.

26

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/loads your objects (avoid pickle for anything untrusted).
  • KEYS * in production blocks the server — use SCAN.
  • 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.
python · caching with Flask-Caching
# 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}
python · manual cache-aside + rate limiting
# 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 Requests

Redis commands you'll actually use

Operationredis-pyUse case
Set with TTLr.set("k", v, ex=300)Cache for 5 min
Getr.get("k")Read cache
Deleter.delete("k")Invalidate
Incrementr.incr("counter")Counters, rate limits
Expire / TTLr.expire("k", 60) / r.ttl("k")Lifetimes
Hashr.hset("user:1", mapping={...}) / r.hgetallObject storage
List push/popr.lpush("q", x) / r.rpop("q")Simple queue
Sorted setr.zadd("board", {"yatin": 92}) / r.zrevrangeLeaderboards
Publishr.publish("events", msg)Pub/sub fan-out
Remember Redis is not your database. It's volatile speed. The truth lives in PostgreSQL; Redis just makes reading it (and coordinating workers) fast.
27

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) or task.apply_async(args, countdown=60, retry=…) — both return an AsyncResult with an .id.
  • Broker: the queue itself — redis://localhost:6379/0 is 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 official FlaskTask pattern below).
Common gotchas
  • Passing ORM objects to tasks: arguments are JSON-serialized — pass user_id, not user, and re-fetch inside the task.
  • No app context in the worker: "Working outside of application context" — the FlaskTask wrapper 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=solo or use WSL/Docker.
python · wiring Celery into the app factory
# 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"]
python · tasks + using them from views
# 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,
    }
bash · running it all
# 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:5555

When to use what

NeedToolWhy
Anything > ~1s in a requestCelery taskWeb workers stay free
Retries / scheduling / fan-outCelery (+ beat)Built in
Simple queue, less machineryRQRedis-only, tiny API
Fire several HTTP calls in one requestFlask async viewNo infra needed (topic 22)
28

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.