Schema migrations with Flask-Migrate
Initialise Alembic through Flask-Migrate, review the scripts autogenerate produces, and apply schema and data changes in a controlled order.
init, migrate, upgrade
pip install flask-migrate
flask db init # creates migrations/ + a starter script
flask db migrate -m "add post.published"
flask db upgrade # apply pending revisions
flask db current # revision the database is on
flask db history --verbose # the whole chain
flask db downgrade -1 # back one revision
flask db revision -m "manual change" --autogenerate
flask db revision -m "data fix" # empty script to edit by handmigrations/
alembic.ini # generated, points at the app metadata
env.py # loads the Flask app, wires target_metadata
versions/
a1b2c3d4_add_post_published.py
b2c3d4e5_add_tags_table.pyflask db migratecomparesdb.metadata— the models your app imported — with the live database and writes the difference. It never changes data.flask db upgradeexecutes the scripts against the configured database, in revision order, recording progress in thealembic_versiontable.- Backends differ: PostgreSQL applies most DDL transactionally, MySQL does not. A failed migration on MySQL can leave a half-applied revision that needs manual repair.
- Rebuild the test database from the migration chain rather than from
create_all(), otherwise a migration that never worked stays invisible until deployment.
Reviewing and editing the generated script
# migrations/versions/c3d4e5f6_add_post_published.py
import sqlalchemy as sa
from alembic import op
revision = "c3d4e5f6"
down_revision = "b2c3d4e5"
branch_labels = None
depends_on = None
def upgrade():
# non-null column on an existing table: add it with a server default
op.add_column("posts", sa.Column("published", sa.Boolean(),
nullable=False, server_default=sa.false()))
op.create_index("ix_posts_published_created", "posts", ["published", "created_at"])
def downgrade():
op.drop_index("ix_posts_published_created", table_name="posts")
op.drop_column("posts", "published")def upgrade():
# data migration in the same script: run SQL, then tighten the constraint
op.execute("UPDATE posts SET slug = lower(replace(title, ' ', '-')) WHERE slug IS NULL")
op.alter_column("posts", "slug", nullable=False)
# or insert reference rows, with the table's real columns
op.bulk_insert(
sa.table("tags", sa.column("id", sa.Integer), sa.column("name", sa.String)),
[{"id": 1, "name": "flask"}, {"id": 2, "name": "python"}],
)| What you changed | What autogenerate gets wrong | Fix |
|---|---|---|
| Renamed a column | Emits drop_column + add_column, losing data | Replace with op.alter_column(..., new_column_name=...) |
| Renamed a table | Drop and create | op.rename_table() |
| Changed a type | A cast that fails on existing values | alter_column(..., type_=..., postgresql_using=...) |
| Added a non-null column | No server default, fails on a non-empty table | Add with server_default, backfill, then drop the default |
| Dropped a column | Removes it immediately | Two releases: stop using it, then drop it |
| New enum value | Often missed entirely | Hand-write the ALTER TYPE statement |
| Index or constraint | Random-looking autogenerated name | Set naming_convention on the metadata |
Branches, teams and production
# two developers migrated from the same parent -> two heads
flask db heads
flask db merge -m "merge tag and post branches" heads
# the database already matches the models (adopted an existing schema)
flask db stamp head
# what the release pipeline should do
flask db upgrade # schema, once, before new code serves traffic
flask db current # confirm the revision that is now liveflask db headsshowing more than one revision means the chain has branched; the app will refuse to start cleanly until it is merged with a merge revision.- Version the
migrations/directory in the same commit as the model change. A model edit without its script is a deploy that breaks the first query it makes. - Back up before a destructive migration, and test it against a restored copy of production data. A script that passes on an empty development database proves almost nothing about an index build on ten million rows.
⚠️
Run migrations as a release step, not from application start-up code. If every gunicorn worker runs
flask db upgrade at boot, four processes race to apply the same revision; the losers raise an error, or worse, two of them interleave DDL statements on a backend without transactional DDL. The release job applies the schema once, then the new code starts.FAQ
migrate produced an empty script. Why?
Autogenerate only sees models that have actually been imported before the comparison runs, and only the differences against the live database. If
env.py does not import your models module, target_metadata is empty and every table looks new; if the change was to a default in application code rather than a column, there is nothing to emit.Can I edit a migration that is already applied?
No. Applied revisions are records of history: editing one means a fresh database and an existing one will disagree. Write a new revision that changes what is wrong, and reserve edits for scripts that exist only on your unmerged branch.
Related
Databases with Flask-SQLAlchemy Deployment: gunicorn, nginx and Docker
Last refreshed 2026-09-18.