Flask Best Practices
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 pipPin direct dependencies
Avoid ambiguous installs like this:
pip install flaskPrefer 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,<9Then 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:
- read the official changelog
- upgrade in a branch
- run tests and smoke checks
- validate extensions for compatibility
- 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
Recommended layout
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.pyInitialize 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:
billingusersadminauth
Over structures like:
get_routes.pypost_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:
- authenticate and authorize
- validate input
- call a service
- 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",
}
}), 500Do 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_userPrefer 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.
Session and cookie hardening
For cookie-backed sessions:
SESSION_COOKIE_SECURE = TrueSESSION_COOKIE_HTTPONLY = TrueSESSION_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.
Related Resources
- Flask Documentation
- Flask 3.1.x Documentation
- Flask Deployment Options
- Flask Blueprints
- Flask Error Handling
- SQLAlchemy Documentation
- Alembic Documentation
- Werkzeug Security Helpers