Skip to content

Starlette Multi-Tenancy

Resolve the request tenant in Starlette inside the route or in authentication middleware, then fetch a tenant-bound session with await db_plugin.get_session_async(request). Read the Tenant Request Contract for the precedence and failure rules shared by every framework, and create the account tables from Account Tables and User Lookup SQL.

How Starlette Resolves the Tenant

When a configuration sets driver_features={"tenant_strategy": "rls"}, Starlette defers connection acquisition until the route asks for a session. The route (or middleware that runs before it) verifies membership and calls set_request_tenant(request, org_id); pass key= with a session, connection, or bind key when several configurations are registered. Routes that never ask for a session never check out a tenant connection. Request transaction finalization (commit_mode) runs under the captured tenant identity before the connection lease resets.

Single-Database Example

Verify membership with a lookup session opened by config.provide_session(bypass_tenant=True), call set_request_tenant(...), and then fetch the tenant session:

from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route

from sqlspec.adapters.asyncpg import AsyncpgConfig
from sqlspec.extensions.starlette import SQLSpecPlugin, set_request_tenant

config = AsyncpgConfig(
    connection_config={"dsn": "postgresql://tenant_app@localhost/app"},
    driver_features={"tenant_strategy": "rls"},
    extension_config={"starlette": {"commit_mode": "autocommit"}},
)


async def list_projects(request: Request) -> JSONResponse:
    user = request.scope.get("user")
    if user is None:
        return JSONResponse({"detail": "Authentication required"}, status_code=401)
    org_id = request.path_params["org_id"]
    async with config.provide_session(bypass_tenant=True) as lookup:
        allowed = await lookup.select_one_or_none(
            "SELECT 1 FROM public.user_org_memberships "
            "WHERE user_id = :uid AND org_id = :org",
            uid=user.id,
            org=org_id,
        )
    if allowed is None:
        return JSONResponse({"detail": "Forbidden organization"}, status_code=403)
    set_request_tenant(request, org_id)
    session = await db_plugin.get_session_async(request)
    rows = await session.select("SELECT id, name FROM public.projects")
    return JSONResponse(rows)


app = Starlette(routes=[Route("/orgs/{org_id}/projects", list_projects)])
db_plugin = SQLSpecPlugin(config, app)

request.scope["user"] holds the user your authentication middleware verified.

Upfront Extractor

When ASGI authentication middleware populates scope["user"] before SQLSpec runs, set tenant_extractor in extension_config["starlette"] so routes can fetch sessions without calling set_request_tenant(...). Starlette passes the ASGI scope; the extractor may be sync or async:

from typing import Any

from sqlspec.adapters.asyncpg import AsyncpgConfig


def verified_tenant(scope: dict[str, Any]) -> str | None:
    user = scope.get("user")
    if user is None or not getattr(user, "is_authenticated", False):
        return None
    selected = getattr(user, "default_org", None)
    return selected if selected in getattr(user, "org_ids", ()) else None


config = AsyncpgConfig(
    connection_config={"dsn": "postgresql://tenant_app@localhost/app"},
    driver_features={"tenant_strategy": "rls"},
    extension_config={
        "starlette": {
            "tenant_extractor": verified_tenant,
            "commit_mode": "autocommit",
        }
    },
)

A set_request_tenant(...) receipt still takes precedence over the extractor. See Upfront Tenant Extractors for the rules every extractor follows.

Public and Bypass Routes

Login and health routes that never fetch a session skip tenant checkout entirely. When a public route needs the database before a tenant exists, mark it with tenant_bypass from sqlspec.extensions.starlette; its session skips SQLSpec's tenant session setup while database RLS policies stay active:

from sqlspec.extensions.starlette import tenant_bypass


@tenant_bypass
async def health(request: Request) -> JSONResponse:
    session = await db_plugin.get_session_async(request)
    return JSONResponse({"ok": await session.select_value("SELECT 1")})

Streaming and Background Tasks

Starlette holds the request lease open through StreamingResponse bodies and BackgroundTask completion, and the tenant resolved for the request stays in effect for both. Sessions opened inside them with config.provide_session() use that tenant. For work that outlives the request, pass the verified tenant identifier to the worker and open config.provide_session(tenant=tenant_id) there; see Tenant Operations.

Sync and Async Accessors

Every request connection, tenant or not, is checked out on first access. With an async configuration, db_plugin.get_session(request) returns a session that opens the connection, under the request tenant, on its first awaited call, so await session.select(...), async for row in session.select_stream(...), and async with session.transaction(): work unchanged. Reading session.connection before that first call raises ImproperConfigurationError; call await db_plugin.get_session_async(request) to open the connection immediately. A raw connection has no deferred form, so use await db_plugin.get_connection_async(request) for async configurations; db_plugin.get_connection(request) returns it only once it is open.