DevTools

Cheatsheet Flask

Micro-framework Python leve e flexível para aplicações web

Back to languages
Flask
72 cards found
Categories:
Versions:

Setup


9 cards
Install Flask
# Create virtual environment:
python -m venv venv
source venv/bin/activate   # Linux/Mac
venv\Scripts\activate      # Windows

# Install:
pip install flask

# Check:
flask --version
# Python 3.12, Flask 3.1, Werkzeug 3.1

venv isolates project dependencies. pip install flask installs the framework and dependencies (Werkzeug, Jinja2). Always activate the environment before working.

Configuration
# config.py
import os

class Config:
    SECRET_KEY = os.environ.get("SECRET_KEY", "dev")
    SQLALCHEMY_DATABASE_URI = os.environ.get(
        "DATABASE_URL", "sqlite:///app.db"
    )
    SQLALCHEMY_TRACK_MODIFICATIONS = False

class DevConfig(Config):
    DEBUG = True

class ProdConfig(Config):
    DEBUG = False

config = {
    "development": DevConfig,
    "production": ProdConfig,
    "default": DevConfig,
}

SECRET_KEY is required for sessions and CSRF. os.environ.get() reads environment variables. Config classes per environment (Dev, Prod). SQLALCHEMY_TRACK_MODIFICATIONS = False avoids overhead.

Flask CLI
# Useful commands:
flask routes              # list all routes
flask shell               # interactive shell
flask db upgrade          # apply migrations
flask db migrate -m "msg" # generate migration

# Custom commands:
import click

@app.cli.command("seed")
def seed():
    """Seed the database."""
    db.session.add(User(name="Admin"))
    db.session.commit()
    click.echo("Data created!")

# Run:
flask seed

flask routes lists registered endpoints. flask shell opens a REPL with the app loaded. @app.cli.command() creates custom commands. click.echo() prints to the terminal. Useful for seeds, cleanup and admin tasks.

Minimal App (Hello World)
# app.py
from flask import Flask

app = Flask(__name__)

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

if __name__ == "__main__":
    app.run(debug=True)

Flask(__name__) creates the application. @app.route() defines the endpoint. app.run(debug=True) starts the server with auto-reload. __name__ helps Flask locate templates and static files.

Environment Variables
# .env (use python-dotenv)
FLASK_APP=run.py
FLASK_DEBUG=1
SECRET_KEY=super-secret-123
DATABASE_URL=postgresql://user:pass@localhost/db

# Load automatically:
pip install python-dotenv

# Flask loads .env automatically!
# Or manually:
from dotenv import load_dotenv
load_dotenv()

# Access:
import os
key = os.environ["SECRET_KEY"]

FLASK_APP points to the app module. FLASK_DEBUG=1 enables debug. Flask 2.x+ loads .env automatically with python-dotenv. Never commit .env (add it to .gitignore).

Project Structure
project/
├── app/
│   ├── __init__.py      # app factory
│   ├── models.py        # SQLAlchemy models
│   ├── routes.py        # main routes
│   ├── templates/       # Jinja2 HTML
│   │   ├── base.html
│   │   └── index.html
│   └── static/          # CSS, JS, images
├── config.py            # settings
├── requirements.txt     # dependencies
└── run.py               # entry point

templates/ and static/ are located automatically by Flask. __init__.py contains the factory. config.py separates settings. requirements.txt lists dependencies for reproducibility.

Run the Server
# Recommended way (Flask CLI):
flask run
flask run --debug          # with debug
flask run --port 8080      # custom port
flask run --host 0.0.0.0   # accessible on the network

# Alternative (script):
python run.py

# With auto-reload and debug:
export FLASK_DEBUG=1
flask run

# Production server (NEVER use flask run):
gunicorn -w 4 "app:create_app()"

flask run is the standard command. --debug enables the debugger and reload. --host 0.0.0.0 exposes it on the local network. In production use gunicorn or uwsgi — Flask's server is for development only.

Application Factory
# app/__init__.py
from flask import Flask

def create_app(config_name="default"):
    app = Flask(__name__)
    app.config.from_object(config[config_name])

    # Initialize extensions
    db.init_app(app)
    migrate.init_app(app, db)
    login.init_app(app)

    # Register blueprints
    from .routes import bp
    app.register_blueprint(bp)

    return app

create_app() is the factory pattern — it allows multiple instances (tests, production). Extensions are initialized with init_app(). Blueprints are registered inside the factory. Essential for medium/large projects.

Essential Extensions
# Install common extensions:
pip install flask-sqlalchemy    # ORM
pip install flask-migrate       # migrations
pip install flask-login         # authentication
pip install flask-wtf           # forms
pip install flask-cors          # CORS
pip install flask-caching       # cache
pip install flask-mail          # emails
pip install flask-restful       # REST API

# requirements.txt:
flask>=3.0
flask-sqlalchemy>=3.1
flask-migrate>=4.0
flask-login>=0.6

Flask is minimalist — extensions add functionality. flask-sqlalchemy = ORM. flask-migrate = migrations (Alembic). flask-login = user sessions. flask-wtf = forms with CSRF. Always pin versions in requirements.txt.

Routes and Views


9 cards
Basic Routes
@app.route("/")
def home():
    return "Home page"

@app.route("/about")
def about():
    return render_template("about.html")

# Multiple routes for the same view:
@app.route("/hello")
@app.route("/hello/<name>")
def hello(name="World"):
    return f"Hello, {name}!"

@app.route() maps a URL to a function. The function name is the endpoint (used in url_for). Multiple decorators = multiple URLs. A default parameter value makes it optional.

url_for and Redirect
from flask import url_for, redirect

# Generate URLs (never hardcode!):
url_for("home")                      # "/"
url_for("profile", username="ana")   # "/user/ana"
url_for("post", id=5)                # "/post/5"

# With query string:
url_for("search", q="flask", page=2)
# "/search?q=flask&page=2"

# Redirect:
return redirect(url_for("login"))
return redirect(url_for("profile", username="ana"))

# Redirect with code:
return redirect(url_for("home"), code=301)

url_for() generates URLs from the function name — if the route changes, the code stays valid. redirect() returns 302 by default. code=301 for a permanent redirect. Always use url_for instead of hardcoded strings.

Subdomains and Rules
# Enable subdomains:
app.config["SERVER_NAME"] = "mysite.com"

@app.route("/", subdomain="<user>")
def profile_sub(user):
    return f"Profile of {user}"
# ana.mysite.com → "Profile of ana"

# Advanced rules:
from werkzeug.routing import Rule

app.url_map.add(Rule(
    "/api/<version>/users",
    endpoint="users",
    defaults={"version": "v1"}
))

# Custom convert:
from werkzeug.routing import BaseConverter

class RegexConverter(BaseConverter):
    def __init__(self, map, *items):
        super().__init__(map)
        self.regex = items[0]

subdomain="<user>" captures subdomains (requires SERVER_NAME). Rule allows advanced rules. Custom converters with BaseConverter and regex. Useful for multi-tenancy and versioned APIs.

HTTP Methods
@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        email = request.form["email"]
        # authenticate...
        return redirect(url_for("dashboard"))
    return render_template("login.html")

# REST API:
@app.route("/api/users", methods=["GET"])
def list_users(): ...

@app.route("/api/users", methods=["POST"])
def create(): ...

@app.route("/api/users/<int:id>", methods=["PUT", "DELETE"])
def update(id): ...

methods=["GET", "POST"] defines the accepted methods. Default is GET only. request.method indicates the method used. For APIs: GET=read, POST=create, PUT=update, DELETE=remove.

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

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

@app.errorhandler(403)
def access_denied(e):
    return "Access denied!", 403

# Custom error with abort:
from flask import abort

@app.route("/admin")
def admin():
    if not current_user.is_admin:
        abort(403)
    return "Admin panel"

@app.errorhandler(code) customizes error pages. abort(code) raises an HTTP error manually. Returning a (template, code) tuple sets the status. Useful for custom 404, 403, 500 pages with the app layout.

URL Parameters
# String (default):
@app.route("/user/<username>")
def profile(username):
    return f"Profile of {username}"

# Type converters:
@app.route("/post/<int:id>")
def post(id):          # id is int
    ...

@app.route("/price/<float:value>")
def price(value):      # value is float
    ...

@app.route("/path/<path:subpath>")
def file(subpath):     # accepts /
    ...

# Multiple parameters:
@app.route("/blog/<int:year>/<slug>")
def article(year, slug): ...

Converters: int, float, path (accepts slashes), string (default). If the type does not match → automatic 404. Multiple parameters separated by /. The name must match the function argument.

Before/After Request
@app.before_request
def before():
    # Runs BEFORE each request
    g.start = time.time()
    if request.endpoint != "static":
        # log, auth, etc.
        pass

@app.after_request
def after(response):
    # Runs AFTER (modifies the response)
    duration = time.time() - g.start
    response.headers["X-Duration"] = f"{duration:.3f}s"
    return response

@app.teardown_request
def teardown(exc):
    # Always runs (even on error)
    pass

@app.before_request runs before each request (auth, logging). @app.after_request modifies the response (headers, cache). @app.teardown_request always runs (cleanup). g is a per-request global object.

Query Parameters
from flask import request

# URL: /search?q=flask&page=2&lang=en
@app.route("/search")
def search():
    q = request.args.get("q", "")
    page = request.args.get("page", 1, type=int)
    lang = request.args.get("lang", "en")

    # All params:
    all_params = request.args.to_dict()
    # {"q": "flask", "page": "2", "lang": "en"}

    # Multiple values (list):
    # /tags?a=1&a=2
    tags = request.args.getlist("a")
    # ["1", "2"]

    return f"Search: {q}, page {page}"

request.args is an immutable dictionary. .get(key, default, type=) with automatic conversion. .getlist() for repeated parameters. .to_dict() converts everything. Values are always strings without type=.

Class-Based Views
from flask.views import MethodView

class UserAPI(MethodView):
    def get(self, user_id=None):
        if user_id is None:
            return {"users": ["ana", "john"]}
        return {"id": user_id}

    def post(self):
        data = request.json
        return {"created": data}, 201

    def delete(self, user_id):
        return "", 204

# Register:
app.add_url_rule(
    "/api/users",
    view_func=UserAPI.as_view("users"),
    defaults={"user_id": None}
)
app.add_url_rule(
    "/api/users/<int:user_id>",
    view_func=UserAPI.as_view("user_detail")
)

MethodView splits HTTP methods into class methods. Each method (get, post, delete) handles the matching verb. add_url_rule() registers it. Ideal for REST APIs with complex per-method logic.

Templates Jinja2


9 cards
Render Templates
from flask import render_template, render_template_string

# HTML file:
@app.route("/posts")
def posts():
    items = [{"title": "Post 1"}, {"title": "Post 2"}]
    return render_template(
        "posts.html",
        title="My Posts",
        posts=items,
        total=len(items)
    )

# Inline string (beware of XSS!):
return render_template_string(
    "Hello {{ name }}!", name="Anna"
)

render_template() looks in templates/. Pass variables the keyword arguments. render_template_string() for strings (avoid with user input — XSS risk). Data becomes available the variables in the template.

Loops
{% for post in posts %}
    <li>{{ loop.index }}. {{ post.title }}</li>
{% endfor %}

<!-- Loop variables: -->
{{ loop.index }}     <!-- 1, 2, 3... -->
{{ loop.index0 }}    <!-- 0, 1, 2... -->
{{ loop.first }}     <!-- True on the 1st -->
{{ loop.last }}      <!-- True on the last -->
{{ loop.length }}    <!-- total -->

<!-- Else (empty list): -->
{% for item in items %}
    <p>{{ item }}</p>
{% else %}
    <p>No items.</p>
{% endfor %}

<!-- Dict: -->
{% for key, value in dict.items() %}
    {{ key }}: {{ value }}
{% endfor %}

{% for %} iterates lists/dicts. loop.index (1-based), loop.first, loop.last are special variables. {% else %} runs if the list is empty. .items() for dictionaries.

Template Security
<!-- Jinja2 escapes HTML automatically: -->
{{ user_input }}
<!-- <script> → &lt;script&gt; (safe!) -->

<!-- DISABLE escaping (careful!): -->
{{ html_content | safe }}
<!-- Renders real HTML — only for trusted
     content (e.g. admin rich text) -->

<!-- Autoescape by extension: -->
<!-- .html → auto-escape ON -->
<!-- .txt → auto-escape OFF -->

<!-- Configure globally: -->
app.jinja_env.autoescape = True

<!-- Never do this: -->
<!-- {{ request.args.get("name") | safe }} -->
<!-- ↑ guaranteed XSS! -->

Jinja2 does auto-escape by default in .html — prevents XSS. | safe disables escaping (only for trusted content). Never apply safe to user input. Markup() marks strings the safe in Python.

Variables and Expressions
<!-- {{ }} for output -->
<h1>{{ title }}</h1>
<p>{{ user.name }}</p>
<p>{{ items[0] }}</p>
<p>{{ dict["key"] }}</p>

<!-- Expressions -->
<p>{{ 2 + 3 }}</p>
<p>{{ "Hello " ~ name }}</p>
<p>{{ items | length }} items</p>

<!-- Comments -->
{# This does not appear in the HTML #}

<!-- Whitespace control -->
{%- if active -%}
  Active
{%- endif -%}

{{ }} prints variables. Dot for attributes/keys: user.name. ~ concatenates strings. {# #} are comments. {%- -%} removes extra whitespace. Basic Python expressions are supported.

Template Inheritance
<!-- templates/base.html -->
<!DOCTYPE html>
<html>
<head>
    <title>{% block title %}App{% endblock %}</title>
</head>
<body>
    {% include "navbar.html" %}
    <main>
        {% block content %}{% endblock %}
    </main>
    {% block scripts %}{% endblock %}
</body>
</html>

<!-- templates/index.html -->
{% extends "base.html" %}

{% block title %}Home{% endblock %}

{% block content %}
    <h1>Home page</h1>
{% endblock %}

{% extends %} inherits from a base template. {% block %} defines overridable sections. The child only redefines the blocks it needs. {{ super() }} includes the parent block content. Essential pattern for consistent layouts.

Filters
<!-- Filters with pipe | -->
{{ name | upper }}           <!-- ANA -->
{{ name | lower }}           <!-- ana -->
{{ name | capitalize }}      <!-- Anna -->
{{ name | title }}           <!-- Anna Silva -->
{{ text | truncate(50) }}    <!-- cuts at 50 chars -->
{{ items | length }}         <!-- length -->
{{ items | join(", ") }}     <!-- "a, b, c" -->
{{ value | round(2) }}       <!-- 3.14 -->
{{ date | datetimeformat }}  <!-- custom -->
{{ html | safe }}            <!-- no escaping -->
{{ price | default("N/A") }} <!-- if None -->

<!-- Chain: -->
{{ name | trim | upper }}

Filters transform output with |. safe disables HTML escaping (careful!). default() for null values. truncate() cuts text. Chain with multiple |. Custom filters registered with @app.template_filter().

Include and Macros
<!-- Include (insert partial): -->
{% include "partials/navbar.html" %}
{% include "partials/footer.html" %}

<!-- With variables: -->
{% include "card.html" with context %}

<!-- Macros (reusable functions): -->
{% macro input(name, label, type="text") %}
<div class="field">
    <label for="{{ name }}">{{ label }}</label>
    <input type="{{ type }}" name="{{ name }}"
           id="{{ name }}">
</div>
{% endmacro %}

<!-- Use the macro: -->
{{ input("email", "Email", type="email") }}
{{ input("password", "Password", type="password") }}

{% include %} inserts partials (navbar, footer). {% macro %} creates reusable components with parameters. Macros accept defaults. Import from another file: {% from "forms.html" import input %}.

Conditionals
{% if user %}
    <p>Hello, {{ user.name }}</p>
{% elif guest_user %}
    <p>Welcome, guest!</p>
{% else %}
    <p>Please <a href="/login">log in</a></p>
{% endif %}

<!-- Operators: -->
{% if age >= 18 and active %}
{% if role in ["admin", "mod"] %}
{% if items is defined %}
{% if value is none %}
{% if name is not none %}

<!-- Inline expression: -->
<p>{{ "Admin" if user.is_admin else "User" }}</p>

{% if %} / {% elif %} / {% else %} / {% endif %}. Operators: and, or, not, in. Tests: is defined, is none. Ternary expression: {{ X if cond else Y }}.

Custom Filters and Tests
# Custom filter:
@app.template_filter("currency")
def currency_filter(value):
    return f"€{value:,.2f}"

# In the template: {{ price | currency }} → €1,234.56

# Filter with parameter:
@app.template_filter("truncate_words")
def truncate_words(text, n=20):
    words = text.split()[:n]
    return " ".join(words) + "..."

# Custom test:
@app.template_test("even")
def even_test(n):
    return n % 2 == 0

# In the template: {% if n is even %}

@app.template_filter("name") registers a custom filter. Use it with {{ value | name }}. @app.template_test() creates tests for {% if x is test %}. Filters receive the value the the 1st argument + extra parameters.

Request e Response


9 cards
Request Data
from flask import request

@app.route("/data", methods=["POST"])
def receive_data():
    # Form data (application/x-www-form-urlencoded):
    email = request.form["email"]
    name = request.form.get("name", "")

    # JSON body:
    data = request.json          # dict
    data = request.get_json()    # alternative
    data = request.get_json(silent=True)  # None if invalid

    # Files:
    photo = request.files["photo"]

    # Request info:
    request.method       # "POST"
    request.url          # full URL
    request.remote_addr  # client IP
    request.content_type # mime type

request.form for form data. request.json / get_json() for the JSON body. request.files for uploads. request.method indicates the verb. Use .get() with a default to avoid KeyError.

Sessions
from flask import session

# Requires SECRET_KEY configured!
app.config["SECRET_KEY"] = "super-secret"

@app.route("/login", methods=["POST"])
def login():
    if authenticate(request.form):
        session["user_id"] = user.id
        session["name"] = user.name
        session.permanent = True  # uses PERMANENT_SESSION_LIFETIME
        return redirect(url_for("dashboard"))
    return "Invalid credentials", 401

@app.route("/logout")
def logout():
    session.clear()       # clear everything
    session.pop("user_id", None)  # or a single key
    return redirect(url_for("home"))

# Check:
if "user_id" in session: ...

session stores data in a signed cookie (client side). Requires SECRET_KEY. session.permanent = True uses the configured lifetime. session.clear() wipes everything. Data is serialized — JSON-safe types only. Do not store complex objects.

CORS and Caching
# CORS (Cross-Origin Resource Sharing):
pip install flask-cors

from flask_cors import CORS

CORS(app)                    # all routes
CORS(app, resources={
    r"/api/*": {"origins": "https://mysite.com"}
})

# Cache with flask-caching:
pip install flask-caching

from flask_caching import Cache
cache = Cache(app, config={"CACHE_TYPE": "simple"})

@app.route("/data")
@cache.cached(timeout=300)   # 5 minutes
def data():
    return jsonify(heavy_data())

# Invalidate:
cache.delete("data")

flask-cors allows requests from other domains. Set origins to restrict. flask-caching with @cache.cached(timeout=N) avoids recomputation. Types: simple (memory), redis, memcached. Essential for public APIs.

JSON Responses (API)
from flask import jsonify

@app.route("/api/users")
def users():
    return jsonify({
        "users": [
            {"id": 1, "name": "Anna"},
            {"id": 2, "name": "John"},
        ],
        "total": 2
    })

# With status code:
return jsonify({"error": "Not found"}), 404

# Direct list (Flask 2.2+):
return [{"id": 1}, {"id": 2}]

# Direct dict (Flask 2.2+):
return {"status": "ok"}

# Custom headers:
resp = jsonify({"ok": True})
resp.headers["X-Total"] = "42"
return resp

jsonify() serializes a dict/list to JSON with Content-Type: application/json. Flask 2.2+ allows returning a dict/list directly. A (json, code) tuple sets the status. Ideal for REST APIs.

File Uploads
from werkzeug.utils import secure_filename
import os

UPLOAD_DIR = "uploads"
EXTENSIONS = {"png", "jpg", "pdf"}

@app.route("/upload", methods=["POST"])
def upload():
    f = request.files["file"]

    if not f or f.filename == "":
        return "No file", 400

    ext = f.filename.rsplit(".", 1)[1].lower()
    if ext not in EXTENSIONS:
        return "Type not allowed", 400

    name = secure_filename(f.filename)
    f.save(os.path.join(UPLOAD_DIR, name))
    return f"Uploaded: {name}", 201

# HTML: <form enctype="multipart/form-data">

request.files["field"] accesses the file. secure_filename() removes dangerous characters. Validate extension and size. f.save() saves to disk. The form needs enctype="multipart/form-data". Set MAX_CONTENT_LENGTH for a limit.

Headers and Status Code
from flask import make_response

# Simple status code:
return "Created!", 201
return "No content", 204
return "Error", 400

# Full response:
resp = make_response("OK")
resp.status_code = 200
resp.headers["X-Custom"] = "value"
resp.headers["Cache-Control"] = "no-cache"
resp.content_type = "text/plain"
return resp

# Tuple with headers:
return "Error", 400, {"X-Error": "validation"}

# Redirect:
return "", 302, {"Location": "/login"}

Return a tuple: (body, status) or (body, status, headers). make_response() creates an editable object. resp.headers[] adds headers. Codes: 200=OK, 201=Created, 204=No content, 400=Bad request, 404=Not found.

Download and Streaming
from flask import send_file, send_from_directory, Response

# Send a file:
@app.route("/download/<name>")
def download(name):
    return send_from_directory("uploads", name,
                               as_attachment=True)

# Generate and send:
@app.route("/export")
def export():
    return send_file(
        "report.pdf",
        mimetype="application/pdf",
        as_attachment=True,
        download_name="report.pdf"
    )

# Streaming (large files):
@app.route("/stream")
def stream():
    def generate():
        for i in range(1000):
            yield f"line {i}\n"
    return Response(generate(), mimetype="text/plain")

send_from_directory() sends a file safely (prevents path traversal). as_attachment=True forces download. Response(generator()) for streaming. mimetype sets the type. Ideal for exports and large files.

Cookies
from flask import request, make_response

# Set a cookie:
resp = make_response("Cookie set")
resp.set_cookie(
    "theme", "dark",
    max_age=3600,          # 1 hour
    httponly=True,         # no JS access
    secure=True,           # HTTPS only
    samesite="Lax"         # CSRF protection
)
return resp

# Read a cookie:
theme = request.cookies.get("theme", "light")

# Delete a cookie:
resp = make_response("Removed")
resp.delete_cookie("theme")
return resp

set_cookie() sets it with security options. httponly=True prevents JavaScript access. secure=True only sends over HTTPS. samesite protects against CSRF. request.cookies.get() reads. Prefer sessions for sensitive data.

Flash Messages
from flask import flash, get_flashed_messages

@app.route("/save", methods=["POST"])
def save():
    flash("Data saved successfully!", "success")
    flash("Email already registered.", "error")
    return redirect(url_for("profile"))

# In the template (base.html):
# {% with messages = get_flashed_messages(
#     with_categories=true) %}
#   {% for category, msg in messages %}
#     <div class="alert-{{ category }}">
#       {{ msg }}
#     </div>
#   {% endfor %}
# {% endwith %}

# Categories: success, error, warning, info

flash(msg, category) stores a message for the next request. get_flashed_messages(with_categories=true) retrieves it in the template. Messages are consumed (shown once). Requires SECRET_KEY. Standard for post-redirect feedback.

Database


9 cards
Flask-SQLAlchemy Setup
pip install flask-sqlalchemy

# app/__init__.py
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

def create_app():
    app = Flask(__name__)
    app.config["SQLALCHEMY_DATABASE_URI"] = \
        "sqlite:///app.db"
    db.init_app(app)
    return app

# Create tables (dev only!):
with app.app_context():
    db.create_all()

SQLAlchemy() creates the instance. db.init_app(app) binds it to the application (factory pattern). SQLALCHEMY_DATABASE_URI defines the DB. db.create_all() creates tables (use migrations in production). Requires app_context().

Relationships
class Post(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    comments = db.relationship(
        "Comment", backref="post",
        lazy="dynamic", cascade="all, delete-orphan"
    )

class Comment(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    text = db.Column(db.Text)
    post_id = db.Column(
        db.Integer, db.ForeignKey("post.id"),
        nullable=False
    )

# Use:
post.comments.all()
post.comments.filter_by(approved=True)
comment.post.title  # backref

db.relationship() defines the relationship. backref="post" creates reverse access. db.ForeignKey() on the child table column. lazy="dynamic" returns a query (filters). cascade="all, delete-orphan" deletes children when the parent is removed.

Raw SQL and Transactions
from sqlalchemy import text

# Direct SQL query:
result = db.session.execute(
    text("SELECT * FROM posts WHERE views > :min"),
    {"min": 100}
)
for row in result:
    print(row.title)

# Manual transaction:
try:
    db.session.begin_nested()  # SAVEPOINT
    db.session.add(Post(title="A"))
    db.session.add(Post(title="B"))
    db.session.commit()
except Exception:
    db.session.rollback()
    raise

# Context manager (Flask 2.x):
with db.session.begin():
    db.session.add(post)
    # automatic commit (or rollback)

db.session.execute(text(...)) for raw SQL with safe parameters. begin_nested() creates a SAVEPOINT. rollback() reverts on error. with db.session.begin() does automatic commit/rollback. Avoid raw SQL — prefer the ORM.

Define Models
from datetime import datetime

class Post(db.Model):
    __tablename__ = "posts"

    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    body = db.Column(db.Text, default="")
    views = db.Column(db.Integer, default=0)
    published = db.Column(db.Boolean, default=False)
    created = db.Column(db.DateTime, default=datetime.utcnow)
    price = db.Column(db.Float)

    def __repr__(self):
        return f"<Post {self.title}>"

# Types: Integer, String, Text, Boolean,
#         DateTime, Float, JSON, LargeBinary

db.Model is the base class. db.Column(type, options) defines fields. nullable=False = required. default= default value. __tablename__ customizes the table name. Common types: String, Integer, Text, Boolean, DateTime.

Migrations (Flask-Migrate)
pip install flask-migrate

# Setup:
from flask_migrate import Migrate
migrate = Migrate(app, db)

# CLI commands:
flask db init          # create migrations/ folder
flask db migrate -m "add posts table"
flask db upgrade       # apply
flask db downgrade     # revert
flask db history       # history
flask db current       # current version

# Workflow:
# 1. Modify the model
# 2. flask db migrate -m "description"
# 3. Review the generated file
# 4. flask db upgrade

flask-migrate uses Alembic under the hood. db init only once. db migrate generates the migration script. db upgrade applies it. Always review the generated file before applying. Essential for schema evolution in production.

Basic CRUD
# CREATE:
post = Post(title="Hello", body="Text")
db.session.add(post)
db.session.commit()

# READ:
post = Post.query.get(1)           # by PK
post = db.session.get(Post, 1)     # alternative
all_posts = Post.query.all()
first = Post.query.first()

# UPDATE:
post.title = "New title"
db.session.commit()

# DELETE:
db.session.delete(post)
db.session.commit()

# Bulk:
db.session.add_all([post1, post2, post3])
db.session.commit()

db.session.add() + commit() to create. query.get(id) fetches by PK. Modify attributes + commit() to update. db.session.delete() removes. Always commit() to persist. rollback() to cancel.

Pagination
@app.route("/posts")
def posts():
    page = request.args.get("page", 1, type=int)
    per_page = 20

    pagination = Post.query.order_by(
        Post.created.desc()
    ).paginate(
        page=page, per_page=per_page,
        error_out=False
    )

    posts = pagination.items      # page items
    total = pagination.total      # total records
    has_next_page = pagination.has_next
    has_prev_page = pagination.has_prev

    return render_template("posts.html",
        posts=posts, pagination=pagination)

# In the template:
# {% for p in pagination.iter_pages() %}

.paginate(page=, per_page=) splits results. .items = page records. .has_next / .has_prev for navigation. .iter_pages() generates page numbers. error_out=False returns empty instead of 404.

Queries and Filters
# Filters:
Post.query.filter_by(published=True).all()
Post.query.filter(Post.views > 100).all()
Post.query.filter(
    Post.title.like("%flask%")
).all()

# Multiple conditions:
from sqlalchemy import and_, or_
Post.query.filter(
    and_(Post.views > 50, Post.published == True)
).all()

# Ordering and limit:
Post.query.order_by(Post.created.desc()).limit(10).all()

# Count and aggregation:
Post.query.count()
Post.query.filter_by(published=True).count()

# First or 404:
post = Post.query.get_or_404(id)

filter_by() for simple equality. filter() for complex expressions (>, like, and_, or_). order_by() + desc() to sort. limit() restricts results. get_or_404() raises 404 if not found.

Events and Hooks
from sqlalchemy import event

# Before insert:
@event.listens_for(Post, "before_insert")
def before_insert_hook(mapper, connection, target):
    target.slug = generate_slug(target.title)

# After update:
@event.listens_for(Post, "after_update")
def after_update_hook(mapper, connection, target):
    target.updated = datetime.utcnow()

# Hybrid properties:
from sqlalchemy.ext.hybrid import hybrid_property

class User(db.Model):
    name = db.Column(db.String(50))
    last_name = db.Column(db.String(50))

    @hybrid_property
    def full_name(self):
        return f"{self.name} {self.last_name}"

@event.listens_for(Model, "event") registers hooks. Events: before_insert, after_update, before_delete. hybrid_property works in Python and in SQL queries. Useful for slugs, timestamps and computed fields.

Forms and Validation


9 cards
Setup Flask-WTF
pip install flask-wtf email-validator

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField
from wtforms.validators import DataRequired, Email

class LoginForm(FlaskForm):
    email = StringField("Email", validators=[
        DataRequired(message="Email is required"),
        Email(message="Invalid email")
    ])
    password = PasswordField("Password", validators=[
        DataRequired()
    ])
    remember = BooleanField("Remember me")

# Fields: StringField, TextAreaField,
# SelectField, IntegerField, DateField,
# FileField, BooleanField, RadioField

FlaskForm is the base class (includes CSRF). wtforms fields with validators. DataRequired() = required. Email() validates the format (requires email-validator). Each field has .data (value) and .errors (list of errors).

CSRF Protection
# Flask-WTF enables CSRF automatically!
# Requires SECRET_KEY in the config.

# In the template (mandatory in every form):
{{ form.hidden_tag() }}
<!-- or manually: -->
<input type="hidden" name="csrf_token"
       value="{{ csrf_token() }}">

# For AJAX:
# <meta name="csrf-token" content="{{ csrf_token() }}">
# fetch(url, {
#   headers: {"X-CSRFToken": token}
# })

# Exclude a specific view:
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect(app)

@app.route("/webhook", methods=["POST"])
@csrf.exempt
def webhook(): ...

CSRFProtect protects every POST. {{ form.hidden_tag() }} includes the token. For AJAX: send X-CSRFToken in the header. @csrf.exempt excludes routes (webhooks, APIs). No token → 400 error. Never disable it globally.

AJAX with Forms
# View that returns JSON:
@app.route("/api/validate", methods=["POST"])
def validate():
    form = RegisterForm()
    if form.validate():
        return {"valid": True}
    return {"valid": False, "errors": form.errors}, 400

# JavaScript:
# const token = document.querySelector(
#     "[name=csrf-token]").content;
#
# fetch("/api/validate", {
#     method: "POST",
#     headers: {
#         "Content-Type": "application/json",
#         "X-CSRFToken": token
#     },
#     body: JSON.stringify({
#         email: "ana@mail.com",
#         csrf_token: token
#     })
# })

form.errors is a dict with errors per field — serializable to JSON. Send X-CSRFToken in the header for AJAX. form.validate() without on_submit for APIs. Return 400 with the errors for the frontend to handle.

Validators
from wtforms.validators import (
    DataRequired, Length, Email,
    EqualTo, NumberRange, Regexp,
    Optional, URL
)

class RegisterForm(FlaskForm):
    name = StringField(validators=[
        Length(min=2, max=50,
               message="2 to 50 characters")
    ])
    email = StringField(validators=[
        DataRequired(), Email()
    ])
    password = PasswordField(validators=[
        Length(min=8, message="Minimum 8 characters"),
        Regexp(r"\d", message="Requires a number")
    ])
    confirm = PasswordField(validators=[
        EqualTo("password", message="Passwords do not match")
    ])
    age = IntegerField(validators=[
        Optional(), NumberRange(min=18, max=120)
    ])

Length(min, max) limits size. EqualTo("field") compares (password confirmation). Regexp() validates with regex. NumberRange() for numbers. Optional() allows empty. message= customizes the error. Validators run in order.

Custom Validator
from wtforms.validators import ValidationError

# Validator function:
def unique_email(form, field):
    user = User.query.filter_by(
        email=field.data.lower()
    ).first()
    if user:
        raise ValidationError("Email already registered.")

# Use it in the form:
class RegisterForm(FlaskForm):
    email = StringField(validators=[
        DataRequired(), Email(), unique_email
    ])

# validate_<field> method (automatic):
class RegisterForm(FlaskForm):
    username = StringField()

    def validate_username(self, field):
        if len(field.data) < 3:
            raise ValidationError("Minimum 3 characters.")
        if " " in field.data:
            raise ValidationError("No spaces.")

Custom validator: a function that receives (form, field) and raises ValidationError. The validate_<field> method is called automatically. Both add to field.errors. Ideal for validations depending on the DB (uniqueness).

Process in the View
@app.route("/register", methods=["GET", "POST"])
def register():
    form = RegisterForm()

    if form.validate_on_submit():
        # Valid data:
        user = User(
            name=form.name.data,
            email=form.email.data
        )
        user.set_password(form.password.data)
        db.session.add(user)
        db.session.commit()

        flash("Account created!", "success")
        return redirect(url_for("login"))

    # GET or validation failed:
    return render_template("register.html", form=form)

# validate_on_submit() = POST + valid

form.validate_on_submit() checks it is POST AND data is valid. form.field.data accesses the clean value. If invalid, it re-renders with errors. Pattern: GET shows the form, POST processes. flash() + redirect() after success (PRG pattern).

File Upload with Form
from flask_wtf.file import FileField, FileAllowed, FileRequired

class UploadForm(FlaskForm):
    photo = FileField("Profile photo", validators=[
        FileRequired(message="Select a file"),
        FileAllowed(["jpg", "png", "webp"],
                    message="Images only!")
    ])

# In the view:
@app.route("/upload", methods=["POST"])
def upload():
    form = UploadForm()
    if form.validate_on_submit():
        f = form.photo.data
        name = secure_filename(f.filename)
        f.save(f"static/uploads/{name}")
        flash("Upload done!")
        return redirect(url_for("profile"))
    return render_template("upload.html", form=form)

# HTML: <form enctype="multipart/form-data">

FileField for uploads. FileAllowed(["ext"]) validates the extension. FileRequired() makes it mandatory. form.field.data is the file object. The form needs enctype="multipart/form-data". Combine with secure_filename().

Render in the Template
<!-- register.html -->
<form method="POST" novalidate>
    {{ form.hidden_tag() }}

    <div class="field">
        {{ form.name.label }}
        {{ form.name(class="input", placeholder="Name") }}
        {% for error in form.name.errors %}
            <span class="error">{{ error }}</span>
        {% endfor %}
    </div>

    <div class="field">
        {{ form.email.label }}
        {{ form.email(class="input") }}
        {% for error in form.email.errors %}
            <span class="error">{{ error }}</span>
        {% endfor %}
    </div>

    {{ form.submit(class="btn") }}
</form>

{{ form.hidden_tag() }} renders the CSRF token (mandatory!). {{ form.field() }} generates the HTML input. class= adds CSS classes. form.field.errors lists validation errors. form.field.label generates the <label>.

Form with Initial Data
# Edit an existing record:
@app.route("/post/<int:id>/edit", methods=["GET", "POST"])
def edit(id):
    post = Post.query.get_or_404(id)
    form = PostForm(obj=post)  # pre-fill!

    if form.validate_on_submit():
        form.populate_obj(post)  # update!
        db.session.commit()
        flash("Post updated!")
        return redirect(url_for("post", id=id))

    return render_template("edit.html",
                           form=form, post=post)

# populate_obj copies form → object
# obj=post copies object → form (GET)

# Or manually:
form = PostForm(title=post.title,
                body=post.body)

PostForm(obj=post) fills the form with the object data. form.populate_obj(post) does the inverse (form → object). Ideal for editing: GET shows current data, POST updates. Alternative: pass fields manually in the constructor.

Blueprints


9 cards
Create a Blueprint
# app/auth/routes.py
from flask import Blueprint, render_template

bp = Blueprint(
    "auth",              # unique name
    __name__,            # module
    url_prefix="/auth",  # URL prefix
    template_folder="templates",
    static_folder="static"
)

@bp.route("/login")
def login():
    return render_template("auth/login.html")

@bp.route("/register")
def register():
    return render_template("auth/register.html")

# Final URLs: /auth/login, /auth/register

Blueprint(name, __name__) creates an independent module. url_prefix prefixes every route. template_folder and static_folder are optional. Each blueprint is a mini-app. Ideal to separate auth, blog, admin, API.

Error Handlers per BP
# Blueprint-specific error:
@bp.app_errorhandler(404)
def bp_404(e):
    return render_template("auth/404.html"), 404

# Before request only in this BP:
@bp.before_app_request
def check_auth():
    if request.endpoint and \
       request.endpoint.startswith("admin."):
        if not current_user.is_admin:
            abort(403)

# BP context processor:
@bp.context_processor
def inject_auth():
    return {"auth_version": "2.0"}

@bp.app_errorhandler() registers global handlers. @bp.before_app_request runs before every request (not only the BP ones). @bp.context_processor injects variables into templates. Useful for module-specific middleware.

Test Blueprints
import pytest
from app import create_app

@pytest.fixture
def app():
    app = create_app("testing")
    app.config["TESTING"] = True
    return app

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

# Test the auth BP:
def test_login_page(client):
    r = client.get("/auth/login")
    assert r.status_code == 200
    assert b"Login" in r.data

def test_login_post(client):
    r = client.post("/auth/login", data={
        "email": "ana@mail.com",
        "password": "123456"
    }, follow_redirects=True)
    assert b"Dashboard" in r.data

Test BPs like normal routes with test_client(). create_app("testing") uses the test config. follow_redirects=True follows redirects. r.data is bytes — use b"text". Pytest fixtures for setup. One test file per BP.

Register Blueprints
# app/__init__.py
from flask import Flask

def create_app():
    app = Flask(__name__)

    from .auth.routes import bp as auth_bp
    from .blog.routes import bp as blog_bp
    from .api.routes import bp as api_bp

    app.register_blueprint(auth_bp)
    app.register_blueprint(blog_bp)
    app.register_blueprint(api_bp, url_prefix="/api/v1")

    return app

# url_prefix at registration overrides
# the prefix defined in the blueprint

app.register_blueprint(bp) adds it to the app. Importing inside the factory avoids circular imports. url_prefix at registration overrides the blueprint one. Registration order does not matter. Each BP can have its own prefix.

API Versioning
# api/v1/routes.py
bp_v1 = Blueprint("api_v1", __name__)

@bp_v1.route("/users")
def users():
    return jsonify({"version": 1, "users": []})

# api/v2/routes.py
bp_v2 = Blueprint("api_v2", __name__)

@bp_v2.route("/users")
def users():
    return jsonify({"version": 2, "data": []})

# Register:
app.register_blueprint(bp_v1, url_prefix="/api/v1")
app.register_blueprint(bp_v2, url_prefix="/api/v2")

# /api/v1/users → version 1
# /api/v2/users → version 2

Version with blueprints: one BP per version. url_prefix="/api/v1" separates them. Old clients keep working. New version = new BP without breaking the existing one. Alternative: Accept-Version header. Standard for public APIs.

Modular Structure
app/
├── __init__.py          # create_app()
├── extensions.py        # db, migrate, login
├── auth/
│   ├── __init__.py
│   ├── routes.py        # auth bp
│   ├── forms.py         # LoginForm
│   └── templates/auth/
├── blog/
│   ├── __init__.py
│   ├── routes.py        # blog bp
│   ├── models.py        # Post, Comment
│   └── templates/blog/
├── api/
│   ├── __init__.py
│   └── routes.py        # api bp
└── templates/           # global base.html

Each feature in its own package. extensions.py centralizes db, migrate, login (avoids circular imports). BP templates in templates/bp_name/. One models.py per module. Scalable for big teams.

Templates per Blueprint
# Blueprint with its own templates:
bp = Blueprint("blog", __name__,
    template_folder="templates",
    static_folder="static",
    static_url_path="/blog/static"
)

# Structure:
# app/blog/
#   templates/blog/
#     index.html
#     post.html
#   static/
#     blog.css

# In the BP route:
@bp.route("/")
def index():
    # Looks in blog/templates/blog/index.html
    return render_template("blog/index.html")

# Inherit from the global base:
# {% extends "base.html" %}

template_folder sets the BP templates folder. Using a subfolder with the BP name avoids conflicts. static_folder + static_url_path for its own assets. BP templates can inherit from the global base.html. Prefix names for clarity.

url_for with Blueprints
# Format: "bp_name.function"
url_for("auth.login")           # /auth/login
url_for("blog.post", id=5)      # /blog/post/5
url_for("api.users")            # /api/v1/users

# In the template:
# <a href="{{ url_for('auth.login') }}">Login</a>
# <a href="{{ url_for('blog.post', id=p.id) }}">

# Redirect between BPs:
return redirect(url_for("auth.login"))

# Check the current endpoint:
request.endpoint  # "auth.login"
request.blueprint # "auth"

url_for("bp.function") references blueprint routes. The prefix is the BP name (1st arg of Blueprint). request.endpoint shows the current endpoint. request.blueprint shows the BP. Always use url_for — never hardcode URLs.

Nested Blueprints
# Flask 2.0+ supports nested BPs:
parent = Blueprint("admin", __name__,
                   url_prefix="/admin")
child = Blueprint("users", __name__,
                  url_prefix="/users")

@child.route("/")
def list_users():
    return "List of users"

# Nest:
parent.register_blueprint(child)
app.register_blueprint(parent)

# Final URL: /admin/users/

# Useful for:
# /admin/users/
# /admin/posts/
# /admin/settings/
# Each sub-module is a child BP

Flask 2.0+ allows parent.register_blueprint(child). Prefixes accumulate: /admin + /users = /admin/users. Ideal for admin panels with sub-modules. Each level is independent and testable. Avoid more than 2 nesting levels.

Extensions and Deploy


9 cards
Flask-Login (Authentication)
pip install flask-login

from flask_login import (
    LoginManager, UserMixin,
    login_user, logout_user,
    login_required, current_user
)

login = LoginManager(app)
login.login_view = "auth.login"  # redirect

class User(UserMixin, db.Model):
    # UserMixin adds: is_authenticated,
    # is_active, is_anonymous, get_id()
    pass

@login.user_loader
def load_user(id):
    return db.session.get(User, int(id))

# Protect a route:
@app.route("/dashboard")
@login_required
def dashboard():
    return f"Hello {current_user.name}"

flask-login manages user sessions. UserMixin adds the required methods. @login_required protects routes. current_user is the current user. login_user() / logout_user() manage the session. login_view sets the redirect if not authenticated.

Deploy with Gunicorn
pip install gunicorn

# Run:
gunicorn -w 4 -b 0.0.0.0:8000 "app:create_app()"

# Options:
gunicorn \
    --workers 4 \
    --bind 0.0.0.0:8000 \
    --timeout 120 \
    --access-logfile - \
    --error-logfile - \
    "app:create_app()"

# Workers = (2 × CPU colors) + 1

# Nginx the reverse proxy:
# proxy_pass http://127.0.0.1:8000;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;

# systemd to manage the process

gunicorn is the production WSGI server. -w 4 = 4 workers (processes). "app:create_app()" calls the factory. Never use flask run in production. Nginx in front the reverse proxy. systemd for auto-restart.

Security and Best Practices
# 1. Security headers:
from flask_talisman import Talisman
Talisman(app, force_https=True)

# 2. Rate limiting:
from flask_limiter import Limiter
limiter = Limiter(app, key_func=get_remote_address)

@app.route("/api/login")
@limiter.limit("5/minute")
def login(): ...

# 3. Never debug in production:
app.config["DEBUG"] = False

# 4. Validate ALL input:
# request.json can be None!
data = request.get_json(silent=True) or {}

# 5. Use parameters in SQL:
db.session.execute(
    text("SELECT * FROM users WHERE id = :id"),
    {"id": user_id}
)

# 6. HTTPS mandatory in production

flask-talisman forces HTTPS and secure headers. flask-limiter limits requests (anti brute-force). Never DEBUG=True in production. Validate input (get_json(silent=True)). Parameters in SQL (anti injection). HTTPS mandatory. Strong SECRET_KEY in env vars.

Flask-RESTful (API)
pip install flask-restful

from flask_restful import Resource, Api, reqparse

api = Api(app)

class UserResource(Resource):
    def get(self, user_id):
        user = User.query.get_or_404(user_id)
        return {"id": user.id, "name": user.name}

    def put(self, user_id):
        parser = reqparse.RequestParser()
        parser.add_argument("name", required=True)
        args = parser.parse_args()
        user.name = args["name"]
        db.session.commit()
        return {"ok": True}

    def delete(self, user_id):
        return "", 204

api.add_resource(UserResource, "/api/user/<int:user_id>")

Resource defines endpoints with HTTP methods. Api(app) registers it. reqparse validates arguments. add_resource() maps the URL. Returning a dict = automatic JSON. Modern alternative: flask-smorest or flask-restx with OpenAPI.

Docker
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", \
     "app:create_app()"]

# docker-compose.yml
# services:
#   web:
#     build: .
#     ports: ["8000:8000"]
#     environment:
#       - SECRET_KEY=super
#       - DATABASE_URL=postgresql://...
#   db:
#     image: postgres:16
#     volumes: [pgdata:/var/lib/postgresql/data]

# docker compose up --build

python:3.12-slim the base (lightweight). Install dependencies before COPY (Docker cache). gunicorn the CMD. docker-compose orchestrates web + DB. Environment variables for config. --build rebuilds the image.

Context Processors
# Global variables in every template:
@app.context_processor
def inject_globals():
    return {
        "app_name": "My App",
        "current_year": datetime.now().year,
        "menu_items": ["Home", "Blog", "Contact"]
    }

# In any template:
# {{ app_name }} - {{ current_year }}

# With a blueprint:
@bp.context_processor
def inject_bp():
    return {"bp_version": "1.0"}

# Utility functions:
@app.context_processor
def inject_utils():
    return {"format_date": lambda d: d.strftime("%d/%m/%Y")}

@app.context_processor injects variables into every template. Return a dict. It runs on each request (keep it light). Ideal for: app name, year, menu, current user. Alternative: app.jinja_env.globals for functions.

Logging
import logging
from logging.handlers import RotatingFileHandler

def setup_logging(app):
    handler = RotatingFileHandler(
        "logs/app.log",
        maxBytes=5_000_000,  # 5MB
        backupCount=5
    )
    handler.setFormatter(logging.Formatter(
        "%(asctime)s %(levelname)s: %(message)s"
    ))
    handler.setLevel(logging.INFO)

    app.logger.addHandler(handler)
    app.logger.setLevel(logging.INFO)

# Use:
app.logger.info("Server started")
app.logger.warning("Cache almost full")
app.logger.error(f"Error: {e}")

# In production: log to stdout (Docker)

app.logger is the Flask logger. RotatingFileHandler limits log size. logging.Formatter sets the format. Levels: DEBUG, INFO, WARNING, ERROR. In Docker: log to stdout. Never log sensitive data.

Testing with pytest
# conftest.py
import pytest
from app import create_app, db

@pytest.fixture
def app():
    app = create_app("testing")
    with app.app_context():
        db.create_all()
        yield app
        db.drop_all()

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

# test_routes.py
def test_home(client):
    r = client.get("/")
    assert r.status_code == 200

def test_create_post(client, auth_headers):
    r = client.post("/posts", json={
        "title": "Test"
    }, headers=auth_headers)
    assert r.status_code == 201

# Run: pytest -v

conftest.py defines shared fixtures. create_app("testing") uses a test DB. db.create_all() / drop_all() per test. test_client() simulates HTTP requests. pytest -v runs them. Testing config: SQLALCHEMY_DATABASE_URI = "sqlite://" (in-memory).

Flask-Mail
pip install flask-mail

from flask_mail import Mail, Message

mail = Mail(app)

app.config["MAIL_SERVER"] = "smtp.gmail.com"
app.config["MAIL_PORT"] = 587
app.config["MAIL_USE_TLS"] = True
app.config["MAIL_USERNAME"] = "app@gmail.com"
app.config["MAIL_PASSWORD"] = "app-password"

def send_email(recipient, subject, body):
    msg = Message(
        subject=subject,
        recipients=[recipient],
        body=body,
        sender="noreply@app.com"
    )
    mail.send(msg)

# HTML:
msg.html = "<h1>Hello!</h1>"

flask-mail sends emails via SMTP. Configure server, port, credentials. Message() creates the email. mail.send() sends it (synchronous). msg.html for HTML content. In production: use a queue (Celery) to avoid blocking. Gmail requires an app password.