Connection Pooling¶
When using a connection pooler like PgBouncer or pgpool-II, or Django's own connection pool, special care is needed to prevent GUC variables from leaking between requests through pooled connections. Django's pool has its own section, Django's Connection Pool. For which combinations are safe under ASGI, see Async Deployment.
The Problem¶
By default, django-rls-tenants uses set_config() to set session-scoped GUC variables.
These persist for the lifetime of the PostgreSQL session. With a connection pooler
in transaction mode, multiple Django requests may share the same PostgreSQL session,
and GUC values from one request could leak to the next.
The Solution: USE_LOCAL_SET¶
Enable USE_LOCAL_SET to use SET LOCAL instead of set_config():
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "myproject",
"HOST": "pgbouncer-host",
"PORT": "6432",
"OPTIONS": {
# PgBouncer connection
},
}
}
RLS_TENANTS = {
"TENANT_MODEL": "myapp.Tenant",
"USE_LOCAL_SET": True,
}
How SET LOCAL Differs¶
| Behavior | set_config() (default) |
SET LOCAL |
|---|---|---|
| Scope | Session (connection) | Transaction |
| Cleared when | Explicitly by middleware | Automatically at COMMIT/ROLLBACK |
| Connection pooling safe | Only with cleanup | Yes |
| Transaction per request | No (autocommit around the view) | Synchronous path (WSGI): yes, opened by the middleware. Async path (ASGI): no (see below) |
| Outside a transaction | Written once, kept on the session | Each statement run under a context gets a transaction of its own (1.4.0+) |
| Nesting support | Full save/restore | Full save/restore with SET LOCAL |
Context managers restore the previous values when each block exits in both modes.
With SET LOCAL, transaction end also discards the transaction-local values. See
Context Managers for how
restoration works after a failed statement or savepoint rollback.
Where the Transaction Comes From¶
SET LOCAL only works inside a transaction. What supplies it depends on how the code
runs.
WSGI requests. Django's ATOMIC_REQUESTS is not the answer: it wraps only the view
callable, and the middleware sets the GUCs before the view runs. So when
USE_LOCAL_SET=True, RLSTenantMiddleware opens transaction.atomic() itself, on every
alias in RLS_TENANTS["DATABASES"], around the rest of the middleware chain and the view.
The GUCs are in force for the whole request and disappear at the commit or rollback that
ends it. Unauthenticated requests set no GUCs and get no transaction.
ATOMIC_REQUESTS=True remains optional. If you enable it as well, Django's per-view
transaction simply nests as a savepoint inside the middleware's.
The middleware's async path (ASGI, when every middleware listed after it can run
asynchronously; changed in 1.4.0). It cannot hold a synchronous transaction.atomic()
open across the awaited chain, so it opens no transaction and writes no GUC. The
GUC affinity guard gives each statement
the request's GUCs instead (see
Code Outside a Request Transaction), or once per
transaction for a sync view under ATOMIC_REQUESTS (Django does not support
ATOMIC_REQUESTS for async views). A WSGI request is atomic in this mode and a request
on the async path is not: there, a view that writes twice can commit the first write and
fail on the second. Wrap the view's writes in transaction.atomic() where that matters.
If a synchronous-only middleware is listed after RLSTenantMiddleware, Django runs it
synchronously even under ASGI, and the request transaction applies as under WSGI. Before
1.4.0 the middleware raised RLSConfigurationError for this mode under ASGI.
Consequences of a transaction per request (WSGI)
As with ATOMIC_REQUESTS, catch database
errors inside your own transaction.atomic() block or the rest of the request fails
with "current transaction is aborted"; transaction.on_commit() callbacks run when
the request ends; and @non_atomic_requests has no effect, because the middleware's
transaction encloses the view anyway. A streaming response iterates its body after
the transaction has ended, so queries made while streaming run without tenant
context (fail-closed: no rows) -- establish a context inside the generator.
Place middleware implementing process_exception before RLSTenantMiddleware,
so handled view exceptions cannot bypass rollback; unsafe ordering raises
RLSConfigurationError. Error handling runs inside the transaction too, and writes
by error handlers are rolled back along with the failed view's writes. On the async
path there is no request transaction, so none of this applies (see
Middleware).
Code Outside a Request Transaction¶
Changed in 1.4.0. Management commands, Celery tasks, scripts and requests on the
middleware's async path have no transaction that the middleware opened. Before 1.4.0 a
context manager entered without transaction.atomic() raised RuntimeError, so you had
to open one yourself. Now the
context managers write nothing there: they set the Python-side context, and the
GUC affinity guard writes the GUCs
transaction-locally (set_config(name, value, true)) on every alias in
RLS_TENANTS["DATABASES"]:
- Inside a transaction (
transaction.atomic(), the WSGI request transaction,AUTOCOMMIT=False, a transaction you opened with SQLBEGIN), the guard sets both GUCs in one statement before the first statement that needs them, and again when the context changes in the same transaction (sibling asyncio tasks that share the connection, say). The values end with the transaction, committed or rolled back. - In autocommit, a statement run while a context is active gets a transaction of its
own:
BEGIN, theset_config()statement, your statement,COMMIT. That is three extra round trips per statement. The statement keeps its autocommit semantics: it commits on its own, a failing one is rolled back alone, and a failingCOMMIT(a deferred constraint) raises from that statement. Anexecutemany()runs in one such transaction, so it commits or fails as a whole (in plain autocommit, psycopg2 keeps the rows before a failing one). - Outside any context, a statement gets no transaction of the guard's. If the
session's own values are unknown (see "After a context ends" below), the next such
statement, in autocommit or in a transaction, first clears them for the session with one
extra
set_config()statement; inside a transaction the clear sticks once it commits.
# No transaction.atomic() needed: each statement gets a transaction of its own.
with tenant_context(tenant_id=42):
orders = list(Order.objects.all())
Where you enter the context decides what it costs:
| The context is entered | Cost |
|---|---|
| Outside any transaction | Nothing on entry or exit. Each autocommit statement run under it costs three extra round trips: BEGIN, a set_config() statement, COMMIT. |
Outside any transaction, with a transaction.atomic() inside the block |
One set_config() statement per transaction, before its first statement that needs the GUCs, plus one per context change in it. |
Inside transaction.atomic() (a TestCase, the WSGI request transaction) |
The block writes the GUCs itself: two current_setting() reads and two SET LOCAL statements on entry, two SET LOCAL statements on exit. The guard sends none for it while its tracker is clean (a test transaction, the WSGI request transaction, the first such block after a statement run outside any context). After an earlier context has ended on that connection it first clears the session's values, one more statement before the block's reads, and a loop that enters atomic() and a context on every iteration pays it every time. |
Putting a batch of statements in one transaction is still worth it for performance: either placement beats three extra round trips per statement once the batch runs more than a few, and the writes commit together.
from django.db import transaction
# The context outside: one set_config() for the whole transaction.
with tenant_context(tenant_id=42):
with transaction.atomic():
orders = list(Order.objects.all())
# Or atomic() outside: the block writes its own GUCs, about six small statements
# (plus one clear first if an earlier context has ended on this connection).
with transaction.atomic():
with tenant_context(tenant_id=42):
orders = list(Order.objects.all())
What to know about the wrapped statements:
- The guard sends
BEGIN,COMMITandROLLBACKpast Django's cursor, so no execute wrapper sees them. It logs them the way Django logs its own transaction control, though: they appear inconnection.queries(assertNumQueriesandCaptureQueriesContextcount them) and on thedjango.db.backendslogger. A wrapped statement counts as four queries, listed in this order:BEGIN, theset_config()statement,COMMIT, then the statement itself, because Django logs it when it finishes. - Statements PostgreSQL may refuse inside a transaction block run as they are, unwrapped
and without the context:
VACUUM(alsoVACUUM (ANALYZE) t),CLUSTER,REINDEX,DISCARD;CREATE/ALTER/DROPof aDATABASE,TABLESPACEorSUBSCRIPTION, andALTER SYSTEM;CREATE [UNIQUE] INDEX CONCURRENTLYandDROP INDEX CONCURRENTLY; andALTER TABLE ... DETACH PARTITION ... CONCURRENTLY. The guard matches them on their grammar, from the statement's first words, so a statement that only mentions those words elsewhere (in a comment, say) is wrapped. None of them evaluates an RLS policy.REFRESH MATERIALIZED VIEW CONCURRENTLYis wrapped.SET TRANSACTIONis transaction control, in both GUC modes: the guard writes nothing before it, so anatomic()entered inside a context can start with it, and writes the context's values after it. - A procedure or a
DOblock that commits or rolls back (CALLof a procedure withCOMMITinside, say) cannot run inside a context in autocommit: it fails with "invalid transaction termination". Run it outside any context. - If the guard's
ROLLBACKafter a failed statement cannot be confirmed (a broken connection, a second failure, an interruption), the guard closes the connection, which Django believed to be in autocommit. The next statement reconnects, a pool rolls back a connection it gets back, and the statement's own error is the one that propagates. - The guard covers only the aliases in
RLS_TENANTS["DATABASES"]. A context manager entered outsidetransaction.atomic()on any other alias still raises, as before: add the alias to the list. - It covers only the admin and tenant GUCs. Bypass flags set with
bypass_flag(),set_bypass_flag()orrls_context()keep their explicitis_localparameter (defaultFalse). - A statement run in autocommit inside a context gets the context's values in its own
transaction, which override writes of the admin or tenant GUC that other code made for
the session (
set_guc(),rls_context()withoutis_local=True). Narrowing anadmin_context()withrls_context({"rls.is_admin": "false", ...})has no effect there, though it does insidetransaction.atomic()and in session mode. Change the context withtenant_context()/admin_context(), or write withis_local=Trueinsideatomic(). - SQL that bypasses Django's cursor (a driver cursor,
callproc(), thecopymethods, theFETCHof an open server-side cursor) is not seen. Outside a transaction it sees no context values in this mode, since nothing is left on the session. - The guard is installed on connections that were already open when the app's
ready()ran (opened in an earlier app'sready(), say), so a context applies on them too.
After a context ends¶
Code inside a context can write the admin or tenant GUC for the session (set_guc(),
rls_context() without is_local=True, a function, a DO block). In session mode the
context's exit restores the GUCs. In this mode nothing would, and the value would outlive
the context on a persistent or pooled connection. So when a context ends (a block exits, a
for_user() call's scope ends, a request served by RLSTenantMiddleware ends, the
request_finished safety net runs), the values that the sessions of the thread's
configured connections hold for themselves count as unknown. So do the session's values on
any connection after a statement that ran under a context's transaction-local values: that
reaches the connection of a thread_sensitive=False hop or a worker thread, which no block
exit on the request thread reaches. No statement runs when a session is marked. Before the
next statement run outside any context, in autocommit or in a transaction, the guard clears
the session's values with one extra set_config() statement: for example the next
request's session lookup on a persistent connection. That clear is for the session, so
once its transaction commits it sticks. Inside a transaction the mark itself takes effect
at the commit; a rollback undoes such a write anyway.
PgBouncer Configuration¶
Transaction Mode (recommended)¶
[databases]
myproject = host=localhost dbname=myproject
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
With pool_mode = transaction and USE_LOCAL_SET = True:
- Each transaction gets a clean connection from the pool.
SET LOCALensures GUCs are scoped to the transaction.- Every statement gets its GUCs inside the transaction it runs in: the middleware's request
transaction under WSGI, your
transaction.atomic(), or, for requests on the middleware's async path and code outside a transaction, a transaction of its own that the GUC affinity guard opens (see Code Outside a Request Transaction). That is what transaction pooling needs. Changed in 1.4.0: before, only WSGI requests and code insideatomic()worked in this mode. - No cleanup is needed after the transaction ends.
- Pass
is_local=Truetobypass_flag(),set_bypass_flag()andrls_context()(insidetransaction.atomic()): they are session-scoped by default in either mode, and nothing clears a flag that PgBouncer hands to another client. - Set
DISABLE_SERVER_SIDE_CURSORS = Trueon the alias. Django requires it for transaction pooling: a server-side cursor (iterator()) can outlive its transaction, and the next transaction may run on another server connection.
Session Mode¶
With pool_mode = session, each client gets a dedicated connection for the duration of
the session. The default set_config() behavior works correctly because the middleware
clears GUCs in process_response.
RLS_TENANTS = {
"TENANT_MODEL": "myapp.Tenant",
"USE_LOCAL_SET": False, # default, works with session mode
}
Django's Connection Pool¶
Django 5.1+ can pool connections itself: set DATABASES[alias]["OPTIONS"]["pool"] with
psycopg 3 (pip install "psycopg[pool]"). Closing a pooled connection, which Django does
at the end of each request, hands it back to a psycopg_pool.ConnectionPool. The pool
rolls back an open transaction and does nothing else, so session GUCs set with
set_config() (USE_LOCAL_SET=False, the default) stay on the connection for its next
borrower.
The Problem¶
The middleware clears the GUCs before the connection goes back. It cannot when its cleanup
failed or reached a different connection: a view that closed its connection before the
response ended (a long-lived SSE stream, for one), a crash, a cancelled request. The
request_finished safety net does not help: it runs after Django's own
close_old_connections has already returned the connection to the pool. The next
borrower, such as an anonymous request or a Celery task, would then run with the previous
request's tenant or admin bypass and could read that tenant's rows. A leaked
extra_bypass_flags entry (for example rls.is_login_request) would do the same for the
models that declare it, even for a borrower that sets its own tenant context. Versions
before 1.4.0 did not clear a pooled connection on checkout (see
Upgrading).
W004 never fires for a working pool, because Django rejects a non-zero CONN_MAX_AGE
together with OPTIONS["pool"].
What the Library Does on Checkout¶
Django sends connection_created on every checkout from the pool. For an alias in
RLS_TENANTS["DATABASES"] whose OPTIONS["pool"] is enabled (True or a non-empty dict;
Django treats {}, False and None as no pool), the connection_created handler clears
every GUC the library's RLS policies read, before the new borrower's first query:
- It is one statement, so one round trip per checkout (plus a
COMMITunderAUTOCOMMIT=False), about one per request on that alias. - It runs with or without a request context, in both GUC modes, and even when the pool has
a
resetcallback (see below). - Then, with
USE_LOCAL_SET=False, on the thread that set the request's GUCs, the handler applies the request's GUCs as before (a connection checked out mid-request). A pooled connection always starts clean. The GUC affinity guard then applies the active context before each statement, on whichever thread runs with one (withUSE_LOCAL_SET=True, transaction-locally), and tracks each checkout as a new session. - If the clear fails (a statement timeout, a broken connection), the checkout fails
closed: the handler logs a warning (
Failed to clear RLS GUCs on a connection for alias ... checked out of a connection pool; discarding it.), closes the connection so the pool discards it instead of reusing it, and raises the error to the code that asked for the connection. The borrower's query fails; it never runs on a connection that may still be dirty.
"Every GUC the policies read" means the configured tenant and admin GUCs (derived from
GUC_PREFIX) and, from the constraints of every installed model, each RLSConstraint's
guc_tenant_var, guc_admin_var and extra_bypass_flags entries, and each
RLSM2MConstraint's guc_tenant_var and guc_admin_var. GUCs read only by hand-written
policies, or by AddM2MRLSPolicy operations with non-default names, are not cleared.
Names that are not custom GUC names, such as an extra_bypass_flags entry without a dot
(PostgreSQL only accepts dotted names for custom settings), are left out, so they cannot
fail the statement.
Only Django's own pool (OPTIONS["pool"]) is detected, and only on aliases listed in
RLS_TENANTS["DATABASES"]. List every alias you use with tenant_context(using=...),
admin_context(using=...) or bypass_flag(using=...) too. A third-party pooling backend
that hands out used connections is not detected.
Clearing on Return: reset_rls_gucs¶
The checkout clear protects the next borrower, but an idle connection still holds the last
borrower's values until then. A pool reset callback clears them as the connection goes
back. Django forwards every key of OPTIONS["pool"] to psycopg_pool.ConnectionPool:
from django_rls_tenants.pool import reset_rls_gucs
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
# ...
"OPTIONS": {"pool": {"reset": reset_rls_gucs}},
},
}
psycopg_pool calls it on the raw psycopg 3 connection, in one of its worker threads,
after rolling back any open transaction. reset_rls_gucs clears the same set of GUCs in one
statement and commits if the connection is not in autocommit, leaving it idle as the pool
requires. If it raises, the pool discards the connection instead of reusing it
(fail-closed). It costs one statement per return. With a reset callback, psycopg_pool
returns connections asynchronously through its worker, and a returned connection is not
available to waiting borrowers until the reset has run.
django_rls_tenants.pool imports nothing from the tenants layer at module level, so it is
safe to import from settings.py before Django is set up.
Why not DISCARD ALL or RESET ALL
Django applies some session settings, such as the time zone, only once per pooled
connection, through the pool's configure= callback; init_connection_state skips
them for pooled connections. RESET ALL (which DISCARD ALL includes) would reset
TimeZone to the session's default (the server, database or role setting), and
nothing would set Django's again. reset_rls_gucs
clears only the RLS GUCs. If you keep session GUCs of your own, write a reset that
calls reset_rls_gucs(conn), clears yours, and leaves the connection idle (commit
unless it is in autocommit).
With USE_LOCAL_SET=True¶
The GUCs set by the middleware, the tenant context managers and the GUC affinity guard
are transaction-local values, which end with the transaction, so a pooled connection never
holds them (changed in 1.4.0: also under ASGI and outside transaction.atomic(); before,
this was WSGI only and the middleware raised RLSConfigurationError under ASGI). Bypass
flags are not: bypass_flag(), set_bypass_flag() and rls_context() default to
is_local=False in either mode, so a flag whose block did not restore it can still go
back to the pool. System check W010 does not fire in this mode; the checkout clear
still runs and covers those flags, and the next statement run outside any context after a
context has ended clears what the session may hold (see
After a context ends).
System Checks¶
django-rls-tenants provides system checks for two pooling misconfigurations:
W004: CONN_MAX_AGE with session GUCs¶
?: (django_rls_tenants.W004) DATABASES['default'] has CONN_MAX_AGE=600 with
USE_LOCAL_SET=False. This risks GUC state leaking between requests on persistent
connections. Consider USE_LOCAL_SET=True, which scopes the GUCs to transactions, so they
never stay on a connection.
HINT: Set RLS_TENANTS['USE_LOCAL_SET'] = True, or set
DATABASES['default']['CONN_MAX_AGE'] = 0.
The alias and the configured CONN_MAX_AGE value (here 600) are interpolated into the
message. It fires once for every alias in RLS_TENANTS["DATABASES"] with a non-zero or
None CONN_MAX_AGE.
Fix: Either:
- Set
USE_LOCAL_SET = True, or - Set
CONN_MAX_AGE = 0(Django default, closes connections after each request).
W010: Django's connection pool without a reset callback¶
?: (django_rls_tenants.W010) DATABASES['default'] uses Django's connection pool with
USE_LOCAL_SET=False and no pool 'reset' callback. A connection that a request's cleanup
did not reach goes back to the pool still holding that request's RLS GUCs, until its next
checkout clears them.
HINT: Set DATABASES['default']['OPTIONS']['pool']['reset'] to
django_rls_tenants.pool.reset_rls_gucs, or set RLS_TENANTS['USE_LOCAL_SET'] = True.
It fires once for every alias in RLS_TENANTS["DATABASES"] whose OPTIONS["pool"] is
enabled without a reset (the pool is True, or a dict without "reset" or with
"reset": None), when USE_LOCAL_SET=False. Any reset, including your own, silences it.
The checkout clear (see above) protects the next borrower either way; the check asks for
the return-side clear so idle connections hold no tenant state.
Fix: Either:
- Set the pool's
resettodjango_rls_tenants.pool.reset_rls_gucs(see Clearing on Return), or - Set
USE_LOCAL_SET = True. Session-scoped bypass flags can still go back to the pool in that mode;reset_rls_gucsclears them too.
Retired checks
W003 (1.3.1) and W007 (1.3.2) warned about USE_LOCAL_SET=True without
ATOMIC_REQUESTS. Since the middleware opens its own transaction, that combination
is valid and the checks no longer exist. Check IDs are never reused.
Django's Persistent Connections (CONN_MAX_AGE)¶
Django's built-in CONN_MAX_AGE setting controls how long database connections are
reused. With USE_LOCAL_SET=False and CONN_MAX_AGE > 0:
- The middleware clears GUCs in
process_response. - But if the response phase is skipped (e.g., due to an unhandled exception), GUCs could leak to the next request on the same connection.
- The
request_finishedsignal handler provides a safety net for this case.
For maximum safety with persistent connections, enable USE_LOCAL_SET:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"CONN_MAX_AGE": 600, # 10 minutes
}
}
RLS_TENANTS = {
"TENANT_MODEL": "myapp.Tenant",
"USE_LOCAL_SET": True,
}
Under ASGI, keep CONN_MAX_AGE = 0: each request runs on a thread of its own, so a
persistent connection is never reused. Use Django's pool or PgBouncer instead (see
Async Deployment).