Context Managers¶
Context managers provide explicit control over RLS context in code that runs outside the request/response cycle -- management commands, Celery tasks, tests, scripts, and service-layer functions.
tenant_context¶
Scopes all database queries within the block to a specific tenant:
from django_rls_tenants import tenant_context
with tenant_context(tenant_id=42):
# All queries see only tenant 42's data
orders = Order.objects.all()
invoices = Invoice.objects.all()
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
tenant_id |
int \| str |
(required) | The tenant PK to scope to. |
using |
str |
"default" |
Database alias. |
Behavior:
- Sets
rls.is_admin = 'false'andrls.current_tenant = str(tenant_id). - Sets the internal
ContextVarstate soRLSManager.get_queryset()automatically addsWHERE tenant_id = Xto all queries (auto-scoping). - Saves and restores previous GUC values and state on exit (supports nesting).
- Raises
NoTenantContextErroriftenant_idisNone.
from django_rls_tenants.exceptions import NoTenantContextError
# NoTenantContextError: use admin_context() for admin access
with tenant_context(tenant_id=None): # raises NoTenantContextError
...
admin_context¶
Enables admin bypass -- all tenant data is visible:
from django_rls_tenants import admin_context
with admin_context():
# Sees data from ALL tenants
all_orders = Order.objects.all()
total = all_orders.count()
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
using |
str |
"default" |
Database alias. |
Behavior:
- Sets
rls.is_admin = 'true'and clearsrls.current_tenant. - Clears the internal
ContextVarstate soRLSManager.get_queryset()does not add any tenant filter (admin sees all rows). - Saves and restores previous GUC values and state on exit (supports nesting).
Nesting¶
Context managers support arbitrary nesting. Each level saves the previous state and restores it on exit:
with admin_context():
# Admin: sees everything
all_count = Order.objects.count()
with tenant_context(tenant_id=1):
# Scoped to tenant 1
t1_count = Order.objects.count()
with tenant_context(tenant_id=2):
# Scoped to tenant 2
t2_count = Order.objects.count()
# Back to tenant 1
assert Order.objects.count() == t1_count
# Back to admin
assert Order.objects.count() == all_count
Save and restore happen in both GUC modes. With USE_LOCAL_SET=True inside
transaction.atomic(), the previous values are re-issued with SET LOCAL when the inner
block exits, so the enclosing block keeps its own scope for the rest of the transaction;
PostgreSQL then drops everything at COMMIT / ROLLBACK as usual. Outside atomic() the
blocks write nothing and restore nothing (see
below).
Before 1.3.2
With USE_LOCAL_SET=True, versions up to 1.3.1 did not restore on block exit at all.
An inner admin_context() left rls.is_admin = 'true' on the connection until the
transaction ended, while get_current_tenant_id() still reported the outer tenant.
Raw SQL and plain managers saw every tenant for the rest of the transaction (#94).
Exceptions Inside a Context Block¶
An exception raised inside a block resets the Python state and attempts to restore the previous GUC values. Restoration depends on the transaction state:
- A Python exception (
ValueError, a failed assertion, ...) leaves the database transaction healthy, so the outer scope is restored normally and the enclosing block continues with its own tenant. - A failed SQL statement (
IntegrityError,DataError, ...) puts the PostgreSQL transaction into the aborted state, where every further statement is rejected. The library suppresses database errors from the restore and lets your exception propagate instead of replacing it withInternalError: current transaction is aborted. Nothing is lost: PostgreSQL rolls backset_config()andSET LOCALchanges together with the transaction, or with the savepoint when the block sits inside a nestedtransaction.atomic(), so the outer scope is reinstated by the rollback itself.
with transaction.atomic(), tenant_context(tenant_id=1):
try:
with transaction.atomic(), admin_context():
Order.objects.create(...) # raises IntegrityError
except IntegrityError:
... # the real error, not "transaction is aborted"
# Back to tenant 1: the savepoint rollback restored the GUCs.
Order.objects.count()
USE_LOCAL_SET=True Outside a Transaction¶
Changed in 1.4.0. SET LOCAL needs a transaction. Inside transaction.atomic() the
managers behave as described above: they snapshot, SET LOCAL on entry and restore on
exit. Outside atomic(), on an alias in RLS_TENANTS["DATABASES"], they no longer raise
RuntimeError. They write nothing on entry or exit and only set the Python-side context
(if a transaction is still open at exit, from AUTOCOMMIT=False or an SQL BEGIN, the
exit may send one set_config() statement that puts the enclosing context's values back,
or clears them for the session when there is none). The
GUC affinity guard
applies the values to each statement, transaction-locally: in a transaction of its own for
a statement run in autocommit (BEGIN, the set_config() statement, your statement,
COMMIT: three extra round trips), and once per transaction for a transaction opened
inside the block. Celery tasks, management commands and scripts therefore need no
transaction.atomic():
Put a batch of statements in one transaction.atomic() when the round trips matter.
Opened inside the block, it costs one set_config() per transaction. Opened around the
block, the block writes its own GUCs: 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 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, and a loop that enters atomic() and a
context on every iteration pays it every time. The same holds for admin_context(),
@with_rls_context, the for_user() scope and the testing helpers. When a block ends,
what the session holds for itself counts as unknown, and the next statement run outside any
context, in autocommit or in a transaction, clears it first (one statement), in case code
inside the block wrote the GUCs for the session. Such a write does not narrow the block's
context in autocommit: the guard's values override it, so change the context with
tenant_context() / admin_context(). See Code Outside a Request
Transaction for the cost by
placement, the query counts, and where the guard stops.
On an alias outside RLS_TENANTS["DATABASES"], which the guard leaves alone, nothing
changes: a block entered outside atomic() still raises RuntimeError ("Cannot use SET
LOCAL for ... outside a transaction"), and the async managers raise RLSConfigurationError.
Async Code¶
Added in 1.4.0. async_tenant_context() and async_admin_context() are the
async with counterparts of the two managers above, for async views, consumers and
tasks:
from django_rls_tenants import async_admin_context, async_tenant_context
async def order_count(request, tenant_id):
async with async_tenant_context(tenant_id):
count = await Order.objects.acount()
async with async_admin_context():
total = await Order.objects.acount()
return JsonResponse({"tenant": count, "all": total})
Django has no asynchronous database cursor: every async ORM call is a
sync_to_async(..., thread_sensitive=True) hop to the thread that owns the connection.
The async managers work the same way, with one hop on entry (snapshot and set the GUCs)
and one on exit (restore), so they are not non-blocking. What they give you is
async with ergonomics, and tenant state (ContextVars) that is set and reset in your
coroutine itself, so the auto-scope filter and strict mode apply to
acount(), aget(), async for and the rest of the async ORM.
Nesting and exceptions behave exactly as for the sync managers, including the restore in both GUC modes. The restore also runs when the task is cancelled inside the block, or while the entry hop is still running.
The generic layer has the same pair, async_rls_context() and async_bypass_flag(), plus
the primitives aset_guc(), aget_guc(), aclear_guc() and arestore_guc() (see the
API reference).
For tests, the testing helpers have async counterparts. Async Tests
also explains which database connection an async def test uses. For running the project on
an ASGI server, see Async Deployment.
USE_LOCAL_SET=True in async code¶
Changed in 1.4.0. A coroutine cannot open transaction.atomic() (atomic() on the
event-loop thread raises SynchronousOnlyOperation), so the thread that runs the database
calls is normally in autocommit. With USE_LOCAL_SET=True the async managers write
nothing there: they set the Python-side context, and the GUC affinity guard gives each
statement the context in a transaction of its own (see
USE_LOCAL_SET=True Outside a Transaction).
That works in an ASGI request, a bare event loop and a script run with asyncio.run():
async with async_tenant_context(tenant_id): # no atomic() needed
count = await Order.objects.acount()
When the coroutine is driven by async_to_sync from synchronous code that is already
inside transaction.atomic(), the managers write SET LOCAL values into that
transaction, as the sync managers do:
from asgiref.sync import async_to_sync
from django.db import transaction
with transaction.atomic():
async_to_sync(do_tenant_work)() # async_tenant_context() inside sets SET LOCAL
On an alias outside RLS_TENANTS["DATABASES"], which the guard leaves alone, the async
managers still need that transaction and raise RLSConfigurationError without one; add
the alias to the list, or run the block through async_to_sync inside atomic(). The
generic async_rls_context() and async_bypass_flag() do not check this: with
is_local=True outside a transaction the RuntimeError from set_guc() propagates.
Concurrent tasks¶
Sibling tasks of one request share the request's database connection, but each has its
own ContextVar state. Before each statement, the
GUC affinity guard applies the context of
the task that runs it, so blocks for different tenants can run concurrently:
await asyncio.gather(
report_for(tenant_a), # async with async_tenant_context(tenant_a.pk): ...
report_for(tenant_b), # async with async_tenant_context(tenant_b.pk): ...
)
That covers raw SQL, safe_tenant_sql() and M2M through tables, which rely on the GUCs
alone. It does not cover:
- bypass flags and other GUCs set with
async_bypass_flag()/async_rls_context(): they stay on the shared connection while the block runs, and sibling tasks see them. Do not run such a block concurrently with work that must not get the bypass; - a block that exits in another
contextvars.Contextthan it entered in (a generator suspended inside it and closed elsewhere): it restores, on the session it entered on, the values of the context it was entered in while that context is live, even if another context is current there; - SQL that bypasses Django's execute wrappers: a driver cursor,
callproc(), the driver'scopymethods, and theFETCHes of a server-side cursor (iterator()) opened inside a transaction, whose rows PostgreSQL computes as they are fetched. Outside a transaction (autocommit, Django's default) it computes them when the cursor opens.
A task that a block starts and does not await gets the block's context only while the block is live: once the block has ended, that task's statements run without it (fail-closed). Enter the context inside the task if it outlives the block.
A sync_to_async call cancelled while its function is still inside a block (an
asyncio.wait_for() that times out, say) leaves the caller's own context in place. A
coroutine outside any context and outside a request that RLSTenantMiddleware serves is
the exception: it runs with the cancelled call's block until that block ends, so do not
run code there that must not have it, or enter a context first.
Mixing sync and async code¶
The sync and async managers nest in both directions. A sync tenant_context() inside a
sync_to_async(..., thread_sensitive=True) call runs on the same connection and restores
the outer async block when it exits; an async block run with async_to_sync inside a sync
tenant_context() restores the sync scope. Prefer thread_sensitive=True (the default): a
different thread means a different connection. The
GUC affinity guard applies your context
there too, but that connection is outside any transaction your code has open (with
USE_LOCAL_SET=True, each of its statements gets a transaction of its own).
@with_rls_context Decorator¶
The with_rls_context decorator automatically extracts a user argument from a
function's signature and sets the appropriate RLS context:
from django_rls_tenants import with_rls_context
@with_rls_context
def process_order(request, as_user):
# RLS context set automatically based on as_user
orders = Order.objects.all()
return process(orders)
How It Works¶
- At decoration time: rejects generator functions (see
What the Decorator Rejects), caches
inspect.signature()of the wrapped function, and picks anasync defwrapper for anasync deffunction (see Async Functions), a plain one otherwise. - At call time (for an
async def, when the call is awaited): extracts the user argument by name from*args/**kwargs. - If the user is an admin (
is_tenant_admin=True): wraps inadmin_context()(async_admin_context()for anasync def). - If the user is a tenant user: wraps in
tenant_context(user.rls_tenant_id)(async_tenant_context()for anasync def). - If the user is
None: logs a warning and proceeds without context (fail-closed).
Async Functions¶
Added in 1.4.0. On an async def function the decorator returns an async def
wrapper, which enters async_admin_context() or async_tenant_context() around the
awaited body, so the context covers every await in it:
from django.contrib.auth.decorators import login_required
@with_rls_context
async def order_count(as_user):
return await Order.objects.acount()
@login_required # an AnonymousUser has no is_tenant_admin / rls_tenant_id
async def dashboard(request):
user = await request.auser()
return JsonResponse({"orders": await order_count(as_user=user)})
- The wrapper is itself a coroutine function, so Django (async views and async-aware
decorators such as
@require_GET) andasynciotreat the decorated function as async. - Calling the function only creates the coroutine. The user argument is read, the context
entered, the body run and the context restored when it is awaited, all in the awaiting
task. A
NoTenantContextErrorfor a non-admin user without a tenant is raised by theawait. is_tenant_adminandrls_tenant_idare read in the coroutine, so they must not query the database. Pass a loaded user: in an async view useawait request.auser(), notrequest.user, a lazy object that queries on first access and raisesSynchronousOnlyOperationin async code.- Everything in Async Code applies: one thread hop on entry and one on exit,
the
USE_LOCAL_SET=Truebehaviour, and concurrent tasks. Decorated calls for different users can run concurrently, as inasyncio.gather(order_count(as_user=a), order_count(as_user=b)), within the limits listed there.
Before 1.4.0
The decorator always returned a sync wrapper. On an async def it entered and left the
context before the coroutine ran, so the body ran under the caller's context instead.
Called from synchronous code inside admin_context() (for example through
async_to_sync), a coroutine decorated for a tenant user saw every tenant's rows.
Awaited from async code, the call failed with SynchronousOnlyOperation (#39).
What the Decorator Rejects¶
Both cases below raise TypeError instead of running a body outside the context.
-
Generator functions, sync (
defwithyield) or async (async defwithyield), fail at decoration time. A generator's body runs while the caller iterates, after the decorated call has returned, so no wrapper can keep a context around it. Enter the context inside the generator instead:def export_orders(as_user): if as_user.is_tenant_admin: ctx = admin_context() else: ctx = tenant_context(as_user.rls_tenant_id) with ctx: yield from Order.objects.iterator()While the generator is suspended between items, its context is active for the caller too, and it is only restored when the generator finishes or is closed. Iterate it to the end, or close it (
contextlib.closing(); Django closes aStreamingHttpResponsebody for you). -
A coroutine returned by a function the decorator saw as synchronous fails at call time, after the coroutine is closed. This happens for an object with
async def __call__, or for anasync defunder another decorator that returns a plain function. Put@with_rls_contextdirectly on theasync def, as the innermost decorator.
Default Parameter Name¶
By default, the decorator looks for a parameter named by the USER_PARAM_NAME setting
(default: "as_user"):
Custom Parameter Name¶
Use user_param to specify a different parameter name:
Signature Mismatch Warning¶
If the parameter is not found in the function signature, the decorator logs a warning at decoration time and the function will always run without RLS context (fail-closed):
Examples¶
# Bare decorator (uses default USER_PARAM_NAME)
@with_rls_context
def create_order(request, as_user):
Order.objects.create(title="New Order", amount=100)
# With explicit user_param
@with_rls_context(user_param="user")
def get_dashboard_data(user):
return {
"orders": Order.objects.count(),
"invoices": Invoice.objects.count(),
}
# Called as a regular function -- user is extracted automatically
create_order(request, as_user=tenant_user)
create_order(request, tenant_user) # also works (positional)
Strict Mode¶
When STRICT_MODE=True, queries on RLS-protected models raise
NoTenantContextError if no RLS context is active. Both tenant_context() and
admin_context() establish an active context, so queries inside these blocks
always pass the strict mode check.
from django_rls_tenants import tenant_context, admin_context
from django_rls_tenants.exceptions import NoTenantContextError
# Without context -- raises NoTenantContextError (strict mode)
try:
Order.objects.count() # NoTenantContextError
except NoTenantContextError:
pass
# With context -- works normally
with tenant_context(tenant_id=42):
Order.objects.count() # OK
with admin_context():
Order.objects.count() # OK
The @with_rls_context decorator also establishes an active context when a valid
user argument is provided, so decorated functions pass the strict mode check (for an
async def, for the whole awaited body).
async_tenant_context() and async_admin_context() set the flag in the coroutine, so the
async ORM passes the check inside them too (see Async Code).
See Configuration for setup instructions.
Debug Logging¶
Both tenant_context() and admin_context() emit DEBUG-level log messages
on entry and exit. This is useful for tracing RLS context transitions in
management commands, Celery tasks, and service-layer code. No output is
produced unless you enable DEBUG level on the django_rls_tenants logger:
LOGGING = {
"version": 1,
"handlers": {
"console": {"class": "logging.StreamHandler"},
},
"loggers": {
"django_rls_tenants": {
"handlers": ["console"],
"level": "DEBUG",
},
},
}
Example output:
DEBUG tenant_context entered: tenant_id=42, using=default
DEBUG tenant_context exited: tenant_id=42, using=default
DEBUG admin_context entered: using=default
DEBUG admin_context exited: using=default
Multi-Database Support¶
All context managers accept a using parameter for multi-database setups:
with tenant_context(tenant_id=42, using="replica"):
orders = Order.objects.using("replica").all()
with admin_context(using="analytics"):
data = Report.objects.using("analytics").all()
The TenantQuerySet.for_user() method automatically uses self.db, so chaining
.using() works correctly:
Changed in 1.4.0: using names the alias a context manager writes its GUCs to on entry
and restores on exit. Every alias in RLS_TENANTS["DATABASES"] follows the context as well:
the GUC affinity guard applies it before
each statement there. tenant_context(42) therefore also scopes a read that a router sends
to a configured replica, and admin_context(using="analytics") gives the admin bypass on
every configured alias. An alias outside RLS_TENANTS["DATABASES"] gets the GUCs only when
you name it in using. With USE_LOCAL_SET=True the configured aliases follow the context
too, with transaction-local values: the guard sets them per statement in autocommit, or
once per transaction opened inside the context. An alias outside the list gets them only
when you name it in using and have a transaction open on it
(transaction.atomic(using=...)).
using does not limit a context to one alias
using names the alias the GUCs are written to first, not the aliases the context
reaches. admin_context(using="analytics") also gives the admin bypass on every alias in
RLS_TENANTS["DATABASES"], default included, even when analytics is not listed.
Do not enter an admin context for an alias you do not need it on.