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 legacy `db.Column(...)` style still works but won't get further development; `mypy` and `pyright` understand `Mapped[]` natively.
- **`datetime.utcnow` is deprecated in Python 3.12+**. Use `datetime.now(timezone.utc)` and store timezone-aware datetimes.
- **`SQLALCHEMY_TRACK_MODIFICATIONS = False`** prevents 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](https://alembic.sqlalchemy.org/en/latest/autogenerate.html#what-does-autogenerate-detect-and-what-does-it-not-detect), 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`:

1. **Deploy 1 (expand)**: migration adds `email_address` column (nullable), application code writes to both `email` and `email_address`, reads from `email`.
2. **Backfill** : one-shot script copies `email` → `email_address` for existing rows.
3. **Deploy 2 (cut over)**: application reads from `email_address`, still dual-writes.
4. **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](https://www.deployhq.com/features/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](https://www.deployhq.com/features/build-pipelines) 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:

1. **Migrations run once, not per-server.** If you deploy to multiple app servers, run `flask db upgrade` against the database from exactly one place — typically the first server in your deploy zone. DeployHQ's [deployment zones](https://www.deployhq.com/features/deployment-targets) let you target a single server for the migration step and all servers for the code release.
2. **`systemctl reload`, not `restart`** , when your app server supports it. Gunicorn re-execs workers gracefully; `restart` drops in-flight requests. Pair this with [automatic Git-triggered deployments](https://www.deployhq.com/features/automatic-deployments) so a push to `main` runs the whole pipeline.
3. **Idempotent migrations.** `flask db upgrade` is 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](https://www.deployhq.com/blog/python-application-servers-in-2026-from-wsgi-to-modern-asgi-solutions).

## 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:

1. **For additive deploys** : keep the old code release on the server (DeployHQ retains previous releases by default) and revert with [one-click rollback](https://www.deployhq.com/features/one-click-rollback). The schema is forward-compatible, so the old code still works.
2. **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.
3. **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 than `downgrade`. 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 away after 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](https://www.deployhq.com/blog/database-migration-strategies-zero-downtime-deployments) — the multi-language version of the expand-contract walkthrough above.
- [Flyway database migrations in 2026](https://www.deployhq.com/blog/flyway-database-migrations-2026) — if you want a SQL-first alternative to Alembic.
- [How to handle database changes during a deployment](https://www.deployhq.com/blog/how-to-handle-database-changes-during-a-deployment) — sequencing schema and code changes safely.
- [SQLite vs PostgreSQL vs MySQL](https://www.deployhq.com/blog/sqlite-vs-postgresql-vs-mysql-choosing-the-right-database) — picking the right engine; the migration tooling assumes you've already made this call.
- [Deploy Django on a budget with Hetzner and DeployHQ](https://www.deployhq.com/blog/how-to-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](https://www.deployhq.com/deploy-from-github), per-server SSH commands, and one-click rollback give you the building blocks. The Alembic discipline is on you.

[Start your free](https://www.deployhq.com/signup)[DeployHQ](https://www.deployhq.com) trial to wire up Flask migrations as part of your deploy pipeline, or [see pricing](https://www.deployhq.com/pricing) for team plans.

* * *

Questions, corrections, or a Flask deploy war story to share? Email us at [support@deployhq.com](mailto:support@deployhq.com) or find us on [X](https://x.com/deployhq).

