Contents

Flask Best Practices

Contents

Flask 3.1.x remains one of the best choices for teams that want Python’s ergonomics without surrendering architectural control. Its minimal core is a strength, not a shortcut: you choose the boundaries, dependencies, and operational model instead of inheriting a large framework’s opinions.

That flexibility also means poor habits scale badly. A Flask codebase can stay small, readable, and fast for years—or gradually turn into a pile of global state, implicit coupling, and fragile request handlers.

This guide focuses on production-grade Flask 3.1.x practices for teams building maintainable services and web applications.


Core Principles

Before discussing code structure, establish a few engineering rules.

Keep Flask at the edge

Flask should orchestrate HTTP concerns:

  • routing
  • request parsing
  • response formatting
  • authentication integration
  • error translation

It should not own business rules, persistence logic, or cross-service workflows. Treat Flask as the delivery layer, not the application itself.

A healthy structure looks like this:

  • routes / controllers: HTTP input-output
  • services / use cases: business logic
  • repositories / data access: database operations
  • domain models / policies: core rules and invariants

Prefer explicitness over convenience

Flask makes it easy to start quickly with globals, decorators, and implicit context. In production, prefer:

  • explicit application initialization
  • explicit extension registration
  • explicit dependency boundaries
  • explicit config loading
  • explicit error contracts

If a teammate cannot trace how a component is initialized in a few minutes, the app is already becoming too magical.

Optimize for change, not just first release

A “working” Flask app is not necessarily a maintainable one. Good Flask architecture should make it easy to:

  • add a new module without touching unrelated code
  • test services without booting the whole stack
  • swap implementations via config
  • evolve API versions safely
  • run background and scheduled work outside request handlers

Installation and Versioning

For Flask 3.1.x, treat runtime and dependency management as part of application correctness.

Use supported Python versions intentionally

Flask 3.1.x supports modern Python versions. In practice, teams should standardize on one production runtime and one local development baseline, such as Python 3.12 or 3.13, instead of letting every developer choose independently.

Use virtual environments everywhere

Never rely on the system interpreter for project dependencies.

python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip

Pin direct dependencies

Avoid ambiguous installs like this:

pip install flask

Prefer constrained or locked dependencies:

pip install "Flask>=3.1,<3.2"

For application projects, maintain a lock strategy using one of these approaches:

  • pip-tools
  • Poetry
  • PDM
  • uv

A practical requirements.in example:

Flask>=3.1,<3.2
SQLAlchemy>=2.0,<2.1
Flask-SQLAlchemy>=3.1,<3.2
alembic>=1.13,<2
psycopg[binary]>=3.2,<3.3
gunicorn>=23,<24
pytest>=8.0,<9

Then compile reproducible locks for deployment.

Separate runtime and development dependencies

Keep production images and deployment environments lean. Common split:

  • runtime: Flask, DB driver, ORM, WSGI server
  • development: pytest, ruff, mypy, factories, debugging tools

Upgrade deliberately

For Flask upgrades:

  1. read the official changelog
  2. upgrade in a branch
  3. run tests and smoke checks
  4. validate extensions for compatibility
  5. deploy gradually

Flask itself is small; ecosystem compatibility is often the real risk.


App Factory

Use the application factory pattern by default.

Why the factory matters

A factory gives you:

  • multiple config profiles
  • easier testing
  • extension reuse without global app binding
  • cleaner CLI and worker startup
  • fewer import cycles
myapp/
├── app/
│   ├── __init__.py
│   ├── config.py
│   ├── extensions.py
│   ├── api/
│   │   ├── __init__.py
│   │   ├── errors.py
│   │   └── users.py
│   ├── web/
│   │   ├── __init__.py
│   │   └── views.py
│   ├── domain/
│   ├── services/
│   ├── repositories/
│   └── models/
├── migrations/
├── tests/
└── wsgi.py

Initialize extensions unbound

# app/extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect

db = SQLAlchemy()
login_manager = LoginManager()
csrf = CSRFProtect()

Create the app in one place

# app/__init__.py
from flask import Flask

from app.config import get_config
from app.extensions import csrf, db, login_manager


def create_app(config_name: str | None = None) -> Flask:
    app = Flask(__name__)
    app.config.from_object(get_config(config_name))

    register_extensions(app)
    register_blueprints(app)
    register_error_handlers(app)
    register_shell_context(app)
    register_cli(app)

    return app


def register_extensions(app: Flask) -> None:
    db.init_app(app)
    login_manager.init_app(app)
    csrf.init_app(app)


def register_blueprints(app: Flask) -> None:
    from app.api import api_bp
    from app.web import web_bp

    app.register_blueprint(api_bp, url_prefix="/api")
    app.register_blueprint(web_bp)


def register_error_handlers(app: Flask) -> None:
    from app.api.errors import register_error_handlers

    register_error_handlers(app)


def register_shell_context(app: Flask) -> None:
    from app.extensions import db

    @app.shell_context_processor
    def shell_context():
        return {"db": db}

Avoid work at import time

Do not:

  • open database connections during module import
  • read secrets during import
  • initialize external clients in module globals
  • start threads from imported files

Imports should define objects, not perform environment-specific side effects.


Configuration

Configuration is where many Flask apps become unsafe.

Use config classes or structured settings

A common pattern:

# app/config.py
import os


class BaseConfig:
    SECRET_KEY = os.environ["SECRET_KEY"]
    SQLALCHEMY_TRACK_MODIFICATIONS = False

    SESSION_COOKIE_HTTPONLY = True
    SESSION_COOKIE_SECURE = True
    SESSION_COOKIE_SAMESITE = "Lax"

    PERMANENT_SESSION_LIFETIME = 3600


class DevelopmentConfig(BaseConfig):
    DEBUG = True
    SESSION_COOKIE_SECURE = False


class TestingConfig(BaseConfig):
    TESTING = True
    WTF_CSRF_ENABLED = False
    SQLALCHEMY_DATABASE_URI = "sqlite+pysqlite:///:memory:"


class ProductionConfig(BaseConfig):
    DEBUG = False


def get_config(name: str | None):
    mapping = {
        "development": DevelopmentConfig,
        "testing": TestingConfig,
        "production": ProductionConfig,
    }
    return mapping.get(name or os.getenv("FLASK_ENV", "production"), ProductionConfig)

Fail fast on missing secrets

If a required secret is absent, let startup fail. Silent fallback values like "dev-secret" are acceptable only in explicitly local profiles.

Keep environment-specific values outside code

Examples:

  • database URLs
  • API keys
  • Redis endpoints
  • feature flags
  • email credentials

Code should describe what is needed; the environment should provide which values.

Avoid sprawling config access

Instead of sprinkling current_app.config[...] across business logic, read config near application boundaries and pass derived values downward. This reduces hidden coupling to Flask context.


Blueprints

Blueprints are essential once the app has multiple bounded areas.

Organize by domain, not by HTTP method

Prefer:

  • billing
  • users
  • admin
  • auth

Over structures like:

  • get_routes.py
  • post_routes.py

Domain-oriented blueprints age better.

Use separate blueprints for different surfaces

In many systems, it is helpful to split:

  • public web blueprint
  • admin blueprint
  • API blueprint
  • health/internal blueprint

That separation makes middleware, error formatting, and auth rules easier to reason about.

from flask import Blueprint

api_bp = Blueprint("api", __name__)
admin_bp = Blueprint("admin", __name__)

Keep blueprint modules thin

A route should typically do four things:

  1. authenticate and authorize
  2. validate input
  3. call a service
  4. return a response

If a route contains pagination logic, validation rules, SQL, permission branching, and serialization details all in one function, it is too large.


Request Validation

One of the fastest ways to create operational pain is to accept weakly validated input.

Validate at the boundary

Validate all external input:

  • path params
  • query params
  • headers
  • form data
  • JSON bodies
  • uploaded files

Flask gives you access to the request; it does not give you a full validation system by default. That is your responsibility.

Use schema-based validation

For APIs, use a dedicated validation layer such as:

  • Marshmallow
  • Pydantic
  • WTForms for HTML forms only

Avoid hand-written ad hoc parsing in every route.

Example with Pydantic-style validation:

from pydantic import BaseModel, EmailStr, Field


class CreateUserRequest(BaseModel):
    email: EmailStr
    full_name: str = Field(min_length=1, max_length=120)

Then keep the route focused:

from flask import request

payload = CreateUserRequest.model_validate(request.get_json() or {})

Normalize validation errors

Clients should receive a predictable error shape, for example:

{
  "error": {
    "code": "validation_error",
    "message": "Invalid request payload",
    "details": {
      "email": ["Invalid email address"]
    }
  }
}

A stable contract improves frontend integration and observability.

Never trust internal callers blindly

Even if one internal service calls another, validate inputs at the receiving boundary. “Internal” bugs cause real incidents too.


Error Handling

Production Flask apps need consistent error behavior, not just stack traces and generic 500 pages.

Define operational error categories

At minimum, distinguish:

  • validation errors → 400
  • authentication failures → 401
  • authorization failures → 403
  • missing resources → 404
  • conflict/idempotency issues → 409
  • unexpected failures → 500

Convert exceptions into a stable response format

from flask import jsonify


class ApiError(Exception):
    status_code = 400
    code = "api_error"

    def __init__(self, message: str, details: dict | None = None):
        self.message = message
        self.details = details or {}


def register_error_handlers(app):
    @app.errorhandler(ApiError)
    def handle_api_error(error: ApiError):
        return jsonify({
            "error": {
                "code": error.code,
                "message": error.message,
                "details": error.details,
            }
        }), error.status_code

    @app.errorhandler(Exception)
    def handle_unexpected_error(error: Exception):
        app.logger.exception("Unhandled exception")
        return jsonify({
            "error": {
                "code": "internal_error",
                "message": "An unexpected error occurred",
            }
        }), 500

Do not leak internals

Never expose:

  • stack traces
  • raw SQL errors
  • driver-level connection messages
  • internal hostnames
  • secret-bearing config values

Log richly; respond minimally.

Roll back database sessions on failure

For session-based ORM usage, failed writes should reliably roll back before the next request touches the same session state.


Database Patterns

Database code is where many Flask apps become tightly coupled and hard to test.

Prefer SQLAlchemy 2.x style

Flask 3.1.x works well with the modern SQLAlchemy 2.x ecosystem. Favor explicit session usage and select() patterns over legacy habits when possible.

from sqlalchemy import select
from app.extensions import db
from app.models import User

stmt = select(User).where(User.email == email)
user = db.session.execute(stmt).scalar_one_or_none()

Keep persistence out of routes

Avoid this inside view functions:

  • constructing large queries
  • performing transaction orchestration
  • mixing read and write side effects
  • embedding domain rules in ORM calls

Instead:

  • route validates input
  • service coordinates behavior
  • repository executes persistence

Be intentional about transaction boundaries

For request-driven writes, one transaction per request is often reasonable. For more complex workflows, define transaction boundaries in the service layer, not implicitly across helper calls.

Use migrations for every schema change

Never “just edit the model” in production and hope environments converge. Use Alembic or Flask-Migrate consistently.

Good migration discipline includes:

  • one migration per meaningful schema change
  • forward and backward review
  • tested upgrades on staging data
  • explicit data backfills when required

Handle N+1 queries early

Watch for:

  • lazy-loading inside loops
  • template-driven relation access
  • hidden serialization queries

Use eager-loading intentionally where appropriate.

Separate read models from write complexity when needed

For larger systems, do not force every endpoint through the same ORM-heavy path. Reporting, exports, and operational dashboards often need specialized read queries.


Authentication Boundaries

Authentication and authorization should be explicit architectural boundaries.

Separate identity from business logic

Flask-Login, session cookies, OAuth, or JWT handling belongs near the delivery layer. Business services should receive a domain-relevant actor context, not depend directly on current_user.

Instead of this deep in service code:

from flask_login import current_user

Prefer passing an actor object or user ID from the route.

Distinguish authentication and authorization

  • authentication: who are you?
  • authorization: are you allowed to do this?

Do not collapse both into a single decorator and forget about object-level permissions.

Centralize permission rules

As the app grows, permission logic should move into policy modules or services, not remain scattered across routes.

Examples:

  • can_view_invoice(actor, invoice)
  • can_edit_project(actor, project)

This improves reuse and testability.

Treat admin interfaces as a separate trust zone

Admin features deserve:

  • stronger auditing
  • stricter rate limits
  • narrower network exposure
  • more explicit permission checks

Do not assume an “internal” admin blueprint is automatically safe.


Async Caveats

Flask 3.x supports async views, but that does not make it a full async-first framework.

Understand what async in Flask actually means

Async route handlers can help when integrating async-compatible libraries, but Flask remains rooted in the WSGI ecosystem unless you deliberately choose an ASGI deployment path and compatible stack.

Do not assume automatic throughput gains

If your app depends on:

  • blocking ORM calls
  • blocking HTTP clients
  • blocking SDKs
  • synchronous filesystem operations

then async def routes may add complexity without material performance gains.

Keep background jobs out of request handlers

Do not use async routes as a substitute for proper background processing. Long-running work such as:

  • sending bulk emails
  • generating reports
  • media processing
  • external synchronization

belongs in a queue or worker system, not inside request-response lifecycles.

Choose the right tool for the concurrency model

If your workload is predominantly async and high-concurrency by design, evaluate whether Flask is still the right foundation versus an ASGI-native framework. Use Flask because it fits the problem, not because it can be stretched.


Testing

Flask applications are easy to test if architecture stays modular.

Test the factory

At minimum, verify the app starts under each config profile and required components register correctly.

Keep most tests below the HTTP layer

The most valuable test distribution is often:

  • many service tests
  • some repository tests
  • fewer route tests
  • a small number of end-to-end tests

If every rule is only testable through HTTP, your architecture is too coupled to Flask.

Use pytest fixtures cleanly

import pytest
from app import create_app
from app.extensions import db


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


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

Test error contracts

Do not test only happy paths. Assert:

  • validation errors return correct shape
  • forbidden actions return 403
  • missing resources return 404
  • unexpected exceptions are sanitized

Use realistic integration tests for persistence

For serious applications, run at least part of the suite against the same database engine class used in production, not only SQLite in memory. SQLite can hide behavior differences around constraints, transactions, and SQL dialect.

Add smoke checks for operational wiring

Useful tests include:

  • health endpoint works
  • migrations apply cleanly
  • CLI commands load app context
  • key blueprints register
  • authentication middleware behaves correctly

Observability

If a Flask app cannot explain what happened in production, it is not production-ready.

Structured logging over ad hoc strings

Prefer logs with stable fields such as:

  • timestamp
  • level
  • request ID
  • route
  • user or actor ID when appropriate
  • status code
  • latency
  • dependency target
  • error code

Correlate logs per request

Assign or propagate a request ID through every inbound request and include it in logs and error responses where appropriate.

Measure the basics

At minimum, collect:

  • request count
  • latency
  • error rate
  • database latency
  • outbound dependency latency
  • worker/process restarts

Capture exceptions centrally

Integrate with an error monitoring system such as Sentry or an equivalent platform. Flask logging alone is rarely sufficient for incident response.

Treat health endpoints seriously

Split health signals when useful:

  • liveness: process is up
  • readiness: app can serve traffic
  • dependency health: critical downstreams are reachable

Avoid “always 200” health endpoints that say nothing meaningful.


Deployment

Good Flask deployment is mostly about operational discipline.

Use a production WSGI server

Do not run the built-in development server in production.

Typical choices:

  • Gunicorn
  • uWSGI

A common Gunicorn pattern:

gunicorn --bind 0.0.0.0:8000 --workers 4 "app:create_app()"

Tune worker count based on:

  • CPU
  • memory
  • blocking I/O characteristics
  • request latency
  • workload profile

Containerize conservatively

A sound Docker image should:

  • pin the Python base image
  • install only required system packages
  • run as a non-root user
  • avoid shipping build caches
  • separate build and runtime stages when useful

Externalize state

Do not store durable state in the Flask container filesystem:

  • uploaded files
  • persistent sessions
  • generated exports
  • user content

Use object storage, databases, or dedicated services.

Make startup deterministic

Your deployment should clearly define:

  • config source
  • migration strategy
  • worker command
  • health checks
  • secret injection method

Plan for graceful shutdown

Workers should stop accepting traffic, finish in-flight requests, and terminate cleanly during rolling deploys.


Security

Security in Flask is mostly about defaults and discipline.

Secret management

Never commit:

  • SECRET_KEY
  • database credentials
  • API tokens
  • signing keys

Use a secret manager or deployment platform secret store.

For cookie-backed sessions:

  • SESSION_COOKIE_SECURE = True
  • SESSION_COOKIE_HTTPONLY = True
  • SESSION_COOKIE_SAMESITE = "Lax" or stricter where appropriate

CSRF protection

If you render HTML forms with authenticated sessions, enable CSRF protection. Do not disable it broadly just because it is inconvenient during development.

Password handling

Never store plaintext passwords. Use a vetted password hashing library such as Werkzeug’s password helpers or Argon2/bcrypt-based tooling.

Input and output safety

Guard against:

  • SQL injection through unsafe query construction
  • XSS through unsafe template rendering
  • open redirects
  • path traversal in file handling
  • unsafe file uploads

Rate limiting and abuse controls

Public endpoints such as login, password reset, signup, and expensive search operations should have rate limits and abuse monitoring.

Security headers

Review headers such as:

  • Content-Security-Policy
  • X-Content-Type-Options
  • Referrer-Policy
  • Strict-Transport-Security

Dependency hygiene

Track dependency advisories and rotate vulnerable packages quickly. In Python apps, dependency sprawl is often a larger attack surface than Flask itself.


Anti-Patterns

These are common failure modes in Flask projects.

Fat route handlers

Routes that validate input, talk to multiple systems, run business rules, manage transactions, and serialize responses all in one function become impossible to reason about.

Global mutable state

Module-level caches, clients, or flags that mutate unpredictably across requests create concurrency and test isolation issues.

Hidden Flask context dependency

If business logic only works when request, g, or current_app exist, it is too tightly coupled to Flask internals.

Catch-all except Exception without intent

Swallowing exceptions and returning “success: false” destroys debuggability and often hides partial failures.

Database access inside templates

Template rendering should not trigger hidden queries. That leads to latency surprises and N+1 problems.

Mixing HTML app and JSON API concerns everywhere

Web pages and APIs often need different auth, CSRF, caching, and error semantics. Separate them intentionally.

Relying on debug mode habits

Code that only works because debug reloaders, implicit local configs, or permissive cookies are enabled will fail in production at the worst time.


Team Checklist

Use this as a practical review list for Flask 3.1.x services.

Architecture

  • App uses an application factory.
  • Extensions are initialized in a dedicated module.
  • Routes remain thin.
  • Business logic lives outside Flask handlers.
  • Blueprint structure follows bounded domains.

Configuration

  • Secrets come from the environment or a secret manager.
  • Production and test config are explicit.
  • Startup fails fast on missing required config.
  • Dependency versions are pinned or locked.

Data and APIs

  • All request input is validated.
  • Error responses follow a stable schema.
  • Migrations are required for schema changes.
  • Transaction boundaries are intentional.
  • N+1 query risks are reviewed.

Security

  • Cookies are hardened for production.
  • CSRF is enabled where session-authenticated forms exist.
  • Passwords use secure hashing.
  • Sensitive endpoints are rate-limited.
  • Admin capabilities are isolated and audited.

Testing and operations

  • Factory, routes, services, and data access are tested.
  • Logs are structured and correlated by request ID.
  • Exceptions are reported to centralized monitoring.
  • Health checks are meaningful.
  • Production runs on a real WSGI server.