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.

Share this article

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 tenantShared 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
The two models. My own platforms use the second, for the reasons in the pillar article.

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.

Shared tables in Django: a manager that raises

If you keep shared tables in Django, the equivalent of my Laravel global scope is a default manager that filters by the current tenant and raises when there isn’t one. The tenant lives in a ContextVar, which works for both sync and async views, set by middleware and reset when the request ends.

A fail-closed tenant manager and the middleware that binds the tenant.
from contextvars import ContextVar

from django.db import models
from django.http import Http404

current_tenant: ContextVar[int | None] = ContextVar('current_tenant', default=None)


class TenantManager(models.Manager):
    def get_queryset(self):
        tenant_id = current_tenant.get()
        if tenant_id is None:
            raise RuntimeError(f'{self.model.__name__} queried without a tenant.')
        return super().get_queryset().filter(tenant_id=tenant_id)


class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        tenant = Domain.objects.filter(host=request.get_host().lower()).values_list('tenant_id', flat=True).first()
        if tenant is None:
            raise Http404('Unknown site.')
        token = current_tenant.set(tenant)
        try:
            return self.get_response(request)
        finally:
            current_tenant.reset(token)

Give tenant-owned models a second, explicitly named manager for platform code, such as all_tenants = models.Manager(), so the opt-out is searchable in the way acrossTenants() is in my Laravel code. And know one limit: Django uses the model’s base manager, not your default manager, when it follows a relation like invoice.customer, so relations are not filtered by this manager. Foreign keys within one tenant’s data keep that safe, and a test that creates rows in two tenants and follows each relation proves it.

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.

A fail-closed tenant filter for SQLAlchemy 2.0, based on the global-criteria pattern in its documentation.
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.

A Celery task that binds the tenant before touching the database.
@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.

Share this article

References

  1. django-tenants documentation
  2. SQLAlchemy 2.0: Adding global WHERE / ON criteria
  3. Python documentation: contextvars

Evidence and further reading

All insights →
InsightSaaS Platform Architecture: The Decisions That Come FirstSeven decisions a multi-tenant platform has to make before features, in the order they bind each other, with what each one cost on four platforms I run.

Facing a similar problem?

Platform & Integration Review

For SaaS founders and engineering leads whose platform is getting slower and riskier to change.

Request an assessment

Let'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.