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_versiontable for all schemas — collapse. - Sequential migration of 1000 schemas — hours.
- Forgetting
SET search_path— queries hitpublicinstead. - 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 .