Cheatsheet for the schema-per-tenant pattern (Postgres). Alternative to RLS for stronger isolation.

Per-request session

async def get_tenant_db(tenant: Tenant = Depends(get_tenant)) -> AsyncSession:
    async with AsyncSessionLocal() as session:
        schema = f"tenant_{tenant.slug}"
        await session.execute(text(f"SET search_path TO {schema}, public"))
        yield session

search_path is set per session; transactions inherit.

Per-tenant onboarding

async def create_tenant(tenant_slug: str):
    async with engine.connect() as conn:
        await conn.execute(text(f"CREATE SCHEMA tenant_{tenant_slug}"))
    
    # Run migrations against the new schema
    cfg = Config("alembic.ini")
    cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
    # ... (multi-tenant env.py iterates or specific arg) ...
    command.upgrade(cfg, "head", arg=f"tenant_{tenant_slug}")

Or stamp the schema with current head + Base.metadata.create_all():

async with engine.begin() as conn:
    await conn.execute(text(f"SET search_path TO {schema}"))
    await conn.run_sync(Base.metadata.create_all)
command.stamp(cfg, "head")

env.py for schema-per-tenant

def run_migrations_online():
    schemas = get_all_tenant_schemas()
    
    for schema in schemas:
        connectable = engine_from_config(...)
        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,        # per-schema version table
            )
            with context.begin_transaction():
                context.run_migrations()
        connectable.dispose()

Each tenant’s schema has its own alembic_version table.

Limits

  • Postgres handles ~1000 schemas well. Beyond: catalog bloat.
  • All schemas migrate together (slow if many tenants).
  • Cross-tenant queries need explicit UNION.

Cross-tenant admin

async def get_admin_db() -> AsyncSession:
    async with AdminSessionLocal() as session:
        # Don't set search_path; explicit schema-qualified queries
        yield session

@app.get("/admin/all-users")
async def all_users(db = Depends(get_admin_db)):
    # ...UNION across tenant schemas...

Parallel migrations

import concurrent.futures

def migrate_one(schema):
    cfg = Config("alembic.ini")
    cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
    command.upgrade(cfg, "head", arg=schema)

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

Watch DB connection count.

When schema-per-tenant fits

  • 100-1000 tenants.
  • Need strong isolation but not full DB-per-tenant cost.
  • Each tenant has identical schema (changes propagate to all).

When NOT

  • <100 enterprise tenants with strict isolation/compliance → DB-per-tenant.
  • 1000+ tenants → shared DB + tenant_id + RLS.
  • Highly customized per-tenant schema → DB-per-tenant.

Common mistakes

  • One alembic_version table for all schemas — collapse.
  • Sequential migration of 1000 schemas — hours.
  • Forgetting SET search_path — queries hit public instead.
  • 10,000 schemas — Postgres catalog struggles.

Read this next

If you want my multi-schema env.py + onboarding script, it’s at rajpoot.dev .


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 .