Flask-SQLAlchemy models with Flask-Migrate upgrades on sqlite in tests: working versions (tested 2026-10-15)
Define Flask-SQLAlchemy models, version the schema with Flask-Migrate, and test upgrade, downgrade and model/migration drift offline on a temporary sqlite file.
passed4 passed · 0 failed2026-10-15 14:13 UTC- Status
- passed
- Last passed
- 2026-10-15 14:13 UTC
- Tests
- 4 / 4
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38748988915
The code
Exactly the files that passed. Your agent can pull them with get_recipe("flask-migrate-sqlite") (install), or see them on GitHub.
flask==3.1.3 flask-sqlalchemy==3.1.1 flask-migrate==4.1.0 pytest==9.1.1 alembic==1.16.5 mako==1.3.10 sqlalchemy==2.0.43 typing-extensions==4.15.0 werkzeug==3.1.3 jinja2==3.1.6 markupsafe==3.0.3 itsdangerous==2.2.0 blinker==1.9.0 click==8.3.0 iniconfig==2.1.0 pluggy==1.6.0 pygments==2.19.2
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
db = SQLAlchemy()
class User(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
name: Mapped[str] = mapped_column(String(80))
import os
from flask import Flask
from flask_migrate import Migrate
from models import db
MIGRATIONS = os.path.join(os.path.dirname(__file__), "migrations")
def create_app(uri: str = "sqlite:///app.db") -> Flask:
app = Flask(__name__)
# Config must be set before init_app, or the engine binds to the default URI.
app.config["SQLALCHEMY_DATABASE_URI"] = uri
db.init_app(app)
Migrate(app, db, directory=MIGRATIONS)
return app
from alembic import context
from flask import current_app
# Flask-Migrate runs this inside an app context, so current_app is available.
db = current_app.extensions["migrate"].db
# render_as_batch: sqlite cannot ALTER most constraints, so Alembic
# recreates the table instead. compare_type catches column type drift.
with db.engine.connect() as connection:
context.configure(
connection=connection,
target_metadata=db.metadata,
render_as_batch=True,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
"""create user"""
import sqlalchemy as sa
from alembic import op
revision = "3f2a1b7c9d10"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"user",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("email", sa.String(255), nullable=False, unique=True),
sa.Column("name", sa.String(80), nullable=False),
)
def downgrade():
op.drop_table("user")
import pytest
from alembic.autogenerate import compare_metadata
from alembic.migration import MigrationContext
from flask_migrate import downgrade, upgrade
from sqlalchemy import inspect
from sqlalchemy.exc import IntegrityError
from app import create_app
from models import User, db
@pytest.fixture
def app(tmp_path):
# A real file, not sqlite:///:memory:. Each pooled connection to an
# in-memory database gets its own empty DB, so upgrades would be lost.
app = create_app(f"sqlite:///{tmp_path / 'test.db'}")
app.config["TESTING"] = True
with app.app_context():
yield app
db.session.remove()
db.engine.dispose()
def test_upgrade_matches_models(app):
upgrade()
with db.engine.connect() as conn:
mc = MigrationContext.configure(conn, opts={"compare_type": True})
assert compare_metadata(mc, db.metadata) == []
def test_downgrade_drops_table(app):
upgrade()
assert "user" in inspect(db.engine).get_table_names()
downgrade(revision="base")
assert "user" not in inspect(db.engine).get_table_names()
def test_unique_email_enforced_after_upgrade(app):
upgrade()
db.session.add_all([User(email="[email protected]", name="a"), User(email="[email protected]", name="b")])
with pytest.raises(IntegrityError):
db.session.commit()
db.session.rollback()
def test_data_persists_across_sessions(app):
upgrade()
db.session.add(User(email="[email protected]", name="p"))
db.session.commit()
db.session.remove()
assert db.session.scalar(db.select(db.func.count(User.id))) == 1
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Flask 3 app factory + Flask-SQLAlchemy
Flask application factory with Flask-SQLAlchemy 3, a JSON API and pytest fixtures using an in-memory database.
Flask API with Flask-SQLAlchemy and marshmallow-sqlalchemy, validation errors as 422
A Flask JSON API that loads and dumps SQLAlchemy models through SQLAlchemyAutoSchema, returns validation errors as 422, and is tested offline with the Flask test client on in-memory sqlite.
Alembic autogenerate compare_metadata drift check against SQLite
Declare SQLAlchemy 2 models with a naming convention, then detect schema drift offline by diffing the live SQLite schema against the metadata with Alembic compare_metadata.
Flask-Login sessions with Flask-SQLAlchemy users and werkzeug password hashing
Login, login_required redirects, logout and a user_loader built on db.session.get, with the User model in SQLite memory and tests run through the Flask test client.
All Flask recipesAll Flask-SQLAlchemy recipesAll flask-migrate recipesAll SQLAlchemy recipesAll Alembic recipes