Tutorial

Clean Code Python: Multi-Tenant Foundation — Context, Isolation, and the Data Boundary

Your BookStore API now has 200 customers who each want their own data. One missing WHERE clause leaks tenant data and kills the company. Here is how to make cross-tenant data leakage structurally impossible with context propagation, session events, and row-level isolation.

Tin Dang avatar
Tin Dang
Multiple isolated compartments in an industrial storage facility each containing separate inventory

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.

StrategyHow It WorksBest ForTrade-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.

src/core/tenant.py
from dataclasses import dataclass
from 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.

src/core/context.py
from contextvars import ContextVar
from 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().id

Middleware: 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.

src/middleware/tenant.py
from uuid import UUID
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response
from src.core.context import set_current_tenant
from 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.

src/db/mixins.py
from uuid import UUID
from sqlalchemy import ForeignKey, Index
from 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:

src/models/book.py
from sqlalchemy.orm import Mapped, mapped_column
from src.db.base import Base
from 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.

src/db/tenant_filter.py
from sqlalchemy import event, inspect
from sqlalchemy.orm import Session, Query
from src.core.context import get_current_tenant_id
from 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 IsolationSchema-Per-TenantDatabase-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 asynccontextmanager
from collections.abc import AsyncIterator
import logging
from 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()
@asynccontextmanager
async 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:

src/api/admin/billing.py
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.

tests/integration/test_tenant_isolation.py
import pytest
from uuid import uuid4
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from src.core.context import set_current_tenant
from src.core.tenant import Tenant
from src.db.base import Base
from src.db.tenant_filter import _apply_tenant_filter, _set_tenant_on_insert
from 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.fixture
async 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) == 2

The Migration from BookStore to ShelfWise

Adding multi-tenancy to an existing single-tenant application follows a repeatable pattern:

  1. Add tenant_id column to every table via Alembic migration (nullable initially, then backfill and set NOT NULL)
  2. Create the tenant table to store tenant metadata
  3. Apply TenantMixin to every model
  4. Register session events at application startup
  5. Add middleware to extract tenant from requests
  6. Backfill tenant_id for existing data (assign all existing data to a “legacy” tenant)
  7. Add composite unique constraints where single-column unique constraints existed (isbn becomes (tenant_id, isbn))
  8. 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.ContextVar propagates tenant identity through the entire async call stack without parameter threading. It survives await boundaries and TaskGroup children.
  • SQLAlchemy session events auto-inject WHERE tenant_id on every SELECT, UPDATE, and DELETE. Developers cannot forget the filter because they never write it.
  • before_flush auto-sets tenant_id on INSERT and raises on cross-tenant write attempts. Accidental writes to another tenant’s data are caught before they reach the database.
  • SystemContext is 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.

0

Next in this series

Clean Code Python: Configuration, Feature Flags, and Secrets That Scale

Continue reading