Flask SQLAlchemy Best Practices 2026: Migrations and Zero-Downtime Deploys

Devops & Infrastructure, Python, Tips & Tricks, and Tutorials

Flask SQLAlchemy Best Practices 2026: Migrations and Zero-Downtime Deploys

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, 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.emailusers.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 emailemail_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.

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:

  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 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 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.

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


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.