Your BookStore API now has 200 customers who each want their own instance. Spinning up 200 separate deployments is not a business model — it is an operations nightmare. The answer is multi-tenancy: one application, one database, many tenants, total data isolation.
This is the post where BookStore becomes ShelfWise — a multi-tenant B2B SaaS platform for bookstores, publishers, and distributors. Each tenant gets their own catalog, their own orders, their own users. But they all share the same database, the same application server, and the same deployment pipeline.
The stakes are different now. A bug in a single-tenant app shows the wrong price. A bug in a multi-tenant app shows another company’s data. That is a career-ending, company-killing incident. The goal of this post is to make cross-tenant data leakage structurally impossible — not by relying on developers to remember WHERE tenant_id = :id, but by making the system enforce it automatically.
Tenant Identification: Where Does the Tenant Come From?
Every multi-tenant request starts with a question: which tenant is making this request? There are three common strategies, and the right one depends on your deployment model.
| Strategy | How It Works | Best For | Trade-Off |
|---|---|---|---|
| Subdomain | acme.shelfwise.io → tenant = acme | B2B SaaS with custom domains | DNS setup per tenant, wildcard certs |
| Header / API key | X-Tenant-ID: acme or API key lookup | API-first platforms, machine clients | Easy to spoof without auth layer |
| JWT claim | JWT contains tenant_id in payload | Apps with existing auth (OAuth, OIDC) | Tenant locked at token issuance |
ShelfWise uses JWT claims as the primary strategy — the tenant_id is embedded in the access token at login. The middleware extracts it and propagates it through the request lifecycle.
from dataclasses import dataclassfrom uuid import UUID
@dataclass(frozen=True, slots=True)class Tenant: """Immutable tenant context for the current request.""" id: UUID slug: str plan: str # "starter" | "growth" | "enterprise"Context Propagation with contextvars
The tenant must be available everywhere — in services, repositories, event handlers, background tasks — without passing it as a parameter through every function call. Python’s contextvars module solves this. It provides task-local storage that survives await boundaries and propagates correctly into asyncio.TaskGroup children.
from contextvars import ContextVarfrom uuid import UUID
from src.core.tenant import Tenant
_current_tenant: ContextVar[Tenant] = ContextVar("current_tenant")
def get_current_tenant() -> Tenant: """Return the tenant for the current request.
Raises LookupError if called outside a tenant context. This is intentional — code that accesses tenant data without a tenant context is a bug. """ try: return _current_tenant.get() except LookupError: raise RuntimeError( "No tenant context. This code path requires a tenant-scoped request." )
def set_current_tenant(tenant: Tenant) -> None: """Set the tenant for the current async context.""" _current_tenant.set(tenant)
def get_current_tenant_id() -> UUID: """Shortcut — most callers only need the ID.""" return get_current_tenant().idMiddleware: Extracting Tenant from Every Request
The FastAPI middleware runs before every request handler. It extracts the tenant from the JWT, sets the context variable, and ensures every downstream call has a tenant identity.
from uuid import UUID
from fastapi import Request, HTTPExceptionfrom starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpointfrom starlette.responses import Response
from src.core.context import set_current_tenantfrom src.core.tenant import Tenant
class TenantMiddleware(BaseHTTPMiddleware): """Extract tenant from JWT claims and propagate via contextvars."""
TENANT_FREE_PATHS = {"/health", "/docs", "/openapi.json"}
async def dispatch( self, request: Request, call_next: RequestResponseEndpoint ) -> Response: if request.url.path in self.TENANT_FREE_PATHS: return await call_next(request)
tenant = self._extract_tenant(request) set_current_tenant(tenant) return await call_next(request)
def _extract_tenant(self, request: Request) -> Tenant: # In production, decode and verify the JWT. # Simplified here to show the extraction pattern. claims = request.state.jwt_claims # set by auth middleware tenant_id = claims.get("tenant_id") tenant_slug = claims.get("tenant_slug") tenant_plan = claims.get("tenant_plan", "starter")
if not tenant_id or not tenant_slug: raise HTTPException( status_code=403, detail="Missing tenant context in access token", )
return Tenant( id=UUID(tenant_id), slug=tenant_slug, plan=tenant_plan, )The TenantMixin: Every Row Knows Its Owner
Every model that stores tenant-specific data inherits from TenantMixin. This adds a non-nullable tenant_id column with an index. The column is never optional, never has a default — it must be set explicitly or by the session event system.
from uuid import UUID
from sqlalchemy import ForeignKey, Indexfrom sqlalchemy.orm import Mapped, mapped_column, declared_attr
class TenantMixin: """Mixin that adds tenant_id to any SQLAlchemy model.
Every query on a tenant-scoped model MUST filter by tenant_id. The session event listener enforces this automatically. """
tenant_id: Mapped[UUID] = mapped_column( nullable=False, index=True, )
@declared_attr @classmethod def __table_args__(cls): # Composite index: tenant_id + id for fast tenant-scoped lookups return ( Index(f"ix_{cls.__tablename__}_tenant_id", "tenant_id"), {"extend_existing": True}, )Models use it by mixing it into their declaration:
from sqlalchemy.orm import Mapped, mapped_column
from src.db.base import Basefrom src.db.mixins import TenantMixin
class Book(TenantMixin, Base): __tablename__ = "books"
id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] = mapped_column(nullable=False) isbn: Mapped[str] = mapped_column(unique=False) # unique per tenant, not global price: Mapped[float] = mapped_column(nullable=False)Notice isbn is unique=False at the database level. In a multi-tenant system, uniqueness is per-tenant, not global. Two tenants can each have a book with the same ISBN. You enforce per-tenant uniqueness with a composite unique constraint: UniqueConstraint("tenant_id", "isbn").
Automatic Tenant Filtering: Making Leakage Structurally Impossible
This is the critical piece. Instead of trusting every developer to add WHERE tenant_id = :current to every query, the system does it automatically using SQLAlchemy’s event system.
from sqlalchemy import event, inspectfrom sqlalchemy.orm import Session, Query
from src.core.context import get_current_tenant_idfrom src.db.mixins import TenantMixin
def _is_tenant_model(mapper) -> bool: """Check if a model uses TenantMixin.""" return issubclass(mapper.class_, TenantMixin)
@event.listens_for(Session, "do_orm_execute")def _apply_tenant_filter(execute_state): """Intercept every ORM query and inject tenant_id filtering.
This fires on SELECT, UPDATE, and DELETE operations. INSERT is handled by _set_tenant_on_insert below. """ if execute_state.is_orm_statement: # Only filter if the query targets a tenant-scoped model stmt = execute_state.statement for column_desc in stmt.column_descriptions: entity = column_desc.get("entity") if entity is not None and issubclass(entity, TenantMixin): tenant_id = get_current_tenant_id() execute_state.statement = stmt.filter( entity.tenant_id == tenant_id )
@event.listens_for(Session, "before_flush")def _set_tenant_on_insert(session, flush_context, instances): """Auto-set tenant_id on new objects before they are flushed to the database.
This means services never need to manually set tenant_id — the system handles it. A developer literally cannot forget. """ tenant_id = get_current_tenant_id() for obj in session.new: if isinstance(obj, TenantMixin): if obj.tenant_id is None: obj.tenant_id = tenant_id elif obj.tenant_id != tenant_id: raise ValueError( f"Attempted to insert {type(obj).__name__} with tenant_id " f"{obj.tenant_id} in context of tenant {tenant_id}. " "This is a cross-tenant write violation." )The do_orm_execute event fires on every SELECT, UPDATE, and DELETE that goes through the ORM. It inspects the target entity, and if it uses TenantMixin, it appends WHERE tenant_id = :current_tenant_id. The developer writes session.execute(select(Book)) and the system produces SELECT * FROM books WHERE tenant_id = '...'.
The before_flush event handles INSERTs. When a new object is added to the session, the listener auto-sets its tenant_id from the context. If someone tries to insert an object with a different tenant’s ID, it raises immediately.
Row-Level vs Schema-Per-Tenant vs Database-Per-Tenant
Row-level isolation (what we just built) is the right starting point for most SaaS applications. Here is when to consider the alternatives:
| Row-Level Isolation | Schema-Per-Tenant | Database-Per-Tenant | |
|---|---|---|---|
| Data isolation | Application-enforced (session events + RLS) | PostgreSQL schema boundary | Full physical isolation |
| Tenant count | Unlimited — 10,000+ tenants, one table | Hundreds (schema proliferation) | Dozens (operational overhead) |
| Query complexity | Every tenant-scoped query adds WHERE clause | SET search_path per request | Separate connection per tenant |
| Migration complexity | One migration, one schema | Run migration per schema | Run migration per database |
| Compliance | Sufficient for most B2B SaaS | SOC 2, some HIPAA | HIPAA, FedRAMP, financial regulation |
| Cross-tenant queries | Easy (remove filter) | Requires cross-schema joins | Requires federated queries |
| Cost | Lowest — shared resources | Medium — schema overhead | Highest — separate infrastructure |
| Best for | ShelfWise starter/growth plans | Enterprise plan | Regulated industries |
ShelfWise uses row-level isolation for starter and growth plans. Enterprise tenants who need regulatory compliance get upgraded to schema-per-tenant — which is a deployment configuration change, not an architecture rewrite, because the TenantMixin and session events already enforce the boundary.
Cross-Tenant Operations: The SystemContext
Some operations must cross tenant boundaries — billing aggregation, usage reporting, admin dashboards, data migrations. These operations run in a SystemContext that explicitly bypasses tenant filtering with full audit logging.
# src/core/context.py (additions)from contextlib import asynccontextmanagerfrom collections.abc import AsyncIteratorimport loggingfrom uuid import UUID
from src.core.tenant import Tenant
logger = logging.getLogger("shelfwise.system")
_system_context: ContextVar[bool] = ContextVar("system_context", default=False)
def is_system_context() -> bool: """Check if the current context is a system (cross-tenant) context.""" return _system_context.get()
@asynccontextmanagerasync def system_context(reason: str, operator: str) -> AsyncIterator[None]: """Enter a system context that bypasses tenant filtering.
Every entry is audit-logged with the reason and operator. This is the ONLY way to perform cross-tenant operations. """ logger.warning( "Entering system context", extra={ "reason": reason, "operator": operator, "action": "system_context_enter", }, ) token = _system_context.set(True) try: yield finally: _system_context.reset(token) logger.info( "Exiting system context", extra={ "reason": reason, "operator": operator, "action": "system_context_exit", }, )Update the session event to check for system context:
# Updated _apply_tenant_filter@event.listens_for(Session, "do_orm_execute")def _apply_tenant_filter(execute_state): if is_system_context(): return # System context bypasses tenant filtering
if execute_state.is_orm_statement: stmt = execute_state.statement for column_desc in stmt.column_descriptions: entity = column_desc.get("entity") if entity is not None and issubclass(entity, TenantMixin): tenant_id = get_current_tenant_id() execute_state.statement = stmt.filter( entity.tenant_id == tenant_id )Usage in an admin endpoint:
from src.core.context import system_context
async def aggregate_monthly_billing(operator_email: str): async with system_context( reason="Monthly billing aggregation", operator=operator_email, ): # This query runs WITHOUT tenant filtering — returns all tenants result = await session.execute( select( Order.tenant_id, func.sum(Order.total).label("revenue"), ).group_by(Order.tenant_id) ) return result.all()The system_context is intentionally verbose. Entering it requires a reason and an operator identity. Every entry and exit is logged at WARNING level. In a well-run system, these log lines should be rare and auditable.
Integration Test: Proving Leakage Is Impossible
The most important test in a multi-tenant system is the one that proves Tenant A cannot see Tenant B’s data. This test is not a nice-to-have — it is the test that lets you sleep at night.
import pytestfrom uuid import uuid4
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from src.core.context import set_current_tenantfrom src.core.tenant import Tenantfrom src.db.base import Basefrom src.db.tenant_filter import _apply_tenant_filter, _set_tenant_on_insertfrom src.models.book import Book
TENANT_A = Tenant(id=uuid4(), slug="acme-books", plan="growth")TENANT_B = Tenant(id=uuid4(), slug="beta-reads", plan="starter")
@pytest.fixtureasync def session(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) factory = async_sessionmaker(engine) async with factory() as s: yield s await engine.dispose()
async def test_tenant_a_cannot_see_tenant_b_books(session): """The foundational isolation test. If this fails, nothing else matters."""
# Insert books as Tenant A set_current_tenant(TENANT_A) session.add(Book(title="Tenant A Book", isbn="9780000000001", price=10.0)) await session.flush()
# Insert books as Tenant B set_current_tenant(TENANT_B) session.add(Book(title="Tenant B Book", isbn="9780000000002", price=20.0)) await session.flush()
# Query as Tenant A — must see ONLY Tenant A's book set_current_tenant(TENANT_A) from sqlalchemy import select result = await session.execute(select(Book)) books = result.scalars().all()
assert len(books) == 1 assert books[0].title == "Tenant A Book" assert books[0].tenant_id == TENANT_A.id
async def test_tenant_cannot_insert_with_wrong_tenant_id(session): """Prevent a service from inserting data into another tenant's space.""" set_current_tenant(TENANT_A)
book = Book(title="Sneaky Book", isbn="9780000000003", price=15.0) book.tenant_id = TENANT_B.id # Explicitly set wrong tenant
session.add(book) with pytest.raises(ValueError, match="cross-tenant write violation"): await session.flush()
async def test_system_context_sees_all_tenants(session): """System context bypasses tenant filtering for admin operations.""" from src.core.context import system_context
# Insert as both tenants set_current_tenant(TENANT_A) session.add(Book(title="A Book", isbn="9780000000004", price=10.0)) await session.flush()
set_current_tenant(TENANT_B) session.add(Book(title="B Book", isbn="9780000000005", price=20.0)) await session.flush()
# System context sees everything async with system_context(reason="test", operator="test@shelfwise.io"): from sqlalchemy import select result = await session.execute(select(Book)) books = result.scalars().all() assert len(books) == 2The Migration from BookStore to ShelfWise
Adding multi-tenancy to an existing single-tenant application follows a repeatable pattern:
- Add
tenant_idcolumn to every table via Alembic migration (nullable initially, then backfill and set NOT NULL) - Create the tenant table to store tenant metadata
- Apply
TenantMixinto every model - Register session events at application startup
- Add middleware to extract tenant from requests
- Backfill
tenant_idfor existing data (assign all existing data to a “legacy” tenant) - Add composite unique constraints where single-column unique constraints existed (
isbnbecomes(tenant_id, isbn)) - Add RLS policies as defense in depth
The session events and context propagation handle 90% of the work. The remaining 10% is data migration — and that is a one-time cost.
Key Takeaways
contextvars.ContextVarpropagates tenant identity through the entire async call stack without parameter threading. It survivesawaitboundaries andTaskGroupchildren.- SQLAlchemy session events auto-inject
WHERE tenant_idon every SELECT, UPDATE, and DELETE. Developers cannot forget the filter because they never write it. before_flushauto-setstenant_idon INSERT and raises on cross-tenant write attempts. Accidental writes to another tenant’s data are caught before they reach the database.SystemContextis the only way to bypass filtering. It requires a reason and operator identity, and every entry is audit-logged.- Row-level isolation scales to thousands of tenants. Schema-per-tenant and database-per-tenant are upgrade paths for compliance requirements, not default architectures.
- The isolation integration test is non-negotiable. It runs in CI, it blocks releases, and it proves the system does what it promises.
BookStore is now ShelfWise. The data boundary is enforced by the system, not by developer discipline. In the next post, we tackle the configuration problem that multi-tenancy creates: 200 tenants each needing different rate limits, feature flags, and API keys — all without redeploying.