Introduction: Flask is not "small" but "the core only"
Flask tends to be introduced as a "small framework," but that understanding is dangerous in production operations. Precisely, Flask is a framework with the design philosophy that it provides only the core of a WSGI application (routing, request/response, templates, configuration, context) and bundles neither an ORM, nor forms, nor authentication. When the official calls it "micro," it doesn't mean "poor in features" — it means "you decide what to load."
This philosophy is double-edged. Assemble a configuration minimally fitted to the requirements, and you get a backend thinner, faster, and more readable than FastAPI or Django. On the other hand, precisely because there's only a core, if you don't design the structure (application factory, Blueprints, configuration, context, deployment) yourself, a small app slides straight into "a legacy dominated by global variables and import order." Most Flask-project failures come not from Flask's lack of features but from this lack of structural design.
This article is the pillar (overall map) for assembling that structure at production quality, faithful to the official documentation of the Flask 3.1 series (the current stable version). The author designed and implemented the backend of a B2B SaaS that won the METI Minister's Award in Python / Flask / SQLAlchemy / PostgreSQL, and operated it in production on API Gateway → ALB → ECS (Fargate). What I show here is only the design that was necessary in that real combat.
💡 The version covered in this article: I assume the Flask 3.1 series (the latest stable version at the time of writing is 3.1.3, released 2026-02). Flask 3.1 requires Python 3.9 or higher and has, as dependencies, Werkzeug (the WSGI/HTTP layer), Jinja (templates), MarkupSafe, ItsDangerous (signing), Click (CLI), and Blinker (signals). The code in this article is based on the official documentation's patterns. The specifics link from this pillar to individual spoke articles.
1. The overall picture: the "7 design targets" of a Flask app
In a production Flask app, what actually requires a design decision is just the following 7. This pillar surveys these 7 and hands the deep dive of each to a dedicated article.
| # | Design target | Central API / concept | Deep-dive article |
|---|---|---|---|
| 1 | App structure | create_app / Blueprint / init_app | Large-scale structure guide |
| 2 | Configuration management | config.from_* / instance folder | This article §4 |
| 3 | Context | current_app / g / request / session | Context thorough explanation |
| 4 | Error handling & logs | errorhandler / dictConfig | Error handling & observability guide |
| 5 | Security | SECRET_KEY / Cookie / CSRF / escaping | Security implementation guide |
| 6 | Deployment | Gunicorn / ProxyFix / Docker | Production deployment guide |
| 7 | Testing | test_client / pytest fixtures | Testing practical guide |
And the upstream decision of whether to adopt Flask (comparison with FastAPI / Django) is summarized in the technology-selection guide.
2. Minimal Flask: first grasp that it's a "WSGI app"
Before getting into the design talk, let me confirm what Flask is in 1 file. It's the minimal form of the official quickstart.
# hello.py
from flask import Flask
app = Flask(__name__)
@app.route("/")
def index():
return "Hello, World!"
Start it with the development server.
flask --app hello run --debug
What you should grasp here is 2 points.
appis a WSGI application. To borrow the official words, "Flask is a WSGI application, and a WSGI server runs it." That is,flask run's development server and production's Gunicorn are "servers", not Flask itself. This separation is the starting point of the deployment design described later.--debugis development-only. The official clearly warns "you must not deploy the development server to production. It's neither safe, nor stable, nor efficient." Production startup is the Gunicorn handled in §8.
⚠️ Anti-pattern: making
if __name__ == "__main__": app.run()the entry point and starting that as-is in production.app.run()is the development server, and in production you have a WSGI server like Gunicorn load theappobject. Always placeapp.run()in theif __name__ == "__main__":block and isolate it from the production path.
This minimal form is good for learning, but the point that a global app is placed at the module top becomes technical debt as-is in production. The next section resolves it.
3. The application factory: the backbone of production Flask
3.1 Why discard the global app
Write app = Flask(__name__) at the module top, and 3 problems arise structurally.
- You can't swap the configuration in tests:
appis fixed at import time, so there's no gap to switch to a test DB or test configuration. - You can't have multiple configurations: you can't create apps with different configurations for production, staging, and test at the same time.
- It induces circular imports: views and models import
app, and theappside imports them too, so the dependency circulates.
The solution is the application factory. Assemble and return app inside a function. This lets the caller control "when, with which configuration, loading which parts" the app is created. This is the canonical form of the official tutorial.
# src/myapp/__init__.py
import os
from flask import Flask
def create_app(test_config: dict | None = None) -> Flask:
# instance_relative_config=True で、設定をリポジトリ外の instance/ から読む
app = Flask(__name__, instance_relative_config=True)
app.config.from_mapping(
SECRET_KEY="dev", # 本番では必ず上書き(§5・§7)
DATABASE=os.path.join(app.instance_path, "myapp.sqlite"),
)
if test_config is None:
# 本番/開発:instance/config.py があれば上書き(無ければ黙ってスキップ)
app.config.from_pyfile("config.py", silent=True)
else:
# テスト:呼び出し側が渡した設定で上書き
app.config.from_mapping(test_config)
# instance フォルダは自動生成されない。明示的に作る
os.makedirs(app.instance_path, exist_ok=True)
@app.route("/health")
def health():
return {"status": "ok"}
return app
For startup too, the flask CLI auto-detects the factory.
flask --app myapp run --debug
💡 Factory auto-detection:
flask --app, if the target module has a function namedcreate_appormake_app, automatically calls it as the factory. When you want to pass arguments, you can write the parentheses' content as a Python literal, likeflask --app 'myapp:create_app("dev")' run. In production's Gunicorn too, you can use the same formatgunicorn 'myapp:create_app()'(§8).
3.2 Extensions are "unbound → bound with init_app"
Extensions like Flask-SQLAlchemy are also made consistent with the factory. Generate the extension object at the module top without app, and call init_app(app) inside the factory to bind it. This way, one extension object can be reused across multiple apps (production, test), and circular imports are avoided too.
# src/myapp/extensions.py — どのアプリにも束縛されていない「裸」の拡張
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
db = SQLAlchemy()
migrate = Migrate()
# src/myapp/__init__.py(抜粋)
from .extensions import db, migrate
def create_app(test_config=None):
app = Flask(__name__, instance_relative_config=True)
# ...設定読み込み...
db.init_app(app) # ここで初めて app に束縛
migrate.init_app(app, db)
from .blueprints.api import bp as api_bp
app.register_blueprint(api_bp, url_prefix="/api")
return app
The deep dive on this pattern, the package structure that avoids circular imports, and Blueprint nesting and CLI commands is split into the large-scale structure guide.
4. Configuration management: drive secrets out of code
app.config is a dict subclass that "treats only uppercase keys as meaningful configuration." In production, don't write secrets (SECRET_KEY, DB password, API keys) in code — this is the core of 12-factor and the minimum line of security. Flask provides multiple loading entrances.
| Method | Use | Example |
|---|---|---|
from_mapping(**kw) | In-code default values | app.config.from_mapping(SECRET_KEY="dev") |
from_object(obj) | Per-environment Config class | app.config.from_object("myapp.config.Production") |
from_pyfile(path, silent=True) | A Python config in the instance folder | app.config.from_pyfile("config.py", silent=True) |
from_prefixed_env() | Environment variables (FLASK_ prefix) | app.config.from_prefixed_env() |
from_file(path, load=...) | JSON / TOML config | app.config.from_file("config.toml", load=tomllib.load, text=False) |
4.1 Recommended: from_object (defaults) → from_prefixed_env (environment override)
The basic form I use in production is the two-stage "give safe default values with a Config class, override with production values via environment variables." from_prefixed_env() is a method added in Flask 3.0 that's optimal for the container/12-factor era, auto-loading environment variables starting with FLASK_ and typing the values with json.loads.
# src/myapp/config.py
class BaseConfig:
JSON_SORT_KEYS = False
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
class ProductionConfig(BaseConfig):
SESSION_COOKIE_SECURE = True # HTTPS 限定 Cookie(§7)
class TestConfig(BaseConfig):
TESTING = True
# create_app 内
app.config.from_object("myapp.config.ProductionConfig")
app.config.from_prefixed_env() # FLASK_SECRET_KEY, FLASK_SQLALCHEMY_DATABASE_URI ...
# 本番の環境変数(コンテナの Secrets から注入)
export FLASK_SECRET_KEY='...' # python -c 'import secrets; print(secrets.token_hex())'
export FLASK_SQLALCHEMY_DATABASE_URI='postgresql+psycopg://...'
export FLASK_MAX_CONTENT_LENGTH=10485760 # json.loads されて int になる
💡 The power of
from_prefixed_env: because the value is interpreted withjson.loads,FLASK_MAX_CONTENT_LENGTH=10485760is loaded not as a string but asint 10485760. You can also assign to nested keys with__(double underscore). The typical worry of "environment variables are all strings and hard to handle" is resolved by the Flask side.
4.2 The instance folder: a configuration storage outside the repository
Specify Flask(__name__, instance_relative_config=True), and app.instance_path (by default <project>/instance/) becomes "a storage for configuration, SQLite, and uploads not committed to the repository." from_pyfile("config.py") is resolved relative to this instance/. Be sure to put instance/ in .gitignore.
⚠️ Don't write
DEBUGin a config file. The official cautions "enablingDEBUGin code or configuration may not work as expected." Enable debug only during development, with theFLASK_DEBUG=1environment variable orflask run --debug.DEBUG=Truein production is a serious vulnerability that exposes the interactive debugger.
4.3 The "production-effective" settings added in Flask 3.1
In the Flask 3.1 series, settings directly tied to production hardening have been added. The details are handled in each spoke, but let me list them as the pillar.
| Setting key | Default | Added | Effect |
|---|---|---|---|
SECRET_KEY_FALLBACKS | None | 3.1 | Key rotation (continue signature verification with the old key) |
SESSION_COOKIE_PARTITIONED | False | 3.1 | CHIPS (partitioned Cookie). Enabling it also forces SECURE |
MAX_FORM_MEMORY_SIZE | 500_000 | 3.1 | The size cap for non-file form values (DoS mitigation) |
MAX_FORM_PARTS | 1_000 | 3.1 | The cap on the number of form parts (DoS mitigation) |
TRUSTED_HOSTS | None | 3.1 | Verify the Host header at routing time (Host-header attack countermeasure) |
5. Context: "don't drag it around" with current_app and g
Create the app with a factory, and a situation arises where "you can't import app from anywhere" (because it doesn't exist at import time). Flask solves this with context locals.
current_app: a proxy to the app currently being processed. Reaches the configuration and extensions without importingapp.g: a namespace that lives only during the current context (≒ 1 request). Used for caching a DB connection, etc.request/session: the current request/session.
The typical pattern the official shows is "during a request, reuse the same DB connection, and close it at the end."
from flask import g, current_app
def get_db():
if "db" not in g: # リクエスト内で初回だけ接続
g.db = connect_to_database(current_app.config["DATABASE"])
return g.db
@app.teardown_appcontext
def teardown_db(exception): # コンテキスト終了時に必ず閉じる
db = g.pop("db", None)
if db is not None:
db.close()
💡
gis not a "global variable." The official states clearly — "the 'g' ingis for global, but it means global within the context. The data is lost when the context ends, and it can't be used for storage across requests. For storage between requests, usesessionor the DB." Furthermore, as an important fact, Flask's context locals are implemented with Python'scontextvarsand Werkzeug'sLocalProxy, not mere thread locals. This is the reason they function correctly even inasyncviews too.
The mechanism of pushing/popping the context, _get_current_object(), the difference between teardown_appcontext and teardown_request, copy_current_request_context that carries the context out into a background task like an invoice, and the correct handling of RuntimeError: Working outside of application context — these are dug into in the context thorough explanation.
6. Error handling and logging: don't "stay silent" in production
6.1 Error handlers: an API design that returns JSON, not HTML
In a REST API, the standard play is to return exceptions as structured JSON, not an HTML error page. Flask can register a handler per exception class/status code with @app.errorhandler.
from flask import jsonify
from werkzeug.exceptions import HTTPException
@app.errorhandler(HTTPException)
def handle_http_exception(e: HTTPException):
"""すべての HTTP エラーを JSON で返す。"""
return jsonify(code=e.code, name=e.name, description=e.description), e.code or 500
@app.errorhandler(404)
def not_found(e):
return jsonify(error="resource not found"), 404
From a view, raise an error with abort(404, description="..."). The handler resolution order (code → class hierarchy → the most specific), the priority of Blueprint-side handlers and the exception "a Blueprint can't catch 404," and the handling of InternalServerError.original_exception are split into the error handling & observability guide.
6.2 Logging: dictConfig "before app creation"
Flask's logging is the standard logging itself. The official's most important caution is "configure logging before app creation" — because touching app.logger before configuration attaches a default handler. The canonical form is logging.config.dictConfig.
from logging.config import dictConfig
dictConfig({
"version": 1,
"formatters": {"default": {
"format": "[%(asctime)s] %(levelname)s in %(module)s: %(message)s",
}},
"handlers": {"wsgi": {
"class": "logging.StreamHandler",
"stream": "ext://flask.logging.wsgi_errors_stream",
"formatter": "default",
}},
"root": {"level": "INFO", "handlers": ["wsgi"]},
})
app = Flask(__name__) # ← dictConfig の後で生成する
In production, extend this to JSON structured logs + request ID (inject request.url etc. with a RequestFormatter). The design of observability (correlation IDs, Sentry, health checks) also goes to the error handling & observability guide.
7. Security: harden the boundary with "configuration"
Flask's "micro" is, in security too, "smart defaults + your choices." Let me list the boundaries to harden at minimum in production (the deep dive is the security implementation guide).
- Always set
SECRET_KEY. Flask'ssessionis a client-side signed Cookie (signed with ItsDangerous). Tampering can be detected, but ifSECRET_KEYleaks or is weak, the signature can be forged. Generate withpython -c 'import secrets; print(secrets.token_hex())'. - Harden the Cookie attributes.
SESSION_COOKIE_HTTPONLY(defaultTrue),SESSION_COOKIE_SECURE(defaultFalse→Truein production),SESSION_COOKIE_SAMESITE("Lax"recommended). - CSRF is not bundled. The official states "a form-validation framework doesn't exist in Flask." Introduce CSRF protection with Flask-WTF's
CSRFProtect. - XSS: Jinja's auto-escaping is the default.
.html/.htm/.xml/.xhtmltemplates are auto-escaped. Importescape/Markupfrommarkupsafe(not fromflask). - Security headers aren't attached by default. Attach HSTS, CSP,
X-Content-Type-Options: nosniff, andX-Frame-Optionsyourself, or leave it to Flask-Talisman.
# 本番の Cookie 既定(ProductionConfig 等で)
app.config.update(
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)
8. Deployment: discard the development server, run with Gunicorn
In production, don't use flask run (the development server). Since Flask is a WSGI app, a WSGI server loads and runs the app. The standard on Linux is Gunicorn.
# app オブジェクトを直接指す場合
gunicorn -w 4 'myapp:app'
# アプリケーションファクトリの場合(§3)
gunicorn -w 4 'myapp:create_app()'
-w(number of workers): the official's starting point isCPU × 2. The default is 1 worker, which is insufficient for production.- Behind a reverse proxy,
ProxyFix. To trustX-Forwarded-*behind nginx / ALB, interpose Werkzeug'sProxyFix. If you don't correctly set the number of trusted proxy hops, a client could send a fakeX-Forwarded-For, so be careful here.
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
)
How to choose the worker type (synchronous vs gevent), Docker multi-stage / non-root / health checks, graceful shutdown, and the handling of TRUSTED_HOSTS/SERVER_NAME are consolidated in the production deployment guide.
💡 The misconception of
async defviews: Flask supportsasync defviews from 2.0 (pip install flask[async]), but the official clearly cautions — "each request still occupies 1 worker. Making a view async doesn't change the number of requests handled simultaneously." Where async takes effect is IO concurrency like "calling multiple external APIs in parallel within 1 view," not a throughput improvement. If full-fledged async is a requirement, consider ASGI-premised Quart — that's the official's position.
9. Testing: fix the contract with test_client and pytest fixtures
Flask is a highly testable framework. With app.test_client() you can round-trip requests without standing up an actual server, and with a factory configuration you can create a test-only-configured app every time.
# tests/conftest.py
import pytest
from myapp import create_app
@pytest.fixture()
def app():
app = create_app({"TESTING": True})
yield app
@pytest.fixture()
def client(app):
return app.test_client()
@pytest.fixture()
def runner(app):
return app.test_cli_runner()
# tests/test_health.py
def test_health(client):
res = client.get("/health")
assert res.status_code == 200
assert res.json == {"status": "ok"}
The meaning of TESTING=True, session manipulation with session_transaction(), post-request verification with with client:, follow_redirects, and testing CLI commands (test_cli_runner) are dug into in the testing practical guide.
10. Recommended package structure: connecting everything
Let me bundle the design so far into one directory structure. This is the "form" of production Flask.
myapp/
├── pyproject.toml
├── src/
│ └── myapp/
│ ├── __init__.py # create_app (the application factory) §3
│ ├── config.py # per-environment Config classes §4
│ ├── extensions.py # "bare" extensions like db = SQLAlchemy() §3.2
│ ├── logging.py # dictConfig §6.2
│ ├── errors.py # common error handlers §6.1
│ ├── models/ # SQLAlchemy models
│ └── blueprints/
│ ├── auth/ # auth Blueprint
│ └── api/ # API Blueprint
├── instance/ # .gitignore (secrets, SQLite) §4.2
│ └── config.py
└── tests/
├── conftest.py # app / client / runner fixtures §9
└── test_*.py
💡 The "extract on the third" principle: there's no need to finely split
blueprints/from the start. Start with one file, and split into Blueprints after features increase to 2 or 3 — that's YAGNI. However, put in just the separation ofcreate_appandextensions.pyfrom the start. This is a separation to satisfy not "future extension" but "the current requirement of testability."
Summary: Flask is a framework that takes responsibility for "design freedom"
Flask's essence is "provide only the core, and you decide the structure." That's exactly why production quality is decided by the following discipline.
- With the application factory (
create_app), discard the globalappand gain testability and multiple environments at once. - Make configuration 12-factor with
from_prefixed_env+ the instance folder, driving secrets out of code. - Don't drag the app reference around with
current_app/g, and bind resources to the context's lifespan. - Errors in JSON, logs with
dictConfig, so it doesn't stay silent in production. - Harden the security boundary with configuration via
SECRET_KEY, safe Cookies, CSRF, and auto-escaping. - Discard the development server and run with Gunicorn + ProxyFix.
- Fix the boundary's contract in tests with
test_client+ pytest fixtures.
These 7 are universal design targets that, in changed forms, become necessary in FastAPI and Django too. Flask lets you write them the thinnest and most explicitly — that's Flask's strength when designed appropriately. For the deep dives of the specifics, proceed to each spoke article linked from this pillar. The judgment of whether to adopt Flask itself is best started from the Flask vs FastAPI vs Django technology-selection guide.