Flask with SQLAlchemy is the most common stack for Python web apps that need a relational database — and getting migrations and deploys right is what separates a working dev environment from a production system that survives schema changes without downtime.
This guide covers SQLAlchemy 2.x (released 2023, now the supported major version), Flask 3.x, and Flask-Migrate 4.x — including the typed Mapped[] declarative API, autogenerate caveats, the expand-contract pattern for zero-downtime schema changes, and how to wire flask db upgrade into a production deploy pipeline.
Why code-first migrations matter
In a code-first workflow, your SQLAlchemy models are the source of truth. Alembic (the migration engine behind Flask-Migrate) diffs those models against the live database and generates migration scripts you commit alongside the code that depends on them.
The win: schema changes ship in the same pull request as the code that uses them. The risk: a sloppy migration in production can lock tables, drop data, or leave the schema in an inconsistent state mid-deploy. The rest of this guide is about avoiding that.
Set up Flask 3 + SQLAlchemy 2 + Flask-Migrate
A working starting point with the modern typed API:
# extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
db = SQLAlchemy(model_class=Base)
migrate = Migrate()
# models.py
from datetime import datetime, timezone
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from extensions import db
class User(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(80), unique=True)
email: Mapped[str] = mapped_column(String(120), unique=True)
created_at: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
orders: Mapped[list["Order"]] = relationship(back_populates="user")
class Order(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
total_amount: Mapped[float]
created_at: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
user: Mapped["User"] = relationship(back_populates="orders")
# app.py
import os
from flask import Flask
from extensions import db, migrate
def create_app():
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ["DATABASE_URL"]
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
migrate.init_app(app, db)
from models import User, Order # noqa: F401 – needed for autogenerate
return app
A few things in this snippet that the old Flask-SQLAlchemy 2.x tutorials get wrong:
DeclarativeBase+Mapped[]is the supported SQLAlchemy 2.x API. The legacydb.Column(...)style still works but won't get further development;mypyandpyrightunderstandMapped[]natively.datetime.utcnowis deprecated in Python 3.12+. Usedatetime.now(timezone.utc)and store timezone-aware datetimes.SQLALCHEMY_TRACK_MODIFICATIONS = Falseprevents a known memory leak. It will become the default in a future release but you should set it explicitly today.
The four commands you actually run
flask db init # one-time, creates the migrations/ directory
flask db migrate -m "create users and orders"
flask db upgrade # apply pending migrations
flask db downgrade # roll back one revision
Commit the generated file in migrations/versions/ to git. That file — not the model — is what runs in production.
Autogenerate is not magic — review every migration
Alembic's --autogenerate is convenient but it does not detect every kind of change. Per the Alembic docs on autogenerate limitations, here is what it routinely misses:
| Change type | Detected? |
|---|---|
| Adding/dropping tables | Yes |
| Adding/dropping columns | Yes |
| Renaming columns | No — appears as drop + add (data loss) |
| Renaming tables | No — same problem |
Changes to server_default |
No |
CHECK constraints |
Partial |
Index changes on columns with unique=True |
Often missed |
| Changes inside JSON/JSONB columns | No |
| Sequence and enum modifications | Partial |
Always open the generated migration file before committing. A column rename that Alembic emits as drop_column + add_column will silently delete every value in that column on upgrade. Edit it to use op.alter_column(..., new_column_name=...) instead.
Zero-downtime schema changes: the expand-contract pattern
If your deploy strategy is rolling (new and old application versions running simultaneously for a few minutes), a single-step ALTER TABLE can break the old version mid-deploy. The industry-standard fix is expand-contract (also called parallel change in the Refactoring Databases literature):
Expand → Migrate data → Contract
add new drop old
nullable column
column
Concretely, to rename users.email → users.email_address:
- Deploy 1 (expand): migration adds
email_addresscolumn (nullable), application code writes to bothemailandemail_address, reads fromemail. - Backfill: one-shot script copies
email→email_addressfor existing rows. - Deploy 2 (cut over): application reads from
email_address, still dual-writes. - Deploy 3 (contract): migration drops
email, application drops the dual-write.
You never have a moment where an old app version sees a column it doesn't know about, or a new version reads from a column that doesn't exist. The same pattern works for type changes (add new column with new type, dual-write, backfill, swap, drop). For more on the supporting deploy infrastructure, see our guide on zero downtime deployments.
The price you pay: three deploys instead of one, and dual-write code you have to remember to remove. Cheaper than a 3am outage.
Production deploy order: code first or schema first?
This is the one decision that determines whether your deploys are safe. The rule:
- Additive changes (new column, new table, new index) — run the migration before the new code. Old code ignores the new column. New code finds it ready.
- Destructive changes (drop column, drop table, narrow a type) — run the migration after the old code is fully retired. Otherwise the still-running old version errors on every request.
If you can't reason about which category a migration falls into, it's almost certainly not additive — and you probably want expand-contract.
Wire flask db upgrade into a DeployHQ deploy
DeployHQ's post-deployment SSH commands are where the migration step lives. A typical Flask deploy on a VPS looks like:
# SSH command 1: install/refresh deps in the virtualenv
cd /var/www/myapp/current
source venv/bin/activate
pip install -r requirements.txt --upgrade
# SSH command 2: run migrations (additive-only releases)
cd /var/www/myapp/current
source venv/bin/activate
export FLASK_APP=app.py
flask db upgrade
# SSH command 3: graceful gunicorn reload
sudo systemctl reload myapp.service
Three details that matter in production:
- Migrations run once, not per-server. If you deploy to multiple app servers, run
flask db upgradeagainst the database from exactly one place — typically the first server in your deploy zone. DeployHQ's deployment zones let you target a single server for the migration step and all servers for the code release. systemctl reload, notrestart, when your app server supports it. Gunicorn re-execs workers gracefully;restartdrops in-flight requests. Pair this with automatic Git-triggered deployments so a push tomainruns the whole pipeline.- Idempotent migrations.
flask db upgradeis already idempotent — running it twice is a no-op once the revision is applied — but make sure any backfill scripts you bolt on are also safe to re-run.
For the broader picture of how the application server fits with Nginx and gunicorn/uvicorn, see our breakdown of Python application servers in 2026: from WSGI to modern ASGI.
Environment-specific config without leaking secrets
Don't hardcode database URLs. Don't commit .env files. Load from the process environment and validate at startup so the app fails fast:
# config.py
import os
class Config:
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"] # KeyError if missing
SECRET_KEY = os.environ["SECRET_KEY"]
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {
"pool_size": int(os.environ.get("DB_POOL_SIZE", "10")),
"pool_pre_ping": True, # detect dead connections
"pool_recycle": 3600, # recycle hourly to dodge MySQL wait_timeout
}
class ProductionConfig(Config):
DEBUG = False
TESTING = False
class DevelopmentConfig(Config):
DEBUG = True
pool_pre_ping=True is the cheap fix for the classic MySQL server has gone away
error after the pool sits idle.
Rollback strategy that actually works
flask db downgrade is fine for additive migrations — the column you just added gets dropped. It is not safe for migrations that already dropped data or columns, because the data is gone.
The realistic rollback playbook:
- For additive deploys: keep the old code release on the server (DeployHQ retains previous releases by default) and revert with one-click rollback. The schema is forward-compatible, so the old code still works.
- For destructive deploys: you should have been using expand-contract. If you weren't, your only real recovery is a database backup. Have a tested point-in-time recovery procedure before you need it.
- Stamp, don't downgrade, when reverting a release: if you've reverted the application code but the migration is harmless, use
flask db stamp <previous_revision>rather thandowngrade. It updates the version table without re-running SQL.
Tests that actually catch migration bugs
The migration that broke production was probably tested on an empty database. To catch the real problems:
# tests/test_migrations.py
import subprocess
def test_full_upgrade_then_downgrade(tmp_path, monkeypatch):
db_path = tmp_path / "test.db"
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{db_path}")
monkeypatch.setenv("FLASK_APP", "app.py")
# Upgrade to head from an empty database
subprocess.run(["flask", "db", "upgrade"], check=True)
# Walk back down one step at a time, then back up
subprocess.run(["flask", "db", "downgrade", "base"], check=True)
subprocess.run(["flask", "db", "upgrade"], check=True)
In CI, run this against a database populated with representative data, not an empty one. A migration that adds a NOT NULL column without a default will pass against empty tables and fail against a real production snapshot.
Common gotchas, ranked by frequency
| Gotcha | Symptom | Fix |
|---|---|---|
| Autogenerate emits drop+add for a rename | Column data vanishes on upgrade | Hand-edit to op.alter_column(..., new_column_name=...) |
Adding NOT NULL column without default |
Migration fails on a non-empty table | Two-step: nullable column → backfill → ALTER NOT NULL |
| Migration not committed | Works locally, fails in CI | Add migrations/versions/*.py to git |
| Multiple devs branch off same revision | Two heads, upgrade complains |
flask db merge -m "merge heads" |
pool_pre_ping=False (default) |
MySQL server has gone awayafter idle |
Set pool_pre_ping=True |
Running flask db upgrade per app server |
Race conditions, partial schema | Run once from one server only |
Forgetting FLASK_APP in deploy script |
Error: Could not locate a Flask application |
Export it in the SSH command |
Where this fits in your broader stack
Code-first migrations are one piece of the database deployment problem. Adjacent reading:
- Database migration strategies for zero-downtime deployments — the multi-language version of the expand-contract walkthrough above.
- Flyway database migrations in 2026 — if you want a SQL-first alternative to Alembic.
- How to handle database changes during a deployment — sequencing schema and code changes safely.
- SQLite vs PostgreSQL vs MySQL — picking the right engine; the migration tooling assumes you've already made this call.
- Deploy Django on a budget with Hetzner and DeployHQ — if your team is split across Flask and Django services.
Flask and SQLAlchemy give you a code-first workflow that scales from a prototype to a production system, but only if you respect what migrations actually do: rewrite a live database while running code depends on it. Treat every autogenerated migration as a draft, separate additive from destructive changes, and run the migration step in exactly one place during your deploy.
DeployHQ's Git-triggered deployments, per-server SSH commands, and one-click rollback give you the building blocks. The Alembic discipline is on you.
Start your free DeployHQ trial to wire up Flask migrations as part of your deploy pipeline, or see pricing for team plans.
Questions, corrections, or a Flask deploy war story to share? Email us at support@deployhq.com or find us on X.