DH
13 min read

Multi-Tenant Architecture in a Django + FastAPI Stack: Schema, Row, and Database Isolation Trade-offs

Row, schema, or database isolation: trade-offs, migration costs, and how to choose the right PostgreSQL strategy before it's too late.

djangopostgressecurity

Your second customer signs up, and your architecture stops being optional. The moment two organizations share one running application, you have made a decision — consciously or not — about where their data boundary lives. Get it wrong and you're either leaking Customer A's invoices to Customer B, or you're paying for a fleet of databases you don't need. Get it right early and it's a non-event. Get it wrong and it's a six-month migration with a moratorium on new features while you fix it.

Direct answer: there are three ways to isolate tenant data in PostgreSQL — shared schema with row filtering, one schema per tenant, and one database per tenant. As isolation model moves from row-level filtering toward database-per-tenant, operational complexity and per-tenant infrastructure cost typically increase. Migration cost between models is asymmetric — moving from row-level to schema-level filtering is painful, moving from schema-level to database-level is comparatively mechanical. Most SaaS products should start with shared schema and row-level tenant filtering, and move to schema-per-tenant only when a specific tenant demands stronger isolation or your row-filtering discipline is breaking down under scale. Few products ever need database-per-tenant, and the ones that do usually know it from a compliance requirement, not from growth.

The rest of this article works through why that's the answer, what each model costs you in practice, and — because you're running Django and FastAPI against the same PostgreSQL instance — exactly how to enforce the same tenant boundary from two different frameworks without one of them quietly bypassing it.

What This Article Teaches

By the end you'll be able to:

  • Name the three PostgreSQL isolation models and explain what each one actually isolates
  • Compare them on isolation strength, migration cost, operational complexity, and per-tenant cost
  • Implement tenant scoping correctly in a Django ORM request path
  • Implement tenant scoping correctly in a FastAPI request path against the same database
  • Close the gap where two frameworks touching one database can disagree about tenant boundaries
  • Apply a scale-based rule for choosing a model now, and for migrating later

The Business Problem Behind Multi-Tenancy

Multi-tenant architecture is the practice of running a single application instance and a single (or shared) database infrastructure to serve multiple customers — tenants — while keeping each tenant's data invisible to every other tenant. It's not a feature you build; it's a property your data layer either has or doesn't.

Here's why this becomes unavoidable so fast: a B2B SaaS product with one customer doesn't need tenancy logic at all — every row belongs to that customer, full stop. The instant a second customer signs up, every query, every background job, every export, and every webhook handler needs an answer to the question "whose data is this?" If that answer isn't enforced structurally, it's enforced by discipline — meaning a developer has to remember to add WHERE tenant_id = ? to every query, forever, across every code path, in two frameworks. Discipline doesn't scale. Structure does.

This is the real problem multi-tenancy solves: it moves the tenant boundary from "something engineers remember" to "something the architecture enforces." The three models differ in where that enforcement lives — in application code, in the database schema, or in the database engine itself — and that placement decision is what drives every trade-off that follows.

Three Approaches, Three Trade-Off Profiles

1. Shared Database, Shared Schema

Every tenant's rows live in the same tables, distinguished by a tenant_id (or organization_id) column. A customers table has one schema and rows from every tenant interleaved in it. Isolation is enforced entirely at the query layer: every SELECT, UPDATE, and DELETE must be scoped by tenant, either by application code adding the filter or by PostgreSQL's row-level security (RLS) enforcing it at the engine level regardless of what the query looks like.

This is the cheapest model to run and the cheapest to build. One connection pool, one set of migrations, one set of indexes to tune. Per-tenant cost approaches zero because tenants share every resource — CPU, memory, disk, connections. It's also the model with the weakest structural isolation: a missing WHERE clause in a new endpoint doesn't throw an error, it silently returns another tenant's data. RLS closes most of that gap by making the database itself refuse to return rows outside the current tenant context even if application code forgets to filter — but RLS has to be configured correctly and tested, not just switched on and trusted.

Where it fits: early-stage SaaS, most B2B tools, anything where tenants are numerous, individually small, and don't have contractual isolation requirements.

2. Schema-Per-Tenant

Same PostgreSQL database, but each tenant gets its own schema — its own namespace of tables. Application code sets a search_path for the current request, and from that point on, SELECT * FROM customers transparently hits that tenant's customers table. There's no tenant_id column and no risk of a forgotten filter, because there's nothing to filter — the tables themselves are separate.

This buys real isolation without the operational weight of separate databases: backups, connection pooling, and infrastructure are still shared. The costs show up elsewhere. Migrations that used to be one ALTER TABLE become one ALTER TABLE run against every tenant schema — with a hundred tenants that's a hundred DDL operations instead of one, and DDL in PostgreSQL takes locks. As tenant count grows, this migration fan-out becomes the dominant operational cost of the model: what was an instant schema change at ten tenants becomes a maintenance-window event at a thousand. PostgreSQL also has practical ceilings on how many schemas (and the catalog bloat that comes with them) a single database instance handles comfortably before catalog queries and connection setup start to slow down — a constraint worth respecting rather than testing in production.

Where it fits: mid-scale SaaS with a moderate, slower-growing tenant count, or products where some tenants explicitly pay for stronger isolation than "just a WHERE clause."

3. Database-Per-Tenant

Each tenant gets a fully separate PostgreSQL database — potentially on separate infrastructure entirely. This is the strongest isolation available short of physically separate hardware: a bug, a runaway query, or a noisy-neighbor tenant in one database cannot touch another tenant's database at all. It's also the most expensive to run in every dimension — connection management multiplies per tenant, migrations must be orchestrated across N databases, backups and monitoring must be per-tenant-aware, and infrastructure cost scales roughly linearly with tenant count rather than being amortized across them.

Where it fits: a small number of large, high-value tenants — often ones with a regulatory or contractual requirement for physical data separation — not a general-purpose default.

Comparing the Three Models

DimensionShared DB, Shared SchemaSchema-per-TenantDatabase-per-Tenant
Isolation strengthWeakest (query-layer only, unless RLS enforced)Strong (namespace-level separation)Strongest (full engine-level separation)
Migration cost (schema changes)Lowest — one operation, applies to all tenantsHighest at scale — one operation per tenant schemaHighest in orchestration — one operation per tenant database
Operational complexityLowest — single connection pool, single set of backupsModerate — search_path management, per-schema migration toolingHighest — per-tenant infra, connection, and backup management
Per-tenant costLowest — resources shared across all tenantsLow-to-moderate — shared infra, but catalog and migration overhead grows with tenant countHighest — resources dedicated per tenant
Accidental cross-tenant leak riskPresent if filtering is manual; largely closed by RLSLow — no shared tables to leak acrossLowest — no shared engine at all

Read this table as a single axis, not three independent choices: every unit of isolation strength you buy, you pay for in migration friction, operational surface area, or dollars. There is no model that's simply "better" — there's only the model that matches your current tenant count and isolation requirements.

The Accidental Cross-Tenant Leak Risk

The scariest failure mode in multi-tenant systems isn't a database going down — it's a database working perfectly and quietly returning the wrong tenant's rows. In the shared-schema model, this happens when a new endpoint, a background job, an admin tool, or a raw SQL migration script queries a table without the tenant filter. Nothing crashes. The response just contains data it shouldn't.

Each model mitigates this differently:

  • Shared schema mitigates it by moving the filter out of "developer remembers to write it" and into either a single ORM-level default filter (still application-enforced, still forgettable in raw queries) or PostgreSQL row-level security (engine-enforced, survives a forgotten WHERE clause). RLS is the meaningfully stronger mitigation here — it's worth the setup cost.
  • Schema-per-tenant mitigates it structurally: there's no cross-tenant table to accidentally query, because the search_path determines which physical table a bare query name resolves to. The residual risk shifts to connection reuse — if a connection's search_path isn't reset between requests from different tenants, you get the same leak through a different door.
  • Database-per-tenant removes the risk almost entirely, at the cost of removing the ability to easily query across tenants at all (which is sometimes a feature you actually need, for admin dashboards or cross-tenant analytics).

Tenant Scoping in the Django Request Path

In Django, the cleanest implementation of shared-schema tenancy is a piece of middleware that resolves the tenant early in the request lifecycle and a base model or manager that applies the filter everywhere.

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

def __call__(self, request):
tenant_id = resolve_tenant_from_subdomain_or_token(request)
request.tenant_id = tenant_id
# For RLS: set the tenant on the DB session for this connection
with connection.cursor() as cursor:
cursor.execute("SET app.current_tenant = %s", [tenant_id])
return self.get_response(request)
# models.py
class TenantScopedManager(models.Manager):
def get_queryset(self):
tenant_id = get_current_tenant_id() # thread-local, set by middleware
return super().get_queryset().filter(tenant_id=tenant_id)

class Invoice(models.Model):
tenant_id = models.UUIDField(db_index=True)
objects = TenantScopedManager()

The manager is the application-level belt. The SET app.current_tenant call, paired with a PostgreSQL RLS policy on the table, is the suspenders — it means even a raw Invoice.objects.raw(...) call or a stray admin query still can't cross the boundary, because the database itself refuses rows outside the session's tenant context.

Tenant Scoping in the FastAPI Request Path

FastAPI doesn't have Django's middleware-plus-manager pattern, but dependency injection gives you the same guarantee more explicitly, which is arguably safer because it's harder to forget in a route signature than in an ORM call.

from fastapi import Depends, Request

async def get_tenant_id(request: Request) -> str:
return resolve_tenant_from_subdomain_or_token(request)

async def get_tenant_db(tenant_id: str = Depends(get_tenant_id)):
async with SessionLocal() as session:
await session.execute(text("SET app.current_tenant = :tid"), {"tid": tenant_id})
yield session

@app.get("/invoices")
async def list_invoices(db: AsyncSession = Depends(get_tenant_db)):
result = await db.execute(select(Invoice)) # RLS enforces tenant scope
return result.scalars().all()

Every route that depends on get_tenant_db gets a session that's already scoped for the current tenant before a single query runs. Routes that forget the dependency simply don't get a database session at all — a much safer failure mode than "gets a session and forgets to filter it."

The Shared Django + FastAPI Concern

Running Django and FastAPI against the same PostgreSQL database is common — Django for the admin-heavy CRUD core, FastAPI for a performance-sensitive API surface or async-heavy service. The danger is that you now have two independent code paths that each need to resolve and enforce the same tenant boundary, and nothing guarantees they agree.

The fix is to stop treating tenant enforcement as an application-layer concern owned separately by each framework, and push it down to the one thing they both share: the PostgreSQL connection. Row-level security policies live in the database, not in Django's ORM or FastAPI's session logic — so as long as both frameworks set the same session variable (app.current_tenant in the examples above) before running queries, the enforcement is identical regardless of which framework issued the query. Django's middleware and FastAPI's dependency are two different mechanisms for doing the exact same thing: setting session-local tenant context before the first query of the request.

This is also the strongest argument for RLS over pure application-level filtering in a two-framework stack. Application-level filtering means you have to audit two codebases for correctness. RLS means you have to audit one database policy — and both frameworks inherit it automatically, including from raw SQL, migration scripts, and any third framework you bolt on later.

The Decision Rule

Choose based on tenant count and isolation requirements, not on architectural taste:

  • Start with shared database, shared schema plus RLS if you're pre-scale or growing toward dozens-to-hundreds of tenants with no individual tenant demanding contractual data separation. This is the right default for nearly every new B2B SaaS.
  • Migrate to schema-per-tenant when either (a) your row-level filtering discipline is producing near-misses even with RLS as a backstop, or (b) a specific enterprise tenant contractually requires stronger-than-row isolation and you're not ready to give them a dedicated database. Do this before schema-management tooling becomes unmanageable, not after — the migration fan-out problem only gets worse with more tenants.
  • Migrate to database-per-tenant only for tenants where the requirement is explicit — regulatory, contractual, or a customer paying specifically for physical isolation. Don't reach for this as a default scaling strategy; the per-tenant cost and orchestration burden are the highest of the three models and usually unjustified by pure growth in tenant count.

The asymmetry worth remembering: shared-schema-to-schema-per-tenant is a real migration — you're moving rows into new namespaces and rewriting connection logic. Schema-per-tenant-to-database-per-tenant is comparatively mechanical, because you've already done the hard part of thinking in per-tenant units. That asymmetry is itself an argument for starting simple: the cheap model isn't a trap, it's a foundation the more isolated models build on.

FAQ

Can I mix models — some tenants in shared schema, others with their own database? Yes, and it's a common pattern once you have a handful of high-value tenants with special requirements. Keep the default path (shared schema plus RLS) for most tenants and route specific tenant IDs to dedicated infrastructure at the connection-resolution layer — the same get_tenant_db / middleware pattern shown above just needs to branch on tenant tier before choosing a connection.

Does row-level security hurt query performance? RLS adds a policy check to every query, but the bigger performance lever in the shared-schema model is almost always your tenant_id index, not the RLS policy itself. Index the column, and RLS overhead is rarely the bottleneck worth optimizing first.

Do I need multi-tenancy if I'm building a single-tenant on-prem product? No — if each customer gets a fully separate deployment and database by default, you already have database-per-tenant isolation without needing any of the shared-infrastructure machinery described here.

Damian Hodgkiss

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps