SaaS architecture
Django and PostgreSQL row-level security, measured
Setting the tenant for PostgreSQL row-level security in Django 5.2, measured: persistent connections, the 5.1 pool, ATOMIC_REQUESTS and middleware.
Row-level security in PostgreSQL needs to know the tenant, and the usual way to tell it is a setting on the connection: set_config('app.tenant', …). In Django that line goes into a middleware. Where exactly, and with which flag, decides whether the next request sees no rows, the right rows, or another customer’s rows — and part of the answer is not in the middleware at all, but in DATABASES.
We measured four middlewares against Django’s connection settings. With the default settings, a session-level tenant looked perfectly safe — only because Django opens a new connection for every request, at about 8 ms extra per request. Turn on persistent connections and a request without a tenant got the previous request’s customer; turn on Django 5.1’s connection pool and it got the customer of whichever request last used that connection — tenant 1’s 20 invoices, in three runs out of three. The transaction-local form in a plain middleware returned no rows at all, also with ATOMIC_REQUESTS. What worked everywhere was a middleware that opens the transaction itself — together with ATOMIC_REQUESTS, for a reason that only showed up when a view failed.
The setup
Django 5.2.17 with psycopg 3.3.6 and psycopg_pool 3.3.3, Python 3.12; PostgreSQL 17.10 in a throwaway container. Measured on 4 October 2026.
- One table
invoices, 20 rows for tenant 1 and 10 for tenant 2, row-level security forced, policyusing (tenant_id = nullif(current_setting('app.tenant', true), '')::int). Django connects as an ordinary role, not the table owner. - One view that returns
Invoice.objects.count()and the backend process ID. A middleware reads?tenant=and, if present, sets it. - Four requests in a row: tenant 1, no tenant, tenant 2, no tenant. “No tenant” stands for any path that does not set one — a public page, a health check, a view someone forgot.
- Requests go through Django’s WSGI handler, and each response is closed the way a WSGI server closes it. Not through Django’s test
Client: that one disconnects Django’s connection clean-up around each request, so the connection stays open, and with it every session-level variant leaked — including the default one.
What did the request without a tenant see?
| Middleware | DATABASES | Tenant 1 | No tenant | Tenant 2 | No tenant |
|---|---|---|---|---|---|
set_config(…, false) | default (CONN_MAX_AGE 0) | 20 | 0 | 10 | 0 |
set_config(…, false) | CONN_MAX_AGE 60 | 20 | 20 | 10 | 10 |
set_config(…, false) | OPTIONS: {"pool": True} | 20 | 0 | 10 | 20 |
set_config(…, true) | default | 0 | 0 | 0 | 0 |
set_config(…, true) | ATOMIC_REQUESTS | 0 | 0 | 0 | 0 |
transaction.atomic() + set_config(…, true) | default, CONN_MAX_AGE 60, or pool | 20 | 0 | 10 | 0 |
Rows counted per request. Bold is another customer’s data; italics is a customer who sees none of their own.
Why does the session-level setting leak?
Because the setting belongs to the database session, and Django reuses sessions as soon as you ask it to. With the default CONN_MAX_AGE of 0 — “closing the database connection at the end of each request”, in the Django documentation — every request got a new PostgreSQL backend, and a new backend has no tenant. With CONN_MAX_AGE = 60 all four requests ran on the same backend, and the requests without a tenant saw the last one that was set.
The pool spreads the problem out. psycopg_pool, which Django uses, hands out the connection that has been idle longest and keeps four by default, and Django configures no reset for it — on return the pool only rolls back an open transaction, which leaves a session-level setting in place. In our four requests the fourth got the backend of the first and counted tenant 1’s 20 invoices, three runs out of three. In a run of eight requests, the requests without a tenant got 0, 20, 0, 0, 10 and 20 rows: whatever the request four places earlier had left on that connection. With concurrent traffic that distance varies, so in production it looks random.
The pool does accept a reset. With "OPTIONS": {"pool": {"reset": lambda conn: conn.execute("RESET ALL")}} the same eight requests counted 0 whenever no tenant was set. That closes this leak; it does not fix the middleware below.
Why does set_config(…, true) return nothing?
Because Django runs in autocommit. set_config(…, true) — like SET LOCAL — lasts until the end of the current transaction, and in autocommit the middleware’s statement is its own transaction. It ended the moment it ran; the view’s query, a separate transaction, saw no tenant and no rows.
ATOMIC_REQUESTS = True does not change that, and the documentation says why: “only the execution of your view is enclosed in the transactions. Middleware runs outside of the transaction”. The tenant was set and gone before the view’s transaction began.
What works?
A middleware that opens the transaction itself and sets the tenant inside it:
from django.db import connection, transaction
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
tenant = resolve_tenant(request) # from the session, the host name, a token …
with transaction.atomic():
if tenant is not None:
with connection.cursor() as cursor:
cursor.execute("select set_config('app.tenant', %s, true)", [str(tenant)])
return self.get_response(request)
The setting ends with the transaction, so the next request on the same connection starts without one. It gave the right rows and no leak with the default settings, with CONN_MAX_AGE = 60 and with the pool. After a view that raised an exception, the next request without a tenant also counted 0.
It does mean every request runs in one transaction — but not the way ATOMIC_REQUESTS does it. Django turns an exception in the view into a 500 response before it reaches this middleware, so atomic() sees a normal return and commits. A view that inserted an invoice and then raised kept the invoice: tenant 1 counted 21 afterwards. With ATOMIC_REQUESTS = True as well, the view runs in its own savepoint inside the middleware’s transaction; the same failing view then left nothing behind, and the tenant was still set. So turn both on.
The usual costs of a transaction per request apply: a view that calls a slow external service holds the transaction open for that long, row locks are held until the response, and transaction.on_commit callbacks run only at the end of the request. A streaming response produces its body after the middleware’s transaction has ended, so queries inside it see no tenant. The middleware covers only what runs below it, so place it after the session and authentication middleware it needs to resolve the tenant. Views that need to work across tenants — an admin overview, a nightly job — need their own route around the policy; the ways that go wrong are in row-level security in PostgreSQL: where tenants leak.
What does it cost?
Average over 300 requests alternating between tenant 1 and tenant 2, two runs each:
| Configuration | Per request |
|---|---|
default, CONN_MAX_AGE 0, session-level setting | 8.9–9.2 ms |
CONN_MAX_AGE 60, session-level setting | 0.89 ms |
CONN_MAX_AGE 60, transaction middleware | 1.08–1.09 ms |
| pool, session-level setting | 0.99–1.02 ms |
| pool, transaction middleware | 1.15–1.17 ms |
The default configuration is safe because it opens a new connection for every request, and that cost about 8 ms per request here, on one machine with the database next door; over a network it is more. The transaction middleware costs 0.15–0.2 ms over the leaking variants. That is the price of the right answer.
What we did not measure
Django behind PgBouncer in transaction mode — we expect the same rules as for any client, measured in PgBouncer and row-level security — async views, multiple databases, Celery workers (a worker that serves every tenant is in PostgreSQL job workers: retries, crashes, tenant context), and libraries such as django-tenants, which separate tenants by schema instead of by policy. The same question for SQLAlchemy, where the tenant goes into a session event instead of a middleware, is measured in SQLAlchemy and PostgreSQL row-level security. When a policy does not return what you expect, the checks are in debugging row-level security in PostgreSQL. The timings come from one machine and are for comparison.
Sources: the Django 5.2 documentation on database transactions (ATOMIC_REQUESTS, middleware outside the transaction) and on databases (CONN_MAX_AGE, the connection pool new in 5.1), read on 4 October 2026. All measurements are our own, on Django 5.2.17 and PostgreSQL 17.10, on 4 October 2026.