Flask Apps skill for Claude Code
App factory, 2.0-style models, gunicorn in prod. Claude structures Flask projects around create_app with lazily bound extensions, environment config that fails fast on missing secrets, blueprints with JSON error handlers, and SQLAlchemy 2.0 typed models queried with select instead of legacy Model.query. It explains context errors instead of papering over them, tests with a fresh app per test, and serves production through tuned gunicorn workers behind a correctly counted ProxyFix.
- name
flask-apps - size303 lines · ≈2,600 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds and reviews Flask 3 applications - the app factory, blueprints, configuration from environment with from_prefixed_env, Flask-SQLAlchemy with SQLAlchemy 2.0 typed models and select queries, Flask-Migrate, JSON error handlers, application and request context pitfalls, CLI commands, pytest fixtures with test_client, and production serving with gunicorn behind a proxy. Use when starting or restructuring a Flask project, fixing working outside of application context errors, migrating legacy Model.query code, writing Flask tests, or deploying Flask to production. Try asking
- Restructure this single-file Flask app into a factory with blueprints and tests
- I keep getting working outside of application context in my Celery tasks
- Get our Flask app production-ready behind nginx with gunicorn
Use it when
- Starting a Flask project or splitting a large app.py
- Migrating Model.query code to SQLAlchemy 2.0 style
- Context errors in scripts, threads or task workers
- Writing Flask tests or deploying Flask behind a proxy
Skip it when
- You want async-first APIs with typed request models — use fastapi-service
- The project is Django or Django REST Framework — use django-apps
How to check the result
pytest passes with a fresh app per test, flask db check reports no unmigrated model changes, and gunicorn refuses to start when SECRET_KEY is missing.
The common mistake it prevents
Shipping with FLASK_DEBUG enabled or app.run(debug=True), which exposes the Werkzeug debugger and lets anyone who triggers an error execute code on the server.
Done well means: the app is built by create_app() with no import-time side effects,
config comes from the environment and fails fast when secrets are missing, models use
SQLAlchemy 2.0 typed mappings, every error returns a consistent body, tests create a fresh
app per test, and production runs under gunicorn, never flask run or debug=True.
Workflow
- [ ] 1. Inventory Flask, SQLAlchemy and extension versions; find global app objects
- [ ] 2. Put the app behind a factory with extensions initialized lazily
- [ ] 3. Split routes into blueprints; add error handlers
- [ ] 4. Model data with SQLAlchemy 2.0 style; migrate with Flask-Migrate
- [ ] 5. Write pytest fixtures and tests through the test client
- [ ] 6. Configure gunicorn, ProxyFix, logging and health checks
1. Inventory
pip show flask flask-sqlalchemy sqlalchemy flask-migrate | grep -E "Name|Version"
grep -rn "app = Flask(" --include=*.py . | head # module-level app objects
grep -rn "\.query\.\|db.session.query" --include=*.py . | wc -l # legacy query style count
grep -rn "debug=True\|app.run(" --include=*.py . | head
Current lines: Flask 3.1, Flask-SQLAlchemy 3.1, SQLAlchemy 2.x. Model.query still works
but is legacy; write new code with db.select() and migrate old call sites when you touch
them.
2. App factory
myapp/
__init__.py # create_app
extensions.py # db, migrate, other extension objects (unbound)
config.py
models.py
orders/ # blueprint package
__init__.py
routes.py
tests/
conftest.py
# myapp/extensions.py
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
db = SQLAlchemy(model_class=Base)
migrate = Migrate()
# myapp/__init__.py
from flask import Flask
from .extensions import db, migrate
def create_app(test_config: dict | None = None) -> Flask:
app = Flask(__name__, instance_relative_config=True)
app.config.from_object("myapp.config.Defaults")
app.config.from_prefixed_env() # FLASK_SECRET_KEY, FLASK_SQLALCHEMY_DATABASE_URI ...
if test_config:
app.config.update(test_config)
if not app.config.get("SECRET_KEY"):
raise RuntimeError("SECRET_KEY is not set")
db.init_app(app)
migrate.init_app(app, db)
from .orders import bp as orders_bp
app.register_blueprint(orders_bp, url_prefix="/api/orders")
from .errors import register_error_handlers
register_error_handlers(app)
@app.get("/healthz")
def healthz():
return {"status": "ok"}
return app
# myapp/config.py
class Defaults:
SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True, "pool_recycle": 1800}
MAX_CONTENT_LENGTH = 2 * 1024 * 1024
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
from_prefixed_env()readsFLASK_*variables and parses values as JSON when possible (FLASK_MAX_CONTENT_LENGTH=1048576becomes an int).- Never create extension objects bound to an app at import time; import-time apps make testing and multiple configs impossible.
- Imports of blueprints inside the factory avoid circular imports.
3. Blueprints and errors
# myapp/orders/routes.py
from flask import abort, request
from sqlalchemy.orm import selectinload
from ..extensions import db
from ..models import Order
from . import bp
@bp.get("/")
def list_orders():
page = db.paginate(
db.select(Order).options(selectinload(Order.items)).order_by(Order.created_at.desc()),
max_per_page=100,
)
return {"items": [o.to_dict() for o in page.items], "page": page.page, "pages": page.pages}
@bp.get("/<int:order_id>")
def get_order(order_id: int):
order = db.get_or_404(Order, order_id)
if order.user_id != current_user_id():
abort(404) # do not reveal other users' orders
return order.to_dict()
@bp.post("/")
def create_order():
data = OrderIn.model_validate(request.get_json(silent=True) or {}) # pydantic or marshmallow
order = Order(user_id=current_user_id(), sku=data.sku, qty=data.qty)
db.session.add(order)
db.session.commit()
return order.to_dict(), 201
# myapp/errors.py
from flask import Flask
from pydantic import ValidationError
from werkzeug.exceptions import HTTPException
def register_error_handlers(app: Flask) -> None:
@app.errorhandler(HTTPException)
def http_error(e: HTTPException):
return {"type": "about:blank", "title": e.name, "status": e.code, "detail": e.description}, e.code
@app.errorhandler(ValidationError)
def validation_error(e: ValidationError):
return {"title": "Validation failed", "status": 422, "errors": e.errors(include_input=False)}, 422
@app.errorhandler(Exception)
def unhandled(e: Exception):
app.logger.exception("unhandled error")
return {"title": "Internal Server Error", "status": 500}, 500
request.get_json(silent=True) returns None for bad JSON instead of raising a 400 HTML
page. Register the HTTPException handler so 404 and 405 also come back as JSON.
4. Models and migrations
# myapp/models.py
from datetime import datetime
from sqlalchemy import ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .extensions import db
class Order(db.Model):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
sku: Mapped[str] = mapped_column(db.String(64))
qty: Mapped[int]
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
items: Mapped[list["OrderItem"]] = relationship(back_populates="order", cascade="all, delete-orphan")
| Legacy | SQLAlchemy 2.0 style |
|---|---|
Order.query.get(id) | db.session.get(Order, id) or db.get_or_404(Order, id) |
Order.query.filter_by(sku=s).first() | db.session.execute(db.select(Order).filter_by(sku=s)).scalar_one_or_none() |
Order.query.all() | db.session.scalars(db.select(Order)).all() |
Order.query.paginate(...) | db.paginate(db.select(Order), ...) |
flask --app myapp db migrate -m "add orders" # autogenerate; ALWAYS read the result
flask --app myapp db upgrade
Autogenerate misses renames (it emits drop + add, losing data), server defaults, and some
constraint changes. Edit the migration before committing. Run db downgrade or any
destructive migration against shared databases only with the user’s confirmation.
Avoid N+1 with selectinload for collections and joinedload for many-to-one.
5. Context rules
| Error / situation | Cause | Fix |
|---|---|---|
| “Working outside of application context” | Using db, current_app in a script or thread | with app.app_context(): |
| “Working outside of request context” | Touching request/session outside a request | Pass values in explicitly; app.test_request_context() in tests |
Background thread loses g/request | Contexts are per thread | Extract data before starting the thread; use a task queue for real work |
Celery task cannot use db | No app context in the worker | Task base class that wraps __call__ in app.app_context() |
async def views work but each request still occupies a worker thread; they do not turn
Flask into an async server. Use Quart or FastAPI when the workload is mostly concurrent IO.
CLI commands for maintenance tasks:
@bp.cli.command("backfill-totals")
def backfill_totals():
... # runs inside an app context automatically
6. Tests
# tests/conftest.py
import pytest
from myapp import create_app
from myapp.extensions import db
@pytest.fixture
def app():
app = create_app({
"TESTING": True,
"SECRET_KEY": "test",
"SQLALCHEMY_DATABASE_URI": "postgresql+psycopg://localhost/myapp_test",
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
def test_missing_order_returns_json_404(client):
res = client.get("/api/orders/999999")
assert res.status_code == 404
assert res.is_json and res.json["status"] == 404
Test against the same database engine as production when you rely on its constraints;
SQLite differs in types, constraints and locking. Use app.test_cli_runner() to test CLI
commands.
7. Production serving
gunicorn "myapp:create_app()" \
--bind 0.0.0.0:8000 --workers 4 --worker-class gthread --threads 4 \
--timeout 30 --graceful-timeout 30 --keep-alive 5 \
--max-requests 2000 --max-requests-jitter 200 --access-logfile -
- Workers: start at
2 x CPU cores + 1for sync workers; withgthread, fewer workers and 2 to 8 threads each for IO-bound apps. Measure under load before tuning further. - Behind nginx or a load balancer, wrap the WSGI app so
request.remote_addr, scheme andurl_for(_external=True)are correct:app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1). Set the counts to the number of proxies you actually have; higher values let clients spoof IPs. - Configure logging with
logging.config.dictConfigbeforecreate_app()runs, to stdout in JSON for containers. - The Werkzeug debugger allows arbitrary code execution.
FLASK_DEBUGmust be unset in every deployed environment.
Gotchas
db.session.commit()inside loops creates one transaction per row; commit once per unit of work.- Mutating a JSON column in place is not detected; reassign or use
MutableDict. flask runis a development server: single process, no graceful shutdown.- Old config keys like
JSON_SORT_KEYSwere removed in Flask 2.3; configure JSON throughapp.json(app.json.sort_keys = False). - Forms that post without CSRF protection: add Flask-WTF’s
CSRFProtectfor cookie-authenticated HTML forms.
Verification
pytest -q
ruff check . && mypy myapp # if configured
flask --app myapp db upgrade && flask --app myapp db check # models match migrations
FLASK_SECRET_KEY= gunicorn "myapp:create_app()" # must fail fast on missing secret
flask db check (Flask-Migrate 4+) fails when models have changes without a migration;
add it to CI.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.