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.

  • nameflask-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.

~/.claude/skills/flask-apps/SKILL.md

--- name: flask-apps description: Builds and reviews Flask 3 applications - the app factory, blueprints, configuration from environment with from_prefixed_env,… license: MIT ---

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() reads FLASK_* variables and parses values as JSON when possible (FLASK_MAX_CONTENT_LENGTH=1048576 becomes 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")
LegacySQLAlchemy 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 / situationCauseFix
“Working outside of application context”Using db, current_app in a script or threadwith app.app_context():
“Working outside of request context”Touching request/session outside a requestPass values in explicitly; app.test_request_context() in tests
Background thread loses g/requestContexts are per threadExtract data before starting the thread; use a task queue for real work
Celery task cannot use dbNo app context in the workerTask 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 + 1 for sync workers; with gthread, 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 and url_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.dictConfig before create_app() runs, to stdout in JSON for containers.
  • The Werkzeug debugger allows arbitrary code execution. FLASK_DEBUG must 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 run is a development server: single process, no graceful shutdown.
  • Old config keys like JSON_SORT_KEYS were removed in Flask 2.3; configure JSON through app.json (app.json.sort_keys = False).
  • Forms that post without CSRF protection: add Flask-WTF’s CSRFProtect for 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.

esc