Connection Pooling¶
When using a connection pooler like PgBouncer or pgpool-II, special care is needed to prevent GUC variables from leaking between requests through pooled connections.
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
},
"ATOMIC_REQUESTS": True, # Required for SET LOCAL
}
}
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 |
Requires ATOMIC_REQUESTS |
No | Yes |
| Nesting support | Full save/restore | PostgreSQL handles it |
Why ATOMIC_REQUESTS Is Required¶
SET LOCAL only works inside a transaction. With ATOMIC_REQUESTS=True, Django wraps
each view in transaction.atomic(), ensuring GUCs are active for the entire request
and automatically cleared at the end.
Without ATOMIC_REQUESTS, you must manually wrap all database operations:
from django.db import transaction
with transaction.atomic():
with tenant_context(tenant_id=42):
orders = Order.objects.all()
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.- No cleanup is needed after the transaction ends.
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
}
System Checks¶
django-rls-tenants provides system checks for common pooling misconfigurations:
W003: USE_LOCAL_SET without ATOMIC_REQUESTS¶
(django_rls_tenants.W003) USE_LOCAL_SET is True but ATOMIC_REQUESTS is
not enabled for database 'default'. SET LOCAL requires an active transaction.
Fix: Set ATOMIC_REQUESTS = True in your database config, or ensure all database
access happens inside transaction.atomic() blocks.
W004: CONN_MAX_AGE with session GUCs¶
(django_rls_tenants.W004) CONN_MAX_AGE is set to a non-zero value for
database 'default' and USE_LOCAL_SET is False. Session-scoped GUCs may
persist across requests on reused connections.
Fix: Either:
- Set
USE_LOCAL_SET = TrueandATOMIC_REQUESTS = True, or - Set
CONN_MAX_AGE = 0(Django default, closes connections after each request).
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: