Claude Code with Flask: Routes, Blueprints, Testing
Why Flask needs an explicit CLAUDE.md
Flask is deliberately minimal. It gives you routing, request context, and Jinja2 templating. Everything else, including database access, authentication, form validation, and testing infrastructure, is a choice the developer makes. That freedom is also what makes Claude Code unreliable on Flask projects without explicit constraints.
Without a CLAUDE.md, Claude generates Flask code from a mix of Flask 1.x tutorials (the majority of its training data), Flask 2.x patterns, and Flask 3.x features. The result is a working application in a narrow happy path that breaks as soon as you add a second route handler, run tests, or deploy to a WSGI server. The four most common failure modes:
Module-level app object. Claude generates app = Flask(__name__) at the top of app.py and registers routes with @app.route. This works for a single file but prevents you from creating multiple app instances for testing, breaks circular imports when you add blueprints, and makes configuration injection impossible. The correct pattern is an application factory function.
Flask 1.x imports. Claude imports from flask.ext.* or uses flask.json.jsonify in ways that were deprecated in Flask 2.x and removed in Flask 3.x.
SQLAlchemy session misuse. Claude generates db.session.commit() inside route handlers after every change, then calls db.session.add() outside a try/except. A failed commit leaves the session in a broken state that affects every subsequent request until the worker restarts.
Missing test fixtures. Claude generates tests that import the module-level app object directly, which fails when you switch to the factory pattern and causes test isolation issues because Flask's app context is not reset between tests.
This guide covers the CLAUDE.md that fixes all four problems, plus blueprint organisation, SQLAlchemy 2.x session handling, pytest fixtures, and common Claude mistakes.
The Flask CLAUDE.md template
# Flask application rules
## Stack
- Flask 3.x, Python 3.12+
- Flask-SQLAlchemy 3.x (SQLAlchemy 2.x under the hood)
- Flask-Migrate for schema migrations
- pytest 8.x + pytest-flask for testing
- python-dotenv for environment variable loading
## Project structure
- src/
- __init__.py application factory (create_app)
- config.py Config, DevelopmentConfig, TestingConfig, ProductionConfig
- extensions.py db, migrate, login_manager singletons (no app reference yet)
- models/ SQLAlchemy model classes
- routes/ Blueprint modules
- templates/ Jinja2 templates
- tests/
- conftest.py pytest fixtures (app, client, db session)
- test_*.py test modules
## Application factory (MANDATORY)
- ALWAYS use the factory pattern: def create_app(config_name='development')
- NEVER put `app = Flask(__name__)` at module level outside the factory
- The factory:
1. Creates the Flask instance
2. Loads config from config.py using app.config.from_object()
3. Calls extension.init_app(app) for each extension
4. Registers blueprints with app.register_blueprint()
5. Returns app
## Blueprint pattern
- One blueprint per functional domain (auth, users, api, admin)
- Blueprint file: src/routes/users.py -> users_bp = Blueprint('users', __name__, url_prefix='/users')
- Register in factory: app.register_blueprint(users_bp)
- NEVER import the app object inside blueprint files (causes circular imports)
## SQLAlchemy session rules (CRITICAL)
- ALWAYS wrap mutations in try/except with db.session.rollback() on failure
- NEVER commit in the middle of a multi-step operation; commit once at the end
- NEVER rely on a global db.session outside a request context
- Use db.session.add(), db.session.delete() then db.session.commit() in that order
- Let Flask-SQLAlchemy's scoped_session handle teardown; do not close sessions manually
## Hard rules
- NEVER use flask.ext.* imports (removed in Flask 1.0+)
- NEVER use app.run() in production code; that is for development only
- NEVER generate module-level route handlers outside a blueprint or factory
- ALWAYS set SECRET_KEY from environment, never hardcode it
- ALWAYS use before_request / teardown_request hooks, not inline session management
- Python type hints on all route handler functions
Install and project setup
# Create a virtual environment
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# Install dependencies
pip install flask flask-sqlalchemy flask-migrate pytest pytest-flask python-dotenv
# Freeze requirements
pip freeze > requirements.txt
Create .env for local development:
FLASK_APP=src
FLASK_ENV=development
SECRET_KEY=dev-secret-key-change-in-production
DATABASE_URL=sqlite:///dev.db
The application factory
The factory pattern is the structural foundation that makes everything else in this guide work. Without it, testing, blueprints, and multi-config deployments are all broken by design.
# src/__init__.py
from flask import Flask
from .extensions import db, migrate
from .config import config_by_name
def create_app(config_name: str = 'development') -> Flask:
app = Flask(__name__)
app.config.from_object(config_by_name[config_name])
# Initialise extensions with the app instance
db.init_app(app)
migrate.init_app(app, db)
# Register blueprints
from .routes.users import users_bp
from .routes.auth import auth_bp
from .routes.api import api_bp
app.register_blueprint(users_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(api_bp)
return app
# src/extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
# Extensions are created without an app instance here.
# They get the app later via init_app() inside create_app().
db = SQLAlchemy()
migrate = Migrate()
# src/config.py
import os
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'change-this')
SQLALCHEMY_TRACK_MODIFICATIONS = False
class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///dev.db')
class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
WTF_CSRF_ENABLED = False
class ProductionConfig(Config):
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
config_by_name = {
'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig,
}
Add the factory skeleton to CLAUDE.md as a concrete code block. When Claude has a complete working example of the pattern in CLAUDE.md, it reproduces it faithfully instead of reverting to the module-level app antipattern.
Models with SQLAlchemy 2.x
Flask-SQLAlchemy 3.x uses SQLAlchemy 2.x under the hood. Claude's default output often mixes 1.x and 2.x patterns, particularly around Query.get() (removed in 2.x) and the relationship() lazy parameter defaults.
# src/models/user.py
from datetime import datetime
from typing import Optional
from src.extensions import db
class User(db.Model):
__tablename__ = 'users'
id: int = db.Column(db.Integer, primary_key=True)
email: str = db.Column(db.String(254), nullable=False, unique=True)
name: str = db.Column(db.String(120), nullable=False)
created_at: datetime = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
posts: list['Post'] = db.relationship('Post', back_populates='author', lazy='select')
def __repr__(self) -> str:
return f'<User {self.email}>'
def to_dict(self) -> dict:
return {
'id': self.id,
'email': self.email,
'name': self.name,
'created_at': self.created_at.isoformat(),
}
class Post(db.Model):
__tablename__ = 'posts'
id: int = db.Column(db.Integer, primary_key=True)
title: str = db.Column(db.String(200), nullable=False)
body: str = db.Column(db.Text, nullable=False)
author_id: int = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at: datetime = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
author: User = db.relationship('User', back_populates='posts')
Add model guidelines to CLAUDE.md:
## SQLAlchemy models
- NEVER use Query.get(id) (removed in SQLAlchemy 2.x); use db.session.get(Model, id) instead
- NEVER use Query.first() without an ORDER BY for predictable results
- ALWAYS define __tablename__ explicitly
- ALWAYS define to_dict() method on models that are returned as JSON
- Type hints on all column definitions improve IDE support and Claude accuracy
- datetime.utcnow is deprecated in Python 3.12+; for new code use datetime.now(timezone.utc)
Blueprint routes
Blueprints group related routes together and register them with a URL prefix. This is the correct structure for any Flask application beyond a single prototype file.
# src/routes/users.py
from flask import Blueprint, jsonify, request, abort
from typing import Any
from src.extensions import db
from src.models.user import User
users_bp = Blueprint('users', __name__, url_prefix='/users')
@users_bp.get('/')
def list_users() -> tuple[Any, int]:
users = db.session.execute(db.select(User).order_by(User.created_at.desc())).scalars().all()
return jsonify({'users': [u.to_dict() for u in users]}), 200
@users_bp.get('/<int:user_id>')
def get_user(user_id: int) -> tuple[Any, int]:
user = db.session.get(User, user_id)
if user is None:
abort(404)
return jsonify({'user': user.to_dict()}), 200
@users_bp.post('/')
def create_user() -> tuple[Any, int]:
data = request.get_json(silent=True)
if not data or not data.get('email') or not data.get('name'):
abort(400, description='email and name are required')
user = User(email=data['email'], name=data['name'])
try:
db.session.add(user)
db.session.commit()
except Exception:
db.session.rollback()
raise
return jsonify({'user': user.to_dict()}), 201
@users_bp.put('/<int:user_id>')
def update_user(user_id: int) -> tuple[Any, int]:
user = db.session.get(User, user_id)
if user is None:
abort(404)
data = request.get_json(silent=True) or {}
if 'name' in data:
user.name = data['name']
try:
db.session.commit()
except Exception:
db.session.rollback()
raise
return jsonify({'user': user.to_dict()}), 200
@users_bp.delete('/<int:user_id>')
def delete_user(user_id: int) -> tuple[Any, int]:
user = db.session.get(User, user_id)
if user is None:
abort(404)
try:
db.session.delete(user)
db.session.commit()
except Exception:
db.session.rollback()
raise
return jsonify({'deleted': True}), 200
The try/except with db.session.rollback() block is the most important pattern here. Without the rollback, a failed commit leaves the SQLAlchemy session in a broken state. Subsequent requests on the same worker get a session in an error state and raise an obscure InvalidRequestError. Add this pattern explicitly to CLAUDE.md as a code block and Claude applies it to every generated mutation handler.
Error handlers
Register error handlers in the factory to return consistent JSON responses:
# src/__init__.py (add inside create_app, before return app)
from flask import jsonify
@app.errorhandler(400)
def bad_request(e):
return jsonify({'error': str(e.description)}), 400
@app.errorhandler(404)
def not_found(e):
return jsonify({'error': 'Not found'}), 404
@app.errorhandler(409)
def conflict(e):
return jsonify({'error': str(e.description)}), 409
@app.errorhandler(500)
def server_error(e):
return jsonify({'error': 'Internal server error'}), 500
Add this to CLAUDE.md:
## Error handlers
- Register all error handlers in create_app() before return
- ALWAYS return JSON from error handlers in API routes (not HTML)
- Standard set: 400, 404, 409, 500
- Use abort(code, description='message') in route handlers to trigger them
Pytest fixtures and testing
The test configuration is where the factory pattern pays off. You can create a fresh app instance configured for testing (in-memory SQLite, CSRF disabled) for each test session, and a fresh database for each test function.
# tests/conftest.py
import pytest
from src import create_app
from src.extensions import db as _db
@pytest.fixture(scope='session')
def app():
"""Create a test application instance for the session."""
app = create_app('testing')
with app.app_context():
_db.create_all()
yield app
_db.drop_all()
@pytest.fixture(scope='function')
def client(app):
"""Create a test client for each test function."""
return app.test_client()
@pytest.fixture(scope='function')
def db(app):
"""Provide a clean database for each test function."""
with app.app_context():
yield _db
_db.session.remove()
# Roll back any changes from this test
for table in reversed(_db.metadata.sorted_tables):
_db.session.execute(table.delete())
_db.session.commit()
# tests/test_users.py
import json
def test_create_user(client):
response = client.post(
'/users/',
data=json.dumps({'email': 'alice@example.com', 'name': 'Alice'}),
content_type='application/json',
)
assert response.status_code == 201
data = response.get_json()
assert data['user']['email'] == 'alice@example.com'
assert data['user']['id'] is not None
def test_get_user_not_found(client):
response = client.get('/users/9999')
assert response.status_code == 404
def test_create_user_missing_fields(client):
response = client.post(
'/users/',
data=json.dumps({'email': 'bob@example.com'}),
content_type='application/json',
)
assert response.status_code == 400
def test_list_users(client, db):
from src.models.user import User
from src.extensions import db as _db
# Seed directly with SQLAlchemy
user = User(email='charlie@example.com', name='Charlie')
_db.session.add(user)
_db.session.commit()
response = client.get('/users/')
assert response.status_code == 200
users = response.get_json()['users']
assert any(u['email'] == 'charlie@example.com' for u in users)
Run the tests:
pytest tests/ -v
Add the conftest pattern to CLAUDE.md:
## Testing setup (pytest-flask)
- conftest.py MUST define: app fixture (session scope), client fixture, db fixture (function scope)
- NEVER import the module-level app object in tests; always use the app fixture
- ALWAYS use scope='session' for the app fixture (create once per test run)
- ALWAYS use scope='function' for the db fixture (clean state per test)
- Test database: sqlite:///:memory: configured in TestingConfig
- Run tests: pytest tests/ -v --tb=short
Database migrations with Flask-Migrate
Flask-Migrate wraps Alembic to generate and apply schema migrations. The commands run against the running Flask app, which means the factory must be importable.
# Initialise the migrations folder (run once)
flask db init
# Generate a migration from model changes
flask db migrate -m "add users table"
# Review the generated migration file in migrations/versions/
# then apply it
flask db upgrade
# Roll back the last migration
flask db downgrade
Set FLASK_APP=src in your environment so the CLI can find create_app. Add to CLAUDE.md:
## Migrations (Flask-Migrate / Alembic)
- flask db init initialise migrations folder (once per project)
- flask db migrate -m generate migration from model diff
- flask db upgrade apply pending migrations
- flask db downgrade revert last migration
- FLASK_APP must point to the package containing create_app
- After adding a model: import it in create_app (or models/__init__.py) so Alembic sees it
- NEVER edit the database directly in production; always use migrations
Running in production with Gunicorn
Flask's built-in server (flask run) is for development only. In production, run Flask behind Gunicorn:
pip install gunicorn
# Start with 4 workers
gunicorn "src:create_app('production')" --workers 4 --bind 0.0.0.0:8000
A Procfile for Heroku or Railway:
web: gunicorn "src:create_app('production')" --workers 4 --bind 0.0.0.0:$PORT
Add deployment rules to CLAUDE.md:
## Production deployment
- ALWAYS run behind Gunicorn (or uWSGI), NEVER use flask run in production
- gunicorn factory call: "src:create_app('production')" (string, not import)
- Workers: 2-4 per CPU core is a standard starting point
- SECRET_KEY: generate with python -c "import secrets; print(secrets.token_hex(32))"
- DATABASE_URL: must be set in environment, never hardcoded
Common Claude Code mistakes with Flask
Six patterns Claude generates without CLAUDE.md constraints, with the correct replacement.
1. Module-level app object
Claude generates:
app = Flask(__name__)
db = SQLAlchemy(app)
@app.route('/users')
def list_users():
...
Correct: application factory pattern with create_app(), extensions via init_app().
2. Query.get() (removed in SQLAlchemy 2.x)
Claude generates: user = User.query.get(user_id)
Correct: user = db.session.get(User, user_id)
3. No session rollback on failure
Claude generates:
db.session.add(user)
db.session.commit()
Correct:
try:
db.session.add(user)
db.session.commit()
except Exception:
db.session.rollback()
raise
4. Flask 1.x @app.route on blueprints
Claude generates: @app.route('/users') inside a blueprint file, importing app from the parent module.
Correct: @users_bp.get('/users') with no app import.
5. Tests importing module-level app
Claude generates: from src.app import app and uses it directly in tests.
Correct: pytest app fixture that calls create_app('testing').
6. HTML error responses in an API
Claude generates error handlers that return HTML (Flask's default error page format).
Correct: @app.errorhandler(404) that returns jsonify({'error': 'Not found'}), 404.
Building reliable Flask APIs with Claude Code
The Flask CLAUDE.md in this guide produces applications where the factory pattern is used consistently, blueprints replace module-level routes, SQLAlchemy sessions are rolled back on failure, Query.get() is never used, tests use proper fixtures with app and database isolation, and error handlers return JSON.
Flask's minimal philosophy puts the burden of architectural decisions on the developer. The CLAUDE.md template makes the correct architectural decisions the default, so Claude generates code that holds up under test, scales to multiple blueprints, and deploys cleanly behind a production WSGI server.
For the database layer powering these routes, Claude Code with Prisma and Claude Code with Drizzle cover the ORM patterns that integrate with Flask's request context model. For background task processing alongside your Flask API, Claude Code with BullMQ covers the queue patterns that offload slow operations from request handlers.
Get Claudify. The Flask CLAUDE.md template, factory scaffold, pytest conftest, and SQLAlchemy 2.x patterns are included, ready to drop into any Python project.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify