Cheatsheet for multi-tenant SQLAlchemy. Three strategies; pick by isolation needs and cost.
Strategies
| Model | Isolation | Cost | When |
|---|---|---|---|
| Shared DB + tenant_id | Logical | Lowest | < 1k tenants, B2C-ish |
| Schema-per-tenant | Stronger | Medium | < 1k mid-size B2B |
| DB-per-tenant | Strongest | Highest | < 100 enterprise / compliance |
Shared DB + tenant_id
class TenantMixin:
tenant_id: Mapped[int] = mapped_column(index=True)
class User(TenantMixin, Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str]
__table_args__ = (
Index("ix_users_tenant_email", "tenant_id", "email", unique=True),
)
Composite indexes — always include tenant_id first.
Tenant context via contextvar
import contextvars
current_tenant: contextvars.ContextVar[int | None] = contextvars.ContextVar("tenant", default=None)
Auto-filter via event
@event.listens_for(Session, "do_orm_execute")
def tenant_filter(execute_state):
tid = current_tenant.get()
if tid is None or execute_state.execution_options.get("bypass_tenant"):
return
execute_state.statement = execute_state.statement.options(
with_loader_criteria(TenantMixin, lambda cls: cls.tenant_id == tid, include_aliases=True)
)
Every SELECT filtered automatically.
Set tenant on insert
@event.listens_for(Session, "before_flush")
def set_tenant_on_insert(session, flush_context, instances):
tid = current_tenant.get()
if tid is None: return
for obj in session.new:
if isinstance(obj, TenantMixin) and obj.tenant_id is None:
obj.tenant_id = tid
FastAPI middleware
@app.middleware("http")
async def tenant_middleware(request: Request, call_next):
tid = await resolve_tenant(request)
token = current_tenant.set(tid)
try:
return await call_next(request)
finally:
current_tenant.reset(token)
RLS layer (Postgres) — defense in depth
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_iso ON users
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);
@event.listens_for(engine.sync_engine, "before_cursor_execute")
def set_rls(conn, cur, stmt, params, ctx, executemany):
tid = current_tenant.get()
if tid is not None:
cur.execute(f"SET LOCAL app.tenant_id = '{tid}'")
App must not connect as BYPASSRLS role. See Postgres-focused cheatsheets
.
Schema-per-tenant
class Settings(BaseSettings):
db_url: str
async def get_tenant_db(tenant: Tenant = Depends(get_tenant)) -> AsyncSession:
async with AsyncSessionLocal() as session:
await session.execute(text(f"SET search_path TO tenant_{tenant.slug}, public"))
yield session
Each tenant: its own schema. Migrations run per schema (multi-tenant Alembic env.py).
DB-per-tenant
import functools
@functools.lru_cache(maxsize=1000)
def tenant_engine(dsn: str):
return create_async_engine(dsn, pool_size=2, max_overflow=4)
async def get_tenant_db(tenant: Tenant = Depends(get_tenant)) -> AsyncSession:
engine = tenant_engine(tenant.dsn)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
yield session
Cap engine cache; close on shutdown.
Cross-tenant admin access
async def get_admin_db():
async with AdminSessionLocal() as session:
await session.execute(text("SET LOCAL ROLE app_admin")) # BYPASSRLS role
yield session
@app.get("/admin/all-users", dependencies=[Depends(require_role("admin"))])
async def admin_list(db = Depends(get_admin_db)):
return (await db.execute(select(User))).scalars().all()
CI test for tenant isolation
async def test_tenant_isolation(db_session):
# Tenant 1
current_tenant.set(1)
db_session.add(User(email="[email protected]"))
await db_session.commit()
# Tenant 2 must not see it
current_tenant.set(2)
rows = (await db_session.execute(select(User).where(User.email == "[email protected]"))).scalars().all()
assert rows == []
Common mistakes
- Missing
tenant_idfilter in custom raw SQL — cross-tenant leak. - Unique constraints without
tenant_id— collisions across tenants. - Connecting as BYPASSRLS role from app — defeats RLS.
- Per-tenant engines without cap — connection explosion.
- Schema-per-tenant with > 1000 schemas — Postgres catalog bloat.
Read this next
If you want my tenant-context + RLS + admin role pattern, 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 .