Every SaaS application is a promise: "Your data is yours, and only yours."
Every Django developer who's built a multi-tenant app knows how fragile that promise actually is.
I'll be honest about how I got here: I was building a Django application and I was, let's say, a bit overambitious. Instead of shipping an MVP first and worrying about multitenancy later, I convinced myself that thousands of customers were right around the corner and I needed to solve tenant isolation now. Classic over-engineering move. I know.
But that premature optimization turned into a genuine education. I started evaluating the options, and each one gave me a different kind of anxiety:
- Separate databases per tenant? Terrible. Connection management alone would be a nightmare.
- Schema-per-tenant? Better, but I kept reading about migration times growing linearly and PostgreSQL catalog bloat at scale. That didn't feel like something I wanted to sign up for.
- Application-level filtering with custom managers? The cleanest option on the surface. But one question kept nagging me: what happens when I forget?
And I would forget. I know myself. A management command here, a Celery task there, a quick raw SQL query to debug something in production -- any of these could silently bypass my carefully constructed ORM filters and return data from every tenant in the system.
That question -- "what happens when I forget?" -- led me down the path of PostgreSQL Row-Level Security, and eventually to building django-rls-tenants. A library that moves tenant isolation from "something your application code must remember to do" to "something your database enforces whether you remember or not."
This post is about why I think that shift matters, how PostgreSQL makes it possible, and when it's the right (and wrong) choice for your project.
The Multitenancy Problem Nobody Talks About Honestly
If you're building a SaaS product with Django, you're almost certainly building a multi-tenant application. Multiple customers share the same application instance, the same database, the same tables. The only thing separating Acme Corp's invoices from Globex's invoices is a tenant_id column and your team's diligence in never forgetting to filter by it.
Let's be blunt about how that usually goes.
Here's a typical Django view in a multi-tenant app:
@login_required
def invoice_list(request):
invoices = Invoice.objects.filter(tenant=request.user.tenant)
return render(request, "invoices/list.html", {"invoices": invoices})
Looks fine. Now here's the same model being used in a Celery task:
@shared_task
def send_overdue_reminders():
overdue = Invoice.objects.filter(
due_date__lt=timezone.now(),
status="unpaid",
)
for invoice in overdue:
send_reminder_email(invoice)
See the problem? There's no tenant filter. This task processes invoices across every tenant. If it's just sending emails, maybe that's intentional. But what if the task also generates a report and attaches it? Now you've got a tenant receiving a PDF that contains another tenant's data.
This isn't a contrived example. This is the default outcome whenever you write code that doesn't explicitly filter by tenant. The safe behavior has to be opted into, every single time, in every single query.
This Isn't a Hypothetical Risk
Insecure Direct Object Reference (IDOR) -- which is exactly what a missing tenant filter amounts to -- is one of the most commonly reported vulnerabilities on bug bounty platforms. OWASP moved Broken Access Control to the #1 position in their 2021 Top 10, noting that 94% of applications tested had some form of broken access control.
In the multi-tenant SaaS world specifically, tenant data leaks have hit companies of all sizes. The pattern is almost always the same: an API endpoint, background job, or admin tool that queries the database without proper tenant scoping. Not because the developers were careless, but because the architecture made it possible to forget.
The fundamental issue is that application-level filtering is an opt-in safety mechanism. Every ORM query, every raw SQL statement, every management command, every data migration -- all of them start with access to everything and must explicitly narrow down. The failure mode is "leak all data." The success mode requires perfect discipline across every developer, every commit, every line of code, forever.
That's not a security model. That's a hope.
How Django Developers Solve This Today
There are two mainstream approaches, and both have real tradeoffs.
Approach 1: Schema-Per-Tenant (django-tenants)
django-tenants gives each tenant their own PostgreSQL schema. Tenant A's data lives in schema_a.invoices, Tenant B's in schema_b.invoices. The library routes each request to the correct schema by setting search_path on the database connection.
What's good about it:
- Strong isolation -- different tenants literally have different tables
- Mature library with a large user base
- Supports per-tenant schema customization (different tenants can have different database structures)
Where it hurts:
-
Migrations scale linearly. Got 500 tenants? Your
migratecommand runs 500 times. It's not uncommon to see reports of deploys taking 45+ minutes just running migrations. -
PostgreSQL catalog bloat. Each schema means a separate set of entries in
pg_class,pg_attribute, and other system catalogs. At thousands of tenants,\dtinpsqlbecomes unusably slow, and the planner can struggle. -
Connection pooling gets complicated. Each connection is pinned to a schema via
search_path, which fights with PgBouncer's transaction-mode pooling. You either need session-mode pooling (less efficient) or careful workarounds. - Operational complexity. Backup, restore, monitoring -- everything has to account for N schemas instead of one.
For 10-50 tenants, schema-per-tenant works well. At 500+, the operational burden is significant.
Approach 2: Application-Level Filtering (django-multitenant, Custom Managers)
The other approach keeps everything in a single schema and filters at the application level. django-multitenant (designed for Citus) rewrites ORM queries to inject tenant filters. Many teams build their own version with custom managers and middleware.
What's good about it:
- Single schema, standard migrations, simple operations
- Works well with ORMs -- queries look clean
The fundamental problem:
Application-level filtering only protects the paths it knows about. Here's an incomplete list of things that bypass it:
-
Raw SQL:
cursor.execute("SELECT * FROM invoices")-- your custom manager isn't involved - Management commands: No request, no middleware, no automatic scoping
-
Data migrations:
apps.get_model("myapp", "Invoice").objects.all()-- uses the historical model, not your custom manager -
dbshell:
python manage.py dbshell-- you're talking directly to PostgreSQL - Third-party packages: Any library that queries your models directly (analytics, export tools, admin panels) might not use your filtered manager
-
Signals and hooks:
post_savehandlers that query related models might not have tenant context
The safety of your entire system depends on every code path going through the filtered manager. That's a big "every."
The Comparison at a Glance
| Schema-Per-Tenant | App-Level Filtering | Database-Enforced (RLS) | |
|---|---|---|---|
| Raw SQL protected? | Yes (via search_path) | No | Yes |
| dbshell protected? | Partially (must set search_path) | No | Yes |
| Management commands? | Must route to correct schema | Must add filtering manually | Yes |
| Migration complexity | Runs N times (once per tenant) | Single run | Single run |
| 1000+ tenant scaling | Catalog bloat, slow migrations | Fine | Fine |
| Fail mode on missing context | Wrong schema (error or wrong data) | All data returned | Zero rows returned |
That last row is the one I care about most. When something goes wrong with application-level filtering, the failure mode is returning data from all tenants. When something goes wrong with RLS, the failure mode is returning nothing. An empty page is embarrassing. A data leak is a breach.
What PostgreSQL Row-Level Security Actually Does
Row-Level Security (RLS) has been in PostgreSQL since version 9.5 (released in 2016). It's not new, not experimental, and not an extension -- it's a core feature of the database. Yet it seems like most Django developers haven't heard of it, or think it's only for edge cases.
Here's the basic idea: you attach a policy to a table that defines which rows a given session can see. The database enforces this policy on every query -- SELECT, INSERT, UPDATE, DELETE -- regardless of whether the query comes from an ORM, raw SQL, a migration, or someone typing directly into psql.
A Simple Example
Let's say you have an invoices table with a tenant_id column. You want each database session to only see rows belonging to a specific tenant. Here's how that looks in plain SQL:
-- Step 1: Tell the database session which tenant we are
SELECT set_config('rls.current_tenant', '42', false);
-- Step 2: Query the table normally
SELECT * FROM invoices;
-- Returns ONLY rows where tenant_id = 42
That's it. No WHERE clause. No filter. The database itself appends the filter to every query against this table.
How does the database know to do this? Because you've defined a policy:
-- Enable RLS on the table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
-- Create the policy
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('rls.current_tenant', true)::integer);
Let's break this down:
-
ENABLE ROW LEVEL SECURITYturns on the RLS mechanism for this table. -
FORCE ROW LEVEL SECURITYensures the policy applies even to the table owner. Without this, the user who owns the table would bypass all policies. -
CREATE POLICY ... USING (...)defines the visibility rule. TheUSINGclause is evaluated for every row: if it returnstrue, the row is visible; iffalse, the row doesn't exist as far as the query is concerned. -
current_setting('rls.current_tenant', true)reads a session variable (called a GUC in PostgreSQL terminology). Thetrueparameter means "return empty string instead of error if the variable isn't set."
The Fail-Closed Guarantee
Here's the thing that makes RLS fundamentally different from application-level filtering. What happens when nobody has set the rls.current_tenant variable?
-- No set_config() call -- tenant variable is empty
SELECT * FROM invoices;
-- Returns: 0 rows
The policy evaluates tenant_id = NULL (because the empty string is cast to NULL), which is always false in SQL. No rows match. No data is returned.
This is the key insight: RLS is fail-closed by default. Forgetting to set the context doesn't give you access to everything -- it gives you access to nothing. The accidental management command, the raw SQL query, the forgotten Celery task -- they all get zero rows instead of every tenant's data.
The Full Policy (for the Curious)
In practice, you want a slightly more sophisticated policy that also supports admin access (for cross-tenant operations like analytics or data migrations). Here's what that looks like:
Click to expand the full policy SQL
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY invoices_tenant_isolation_policy ON invoices
-- USING clause: controls which rows are visible (SELECT, UPDATE, DELETE)
USING (
-- Match the current tenant
tenant_id = COALESCE(
NULLIF(current_setting('rls.current_tenant', true), '')::integer,
NULL
)
-- OR allow admin access (full bypass)
OR COALESCE(
current_setting('rls.is_admin', true) = 'true',
false
)
)
-- WITH CHECK clause: controls which rows can be written (INSERT, UPDATE)
WITH CHECK (
tenant_id = COALESCE(
NULLIF(current_setting('rls.current_tenant', true), '')::integer,
NULL
)
OR COALESCE(
current_setting('rls.is_admin', true) = 'true',
false
)
);
The USING clause filters reads, the WITH CHECK clause validates writes. The COALESCE(NULLIF(..., ''), NULL) chain handles the "variable not set" case gracefully, collapsing it to NULL which makes the equality check fail. The admin flag provides an escape hatch for legitimate cross-tenant operations.
Introducing django-rls-tenants
All of the above is standard PostgreSQL. You could implement it yourself with raw SQL migrations and a middleware that calls set_config(). But there's a lot of subtle detail to get right: policy generation, GUC management, connection lifecycle, testing, the interaction with Django's migration framework, connection pooling safety...
django-rls-tenants handles all of that. Here's how it works in practice.
1. Define Your Model
from django_rls_tenants import RLSProtectedModel
class Invoice(RLSProtectedModel):
title = models.CharField(max_length=200)
amount = models.DecimalField(max_digits=10, decimal_places=2)
due_date = models.DateField()
status = models.CharField(max_length=20, default="unpaid")
class Meta(RLSProtectedModel.Meta):
ordering = ["-due_date"]
That's it. No tenant ForeignKey declared -- the library adds it automatically via Django's class_prepared signal. No custom manager boilerplate -- RLSProtectedModel sets up RLSManager as the default manager. And crucially, the RLSConstraint in the abstract model's Meta.constraints generates the full RLS policy during migrate.
When you run python manage.py migrate, the library creates the table, enables RLS, forces RLS, and creates the tenant isolation policy. All in a single, standard migration.
2. Configure the Settings
# settings.py
INSTALLED_APPS = [
# ...
"django_rls_tenants",
# ... your apps
]
RLS_TENANTS = {
"TENANT_MODEL": "myapp.Tenant", # your tenant model
"TENANT_FK_FIELD": "tenant", # FK field name (auto-added)
"GUC_PREFIX": "rls", # PostgreSQL variable prefix
"TENANT_PK_TYPE": "int", # or "bigint" or "uuid"
"USE_LOCAL_SET": False, # True for connection pooling
}
MIDDLEWARE = [
# ...
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django_rls_tenants.RLSTenantMiddleware", # after auth
# ...
]
3. Implement the TenantUser Protocol
The library needs to know two things about a user: which tenant they belong to, and whether they're an admin who should see all tenants. You implement this via a simple protocol:
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
tenant = models.ForeignKey(
"myapp.Tenant",
on_delete=models.CASCADE,
null=True, # null for admin users
)
is_tenant_admin = models.BooleanField(default=False)
@property
def rls_tenant_id(self):
return self.tenant_id
Two properties: is_tenant_admin and rls_tenant_id. That's the entire contract. You don't need to inherit from any special base class -- it's structural typing via Python's Protocol.
4. Write Views Without Thinking About Tenant Filtering
And here's the payoff. This is what your views look like:
@login_required
def invoice_list(request):
invoices = Invoice.objects.all() # no .filter(tenant=...)
return render(request, "invoices/list.html", {"invoices": invoices})
No tenant filter. Invoice.objects.all() returns only the current tenant's invoices, because the middleware has already told PostgreSQL which tenant this session belongs to. The database does the filtering.
And here's the management command from earlier -- the one that was a data leak risk:
from django_rls_tenants import tenant_context
class Command(BaseCommand):
def handle(self, *args, **options):
for tenant in Tenant.objects.all():
with tenant_context(tenant.pk):
overdue = Invoice.objects.filter(
due_date__lt=timezone.now(),
status="unpaid",
)
self.stdout.write(f"Tenant {tenant.name}: {overdue.count()} overdue")
The tenant_context context manager sets the PostgreSQL session variable for the duration of the block. If someone forgets to wrap their queries in a tenant_context? Zero rows. Not all rows -- zero rows.
How It Flows
HTTP Request
|
v
AuthenticationMiddleware --> sets request.user
|
v
RLSTenantMiddleware --> reads user.rls_tenant_id
| calls SET rls.current_tenant = '42'
| calls SET rls.is_admin = 'false'
v
Your View --> Invoice.objects.all()
|
v
PostgreSQL --> applies RLS policy automatically
| WHERE tenant_id = 42
v
Response (only tenant 42's invoices)
The middleware sets two PostgreSQL session variables: rls.current_tenant (the tenant ID) and rls.is_admin (whether to bypass tenant filtering). After the response is sent, both variables are cleared. There's even a safety net: a request_finished signal handler clears the variables if the middleware's process_response doesn't run for any reason.
Defense in Depth
One design decision worth highlighting: when you use the for_user() queryset method, the library applies both a Django ORM .filter(tenant_id=...) and the RLS GUC variable:
invoices = Invoice.objects.for_user(request.user)
# This does TWO things:
# 1. ORM: .filter(tenant_id=user.rls_tenant_id) -- Django-level
# 2. GUC: SET rls.current_tenant = '42' -- PostgreSQL-level
Either layer alone would be sufficient for isolation. Together, they provide defense in depth: even if one mechanism has a bug, the other catches it.
Context Managers for Everything Else
Not everything runs in an HTTP request. For Celery tasks, management commands, scripts, or tests, the library provides context managers:
from django_rls_tenants import tenant_context, admin_context
# Scope to a specific tenant
with tenant_context(tenant_id=42):
orders = Order.objects.all() # only tenant 42's orders
# Admin access (e.g., for cross-tenant analytics)
with admin_context():
all_orders = Order.objects.all() # all tenants
These nest correctly (saving and restoring previous GUC state), work in async code, and clean up properly even if exceptions are raised.
Testing That RLS Actually Works
The library ships with test utilities to verify your RLS setup:
from django_rls_tenants.tenants.testing import (
rls_bypass,
rls_as_tenant,
assert_rls_enabled,
assert_rls_policy_exists,
assert_rls_blocks_without_context,
)
def test_invoices_are_isolated(tenant_a, tenant_b):
# Verify the policy exists
assert_rls_enabled("myapp_invoice")
assert_rls_policy_exists("myapp_invoice")
# Verify fail-closed behavior
assert_rls_blocks_without_context(Invoice)
# Verify tenant isolation
with rls_as_tenant(tenant_a.pk):
assert Invoice.objects.count() == 5 # only tenant_a's invoices
with rls_as_tenant(tenant_b.pk):
assert Invoice.objects.count() == 3 # only tenant_b's invoices
There's also a management command for CI pipelines:
$ python manage.py check_rls
Checking RLS status for 3 models...
myapp_invoice ......... OK
myapp_order ........... OK
myapp_document ........ OK
All 3 models have RLS properly configured.
When NOT to Use This Approach
I've spent this entire post arguing for RLS, so let me be equally honest about when it's not the right fit.
You need per-tenant schema customization. If different tenants have different database structures (different columns, different tables), RLS on a shared schema won't work. Schema-per-tenant is the right choice here.
You're not on PostgreSQL. RLS is a PostgreSQL feature. If you're on MySQL, SQLite, or another database, this entire approach doesn't apply. (Though honestly, if you're building a serious multi-tenant SaaS app, PostgreSQL is probably the right choice anyway.)
You have very few tenants (< 5) and they won't grow. If you're building an internal tool with 3 known customers, schema-per-tenant with django-tenants is simpler and the scaling downsides won't bite you. Use the simplest solution that works.
You need cross-database compatibility. If your application must run on multiple database backends, you can't rely on PostgreSQL-specific features. The library requires PostgreSQL 15+.
Your database user is a superuser. PostgreSQL superusers bypass all RLS policies. If your application connects to PostgreSQL as a superuser (common in development but a bad practice in production), RLS won't protect anything. The library's example project includes a docker/init-db.sql that creates a non-superuser role specifically for this reason.
Getting Started
If you want to try it out, the fastest path is the example project:
git clone https://github.com/dvoraj75/django-rls-tenants.git
cd django-rls-tenants/example
docker compose up
This starts a PostgreSQL database and a Django app with 3 tenants, 4 users, and sample data. Open http://localhost:8000, log in as different users, and watch how each user only sees their tenant's data -- even though the views use Note.objects.all() with no tenant filter.
For adding it to your own project:
pip install django-rls-tenants
The quickstart guide walks through the full setup in about 10 minutes. The full documentation covers configuration, context managers, bypass mode, testing, connection pooling, and migration guides if you're coming from django-tenants or django-multitenant.
The library supports Python 3.11-3.14, Django 4.2-6.0, and PostgreSQL 15+. It's MIT licensed and has a CI matrix that tests across all supported Python and Django versions.
Wrapping Up
The core argument of this post is simple: tenant isolation is too important to enforce only in application code.
Application-level filtering is like a door with a lock that only works if everyone remembers to close it. PostgreSQL RLS is a door that locks itself, every time, regardless of who walks through.
If you're building a multi-tenant Django application on PostgreSQL, I think database-enforced isolation should be your default. Not because application-level filtering can't work -- it can and does, in thousands of production applications. But because the failure mode of "return nothing" is so fundamentally safer than "return everything" that it's worth the (minimal) additional setup.
Give django-rls-tenants a look. If you have questions or feedback, open a discussion on GitHub. I'd genuinely love to hear what you think.
django-rls-tenants is open-source and MIT licensed. If you find it useful, a GitHub star helps others discover it.
Top comments (0)