Chapter 6: Alembic for apps with multiple databases or schema-per-tenant. Setup, migration strategies, common patterns.

When this matters

  • App with multiple databases (e.g., users DB + analytics DB).
  • Schema-per-tenant SaaS.
  • DB-per-tenant SaaS.
  • Sharded databases.

Multi-database template

alembic init -t multidb migrations

env.py iterates databases:

# alembic.ini
[alembic]
databases = users, analytics

[users]
sqlalchemy.url = postgresql://...
script_location = migrations/users

[analytics]
sqlalchemy.url = postgresql://...
script_location = migrations/analytics

Per-database migrations. Each gets its own version table.

Multi-DB migrations

alembic --name users upgrade head
alembic --name analytics upgrade head

Or wrap in a script that iterates.

For consistency across DBs (e.g., shared user_id column): keep schemas decoupled where possible.

Schema-per-tenant

Each tenant has its own Postgres schema. One migration must run on every schema.

env.py:

def run_migrations_online():
    schemas = get_tenant_schemas()  # from your app
    
    for schema in schemas:
        connectable = create_engine(...)
        with connectable.connect() as conn:
            conn.execute(text(f"SET search_path TO {schema}"))
            context.configure(
                connection=conn,
                target_metadata=target_metadata,
                version_table_schema=schema,
            )
            with context.begin_transaction():
                context.run_migrations()
        connectable.dispose()

Iterates schemas; runs each migration against each.

Per-tenant version tables

Each schema gets its own alembic_version table:

context.configure(
    connection=conn,
    version_table_schema=schema,  # places the table in the tenant's schema
)

Without this: all tenants share one version table; can’t migrate independently.

DB-per-tenant

Each tenant: its own database. Iterate databases:

def run_for_all_tenants():
    for tenant in get_tenants():
        connectable = create_engine(tenant.connection_string)
        with connectable.connect() as conn:
            context.configure(connection=conn, target_metadata=target_metadata)
            with context.begin_transaction():
                context.run_migrations()
        connectable.dispose()

For 100s of tenants: takes minutes / hours. Parallelize.

Parallel migrations

import concurrent.futures

def migrate_one(tenant):
    # ...

with concurrent.futures.ThreadPoolExecutor(max_workers=10) as ex:
    list(ex.map(migrate_one, tenants))

Watch DB connection counts; don’t blow past limits.

Onboarding a new tenant

For schema-per-tenant on tenant signup:

async def create_tenant(tenant_name):
    async with engine.connect() as conn:
        await conn.execute(text(f"CREATE SCHEMA tenant_{tenant_name}"))
        # Run migrations for the new schema
        cfg = Config("alembic.ini")
        cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
        # ... configure for the schema ...
        command.upgrade(cfg, "head")

Or stamp the schema with current head and don’t run all historical migrations:

command.stamp(cfg, "head")

Combine with Base.metadata.create_all() to bootstrap the tables.

Migrations that don’t apply to all tenants

Sometimes a migration is per-tenant; sometimes global (shared schema).

def upgrade():
    if should_run_for_this_tenant():
        op.add_column("users", ...)

Or split into per-tenant and global migration tracks.

Sharded databases

Each shard runs the same migration. Iterate shards.

For consistency: snapshot the version state before; run all shards; verify after.

Watch for partial failures: some shards migrated; others not. Rerun safely.

Multi-app monorepo

app1/
  migrations/
  alembic.ini
app2/
  migrations/
  alembic.ini
shared/
  models.py

Each app has its own Alembic. Shared models in a separate package; each app’s target_metadata references it.

For consistency: align migration cadence and avoid one app changing shared schemas without coordination.

CI for multi-DB

- run: alembic --name users upgrade head
- run: alembic --name analytics upgrade head
- run: alembic --name users history
- run: alembic --name analytics history

Test each DB separately.

Drift detection per DB

For each DB:

def test_no_drift_users(engine):
    with engine.connect() as conn:
        ctx = MigrationContext.configure(conn)
        diff = compare_metadata(ctx, UsersBase.metadata)
        assert diff == [], f"users drift: {diff}"

Common mistakes

1. One alembic_version table for all tenants

Each tenant’s state collapses. Use version_table_schema per-tenant.

2. Sequential migrations on 1000 tenants

Hours. Parallelize.

3. Schema-per-tenant + 10000 tenants

Postgres struggles with thousands of schemas. Cap at hundreds; otherwise consider RLS instead.

4. Sharing alembic.ini across apps

One app’s revision affects another. Separate envs.

5. Manual schema creation drift

If new tenants get tables via Base.metadata.create_all() and existing tenants migrate via Alembic, they can drift. Use Alembic for both.

What’s next

Chapter 7: CI integration.

Read this next


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .