Technical Article
Django Multi-Tenant SaaS and FastAPI: Two Tenancy Models
Two tenancy models for Python, django-tenants or a tenant column, and the fail-closed filters, Celery handling and tests that make either one safe.
Python multi-tenant SaaS starts with a choice the two big frameworks push you towards differently. Django has a mature package, django-tenants, built on a PostgreSQL schema per tenant. FastAPI has no tenancy story at all, so you build it on SQLAlchemy and a context variable. Underneath, the rules are the same ones I run in production on a Laravel platform: one place holds the tenant, an unknown tenant is refused, a missing tenant fails closed, and background jobs carry the tenant with them. My multi-tenant platforms are Laravel, so this article is the translation of those rules into Python, with the mechanism I would use in each framework. The order those decisions come in is covered in SaaS platform architecture: the decisions that come first.
Two tenancy models, two frameworks
Python gives you two well-trodden paths, and they are different tenancy models, not just different libraries:
| Schema per tenant | Shared tables, tenant column | |
|---|---|---|
| Typical Python stack | Django with django-tenants | FastAPI with SQLAlchemy, or Django with a custom manager |
| Isolation enforced by | PostgreSQL’s search path, set per request | Your query filter, plus row-level security if you add it |
| Migrations | Run once per tenant schema | Run once |
| Cross-tenant reporting | A query per schema | One query |
| What fails if you forget | Queries hit the shared schema | Queries return every tenant’s rows, unless the filter fails closed |
All four platforms I run or have built use shared tables with a tenant column, so that is the model I can speak to from experience. Schema per tenant is a sound choice when tenants need stronger separation or per-tenant customisation of the schema, and django-tenants makes it the easy path in Django.
Django multi-tenant with django-tenants: schema per tenant
django-tenants gives each tenant its own PostgreSQL schema. Its middleware finds the tenant from the request’s host name and points the database connection at that tenant’s schema, so ordinary Django queries need no tenant filter. Apps are split into shared apps, which live in the public schema (the tenant and domain tables, for example), and tenant apps, which are created in every tenant schema; migrate_schemas runs the migrations across all of them.
Three things to decide early if you take this path:
- What an unknown host does. Make it a 404, never a fall back to the public schema or a default tenant, which is the same rule my Laravel platform enforces with a test.
- Which models are shared. Anything in a shared app is visible to every tenant, so the split between shared and tenant apps is a security decision, not just an organisational one.
- How migrations scale. Each migration runs once per tenant schema, so a platform with many tenants needs a plan for long or failed migration runs.
FastAPI multi-tenant with SQLAlchemy: a filter on every ORM query
FastAPI leaves tenancy to you, and SQLAlchemy 2.0 has the hook for it. The do_orm_execute event sees every ORM statement a session runs, and with_loader_criteria adds a condition for every entity that inherits from a mixin, including relationship loads. SQLAlchemy’s documentation shows the pattern for a global filter; the tenant version reads the tenant from the session and raises when there isn’t one.
from sqlalchemy import event
from sqlalchemy.orm import Mapped, Session, mapped_column, with_loader_criteria
class TenantOwned:
tenant_id: Mapped[int] = mapped_column(index=True)
@event.listens_for(Session, 'do_orm_execute')
def _scope_to_tenant(state):
if not state.is_select or state.execution_options.get('across_tenants', False):
return
tenant_id = state.session.info.get('tenant_id')
if tenant_id is None:
raise RuntimeError('ORM query without a tenant.')
state.statement = state.statement.options(
with_loader_criteria(TenantOwned, lambda cls: cls.tenant_id == tenant_id, include_aliases=True)
)The tenant sits in session.info, set by a FastAPI dependency that resolves the tenant from the host and yields a session for it. That keeps the tenant attached to the thing that runs the queries, and it avoids a subtle trap: a context variable set inside a sync dependency is set in a worker thread’s copy of the context, so it is not a reliable way to hand the tenant to the rest of the request. The host lookup itself runs with across_tenants=True, the visible opt-out.
Two gaps to close separately. This filter covers ORM reads; inserts should be stamped and checked in a before_insert mapper event on the mixin, which is the Python version of my Laravel trait’s create rule. And Core statements or raw SQL bypass it, which is where PostgreSQL row-level security helps: the policy and the per-transaction setting are the same in Python as in the Node.js version.
Celery: pass the tenant, bind it first
A Celery task runs in a worker with no request, so it has no tenant unless it is given one. Pass the tenant id as a task argument and bind it before the task does anything else. A task called without a tenant then hits the fail-closed filter on its first query instead of running across every tenant.
@app.task
def send_invoice(tenant_id: int, invoice_id: int) -> None:
with tenant_session(tenant_id) as session:
invoice = session.get(Invoice, invoice_id)
...
# Queued from a request, with the tenant made explicit:
send_invoice.delay(request_tenant_id, invoice.id)Explicit arguments are more verbose than a hidden header, but they show up in the task’s signature, its logs and its retries, which is where you want to see them when a job misbehaves.
The tests to write first
These mirror the tenancy tests on my Laravel platform, adjusted for Python:
- Querying a tenant-owned model with no tenant raises.
- Fetching another tenant’s row by id returns nothing, in a view and in a task.
- An unknown host returns 404 and never reaches a default tenant or the public schema.
- Following each relation from a tenant’s row never reaches another tenant’s data.
- A Celery task runs against the tenant it was given, and fails when it was given none.
- For django-tenants: a model in a tenant app is not visible from another tenant’s schema.
Where to go next
The same rules in their original form, with the code and tests from a live platform, are in Laravel multi-tenancy. Who may do what inside a tenant is covered in multi-tenant access control. For an outside review of a Python platform’s tenancy, see SaaS architecture.
References
Evidence and further reading
All insights →Facing a similar problem?
Platform & Integration Review
For SaaS founders and engineering leads whose platform is getting slower and riskier to change.
Request an assessmentLet's build what's next
Have a complex system that needs to be built right?
Whether you are starting from an idea, replacing an existing platform, or scaling a system, let's talk.
Better Technology.
Brighter Possibilities.

