Migration Guide¶
This guide helps you migrate from other Django multitenancy libraries to django-rls-tenants.
From django-tenants¶
django-tenants uses a schema-per-tenant approach. Migrating to django-rls-tenants means moving from separate schemas to a single schema with RLS policies.
Key Differences¶
| Aspect | django-tenants | django-rls-tenants |
|---|---|---|
| Isolation | Separate PostgreSQL schemas | RLS policies on shared tables |
| Tenant routing | connection.set_tenant() |
GUC variables via middleware |
| Shared apps | SHARED_APPS / TENANT_APPS |
All apps share one schema |
| Migrations | Run per-schema | Run once (single schema) |
| Raw SQL safety | Yes (schema isolation) | Yes (RLS policies) |
| Database overhead | High (N schemas) | Low (single schema) |
Migration Steps¶
-
Create a tenant FK column on all tenant-scoped tables:
-
Replace model inheritance: change
TenantMixinto your own tenant model, and tenant-scoped models to inherit fromRLSProtectedModel. -
Replace middleware: swap
TenantMainMiddlewareforRLSTenantMiddleware. -
Replace tenant routing: replace
connection.set_tenant()calls withtenant_context()oradmin_context(). -
Consolidate schemas into a single schema (this is the hardest step and is project-specific).
-
Run migrations to create RLS policies.
-
Verify: run
python manage.py check_rls.
Warning
Schema consolidation is a significant data migration and should be planned carefully. Test thoroughly in a staging environment before production.
From django-multitenant¶
django-multitenant uses ORM-level query rewriting. Migrating is simpler because you already use a single schema.
Key Differences¶
| Aspect | django-multitenant | django-rls-tenants |
|---|---|---|
| Isolation | ORM query rewriting | RLS policies |
| Raw SQL safety | No | Yes |
| Citus support | Yes | No (standard PostgreSQL) |
| Fail-closed | No | Yes |
| Manager | TenantManager |
RLSManager |
Migration Steps¶
-
Replace model base class: change
TenantModeltoRLSProtectedModel. -
Replace manager calls: change
set_current_tenant()to context managers. -
Replace middleware: swap the multitenant middleware for
RLSTenantMiddleware. -
Add
TenantUserproperties to your User model. -
Update settings: replace
MULTI_TENANTsettings withRLS_TENANTS. -
Run migrations to create RLS policies.
-
Verify: run
python manage.py check_rls.
From No Multitenancy¶
If you are adding multitenancy to an existing single-tenant application:
- Create a Tenant model (see Tenant Model).
- Add tenant FK to all data models that need isolation.
- Populate the FK with the appropriate tenant ID for existing data.
- Inherit from
RLSProtectedModelon those models. - Implement
TenantUseron your User model. - Add middleware and settings.
- Run migrations and verify with
check_rls.
The most challenging step is populating the tenant FK for existing data. Plan a data migration that assigns the correct tenant to each existing record.
Upgrading django-rls-tenants¶
From 1.3.4 to 1.4.0¶
- Django 4.2, 5.0 and 5.1 and Python 3.11 are no longer supported. 1.4.0 needs
Django 5.2+ on Python 3.12+ (see
Supported versions). Upgrade
them first, or pin
django-rls-tenants<1.4, which keeps working on the old versions. @with_rls_contextrejects generator functions. Decorating adeforasync deffunction that containsyieldnow raisesTypeErrorwhen the decorator is applied, which for a module-level function means at import. Such a body never ran under the decorator's context. Remove the decorator and entertenant_context()/admin_context()inside the generator; see What the Decorator Rejects.@with_rls_contextworks onasync def. The decorated coroutine now runs insideasync_tenant_context()/async_admin_context(); see Async Functions. An async function the decorator cannot recognise (an object withasync def __call__, or anasync defunder a decorator that returns a plain function) now raisesTypeErrorwhen called, instead of returning a coroutine that ran outside the context. Put@with_rls_contextdirectly on theasync def.assert_rls_blocks_without_context()counts with raw SQL. It now works withSTRICT_MODE=Trueand insiderls_bypass()/rls_as_tenant(), so a test that turned strict mode off around it can drop that override. The count no longer goes through the model's default manager: every row in the table must be hidden, including rows a filtering manager would have excluded. See assert_rls_blocks_without_context.for_user()sets the user's GUCs for every method that runs SQL.count(),exists(),aggregate(),update(),delete(),create()and the other methods now run as the user, not only iteration. WithUSE_LOCAL_SET=Truethe GUCs are transaction-local: insidetransaction.atomic()the scope writesSET LOCAL;update_or_create(),bulk_create(),bulk_update()anddelete()open a transaction of their own when none is open (on a configured alias the guard sets the user's values in it); and outsideatomic(), on an alias inRLS_TENANTS["DATABASES"], every other method works too, because the GUC affinity guard applies the user's GUCs to each statement (see theUSE_LOCAL_SET=Trueitem below). On an alias outside that list they raiseRuntimeErroroutsideatomic(), as iteration always did (before, they ran without the GUCs). WithSTRICT_MODE=True,aiterator()without a context now raisesNoTenantContextError, likeiterator(). See Lazy QuerySet Evaluation.- Other threads run with the context they were handed, and keep nothing (#130
security fix, #41). Code that runs on another thread with a copy of the RLS context
uses that thread's own connection: the error pages Django builds under ASGI outside
process_exception(such as a 404 for a URL that does not resolve) and, under ASGI or WSGI, work run withsync_to_async(..., thread_sensitive=False)orasyncio.to_thread(). Before, that connection got the request's GUCs only if it opened during that request, and then kept them for later requests on its thread. Now the new GUC affinity guard applies the context to each of that code's statements while the context is live, and takes it off the connection before the first statement run without it. Work started without a copy of the context (ThreadPoolExecutor.submit(),loop.run_in_executor()), or still running after the context's block has ended (a task started and not awaited inside it), runs without one. This holds withUSE_LOCAL_SET=Truetoo, where the guard applies the context transaction-locally. See Running under ASGI and GUC Affinity Guard. - Every configured alias follows the active context (#41). With several aliases in
RLS_TENANTS["DATABASES"], a context entered on one of them now applies to all of them: a read a router sends to a configured replica insidetenant_context()is scoped to that tenant (before, it usually found no rows), andadmin_context(using="replica")gives the admin bypass on every configured alias, not only the replica. Nestedtenant_context(..., using=...)blocks that only repeated an outer context for another configured alias can go.usingnames the alias written to first, not a limit: a context entered withusing=on an alias outsideRLS_TENANTS["DATABASES"]still applies on every configured alias, so checkadmin_context(using=...)calls that were meant to grant the admin bypass on one alias only. An alias outside the list does not follow a context entered on another alias. This holds withUSE_LOCAL_SET=Truetoo (before, only theusingalias got the context in that mode). See Multi-Database Support. - An extra
set_config(...)statement can appear in query counts (#41). The guard runs one before a statement whose connection does not hold the active context's GUCs. Code that stays on one connection runs none, but a test that leaves a context open (by callingprocess_request()withoutprocess_response(), say), or one that ends a transaction through the driver connection, can make the next statement run one. WithUSE_LOCAL_SET=True, every statement run under a context outside a transaction is wrapped inBEGIN, aset_config()andCOMMIT, which the query log counts (see theUSE_LOCAL_SET=Trueitem below). See Aset_config(...)query you did not run. - Django's connection pool is cleared on every checkout (#96, security fix). With
OPTIONS["pool"](Django 5.1+, psycopg 3), a connection that the request's cleanup did not reach used to hand its session GUCs to its next borrower: the tenant and admin GUCs withUSE_LOCAL_SET=False, and session-scoped bypass flags in either mode. Theconnection_createdhandler now clears every GUC the policies read on each checkout from the pool, in one statement: one extra round trip per checkout. If that clear fails, the checkout now raises instead of handing over the connection. Otherwise nothing changes, but setdjango_rls_tenants.pool.reset_rls_gucsas the pool'sresetcallback to clear them when the connection goes back too. New system checkW010warns until you do (or setUSE_LOCAL_SET=True). See Django's Connection Pool. -
USE_LOCAL_SET=Trueworks under ASGI and outsidetransaction.atomic()(#96). The GUC affinity guard now acts in this mode too, writing the context's GUCs transaction-locally (set_config(..., true)), so they end with their transaction and never stay on the session or on a pooled connection. What used to raise now works; WSGI requests are unchanged. If you use this mode, check:- Context managers,
for_user()and the testing helpers outsideatomic(). On an alias inRLS_TENANTS["DATABASES"]they no longer raiseRuntimeError. The context managers and thefor_user()scope write nothing on entry or exit (a transaction still open at exit, fromAUTOCOMMIT=Falseor an SQLBEGIN, may get oneset_config()statement that puts the enclosing context's values back, or clears them for the session when there is none, so the clear stays after the commit), and the guard applies the context to each statement. Celery tasks, management commands and scripts need notransaction.atomic()any more; code that keeps it still works, and is cheaper for a batch (see the round trips below). On an alias outside the list they still raise (RuntimeError;RLSConfigurationErrorfrom the async managers). Insideatomic()nothing changes.assert_rls_blocks_without_context()also works outsideatomic()in this mode: it still clears and restores the GUCs for the session around its count. - The middleware's async path (ASGI). The middleware no longer raises
RLSConfigurationError: RLSTenantMiddleware cannot use RLS_TENANTS['USE_LOCAL_SET'] = True under ASGI. On its async path (ASGI, when every middleware listed after it can run asynchronously) it opens no request transaction and writes no GUC; the guard applies the request's context to each statement, or once per transaction for a sync view underATOMIC_REQUESTS. So a WSGI request is atomic in this mode and a request on the async path is not: if a view relied on the request transaction to roll back several writes together, wrap them intransaction.atomic(). The startup check of exception-middleware ordering is skipped for an instance on the async path. Under ASGI with a synchronous-only middleware listed afterRLSTenantMiddleware, Django runs it synchronously and the request transaction, its rollback and the ordering check apply as under WSGI. SeeUSE_LOCAL_SETand ASGI. - WSGI. Unchanged: the middleware still opens
transaction.atomic()on every configured alias around each authenticated request, and streaming responses still iterate their body without a context. A context block the view leaves open is ended before the error response, which then runs with no context. - Extra round trips and query counts. In autocommit, a statement run under a context
gets a transaction of its own:
BEGIN, aset_config(..., true)statement, your statement,COMMIT. That is three extra round trips per statement. The guard sendsBEGIN,COMMITandROLLBACKpast Django's cursor (no execute wrapper sees them) and logs them as Django logs its own transaction control, so they appear inconnection.queriesand count inassertNumQueries: a wrapped statement counts as four queries, listedBEGIN,set_config(),COMMIT, then the statement itself, which Django logs when it finishes. Where the context is entered decides the rest: a transaction opened inside it costs oneset_config(); a context entered insidetransaction.atomic()writes its own GUCs (twocurrent_setting()reads and twoSET LOCALstatements on entry, two on exit), and the guard sends none for it while its tracker is clean (a test transaction, the WSGI request transaction), but one clear first after an earlier context has ended on that connection, on every iteration of a loop that entersatomic()and a context. Raise the expected counts of tests that run outside a transaction, or run the counted code in onetransaction.atomic()inside the context. - Commands that run unwrapped. Statements PostgreSQL may refuse inside a transaction
block (
VACUUM,CLUSTER,REINDEX,DISCARD;CREATE/ALTER/DROPof aDATABASE,TABLESPACEorSUBSCRIPTION, andALTER SYSTEM;CREATE [UNIQUE] INDEX CONCURRENTLYandDROP INDEX CONCURRENTLY;ALTER TABLE ... DETACH PARTITION ... CONCURRENTLY) run as they are, without the context; none evaluates an RLS policy.SET TRANSACTIONis transaction control in both GUC modes: the guard writes nothing before it, so it can start anatomic()entered inside a context, and writes the context's values after it. A procedure or aDOblock that commits or rolls back fails inside the wrap with "invalid transaction termination": run it outside any RLS context in this mode. Anexecutemany()under a context runs in one transaction, so it commits or fails as a whole. - After a context ends. When a context ends, and on any connection after a statement
run under a context's values (a
thread_sensitive=Falsehop's, say), what a session holds for itself counts as unknown. The next statement run outside any context, in autocommit or in a transaction, clears it first for the session: one extraset_config()statement, for example the next request's session lookup on a persistent connection. It covers an admin or tenant GUC that code inside the context wrote for the session (set_guc(),rls_context()withoutis_local=True, a function, aDOblock), which used to outlive the context in this mode. No statement runs when the context ends. - Session writes inside a context. In autocommit, a statement run inside a context
gets the context's values in its own transaction, which override such session writes:
narrowing an
admin_context()withrls_context({"rls.is_admin": "false", ...})has no effect there, though it does insideatomic()and in session mode. Change the context withtenant_context()/admin_context()instead. - Failed rollbacks. If the guard's
ROLLBACKafter a failed statement cannot be confirmed (a broken connection, say), it closes the connection; the next statement reconnects and the statement's own error is the one raised. - Connections opened mid-request. A connection opened on the request thread is no
longer written eagerly in this mode (that attempted
SET LOCALoutside a transaction and logged a warning); the guard applies the values to its statements. A connection that was already open when the app'sready()ran gets the guard there. - System checks. The text of
W004now ends "...which scopes the GUCs to transactions, so they never stay on a connection.". Update any filter that matches the old text. A new check,W011, warns when PostgreSQL applies a database- or role-level default (ALTER DATABASE ... SET,ALTER ROLE ... SET) for a GUC the RLS policies read: such a value is in force in every new session, before any context is set, and the library never clears it. - Limits that stay. The guard covers only aliases in
RLS_TENANTS["DATABASES"]and only the admin and tenant GUCs; bypass flags keep their explicitis_localparameter (defaultFalse). SQL that bypasses Django's cursor is not seen.
See Code Outside a Request Transaction and GUC Affinity Guard.
- Context managers,
From 1.3.3 to 1.3.4¶
A drop-in upgrade -- no code, data or settings change is required. Under ASGI,
RLSTenantMiddleware no longer fails authenticated requests with
ValueError: ... Token was created in a different Context, and it also cleans up when an
async view is cancelled. If you took the middleware out of MIDDLEWARE or moved views
to explicit tenant_context() calls to work around that error, you can put it back; see
Running under ASGI.
USE_LOCAL_SET=True is still WSGI-only in 1.3.4; 1.4.0 supports it under ASGI (see
From 1.3.4 to 1.4.0).
From 1.3.2 to 1.3.3¶
A drop-in upgrade -- the library code is unchanged. The release adds the
Performance & Indexing guide and the
Troubleshooting page, corrects the Quick Start and User
Integration guides (a plain AbstractUser instead of an RLS-protected User; see
Protecting the User Model if
you copied the old snippet), expands the example project, backfills tests and tightens CI.
Nothing to do on upgrade.
From 1.3.1 to 1.3.2¶
A drop-in upgrade -- no code or data migration is required.
- Per-test overrides of
RLS_TENANTSnow work. The config reloads on Django'ssetting_changedsignal, sooverride_settings(RLS_TENANTS=...)and pytest-django'ssettingsfixture take effect immediately. Earlier versions cached the dict for the life of the process; if your tests clearedrls_tenants_config._config_cacheby hand to get around that, delete the workaround. See Testing. select_related()on nested nullable paths returns the missing rows again.Model.objects.select_related("a__b")inside a tenant context used to drop every row whoseaora.bwasNULLwhenbpoints at an RLS-protected model. If you worked around that with a separate query orprefetch_related(), the workaround can go; no settings or migrations change.- Nested context managers restore the outer scope in
SET LOCALmode. WithUSE_LOCAL_SET=True, exiting an innertenant_context()/admin_context()/rls_context()/bypass_flag()now re-issues the previous values withSET LOCALinstead of leaving the inner scope active until the transaction ends. Code that relied on the inner value persisting after the block (a latent isolation defect, #94) must keep the block open for as long as the scope is needed. A failed statement inside a block now surfaces as its own exception rather thanInternalError: current transaction is aborted(#102); see Context Managers. USE_LOCAL_SET=Trueworks through the middleware, and no longer needsATOMIC_REQUESTS.RLSTenantMiddlewarenow openstransaction.atomic()on every configured alias around each authenticated request (previously every such request crashed, becauseATOMIC_REQUESTSonly wraps the view). You may dropATOMIC_REQUESTS=TruefromDATABASESif you only had it for RLS; keeping it is harmless. The transaction-per-request rules ofATOMIC_REQUESTSnow apply to this mode (catch database errors insideatomic(),on_commitruns at request end,@non_atomic_requestshas no effect). Middleware implementingprocess_exceptionand function-based middleware factories must precedeRLSTenantMiddleware; unsafe ordering raisesRLSConfigurationErrorwhen the middleware is loaded. ASGI deployments cannot use this mode in 1.3.x; the middleware raisesRLSConfigurationError(1.4.0 supports it, see From 1.3.4 to 1.4.0). See Middleware.- System check
W007is retired. It flaggedUSE_LOCAL_SET=TruewithoutATOMIC_REQUESTS, which is now a supported configuration.W004no longer mentionsATOMIC_REQUESTSin its hint. Nothing to do; a leftoverSILENCED_SYSTEM_CHECKSentry is harmless.
From 1.3.0 to 1.3.1¶
A drop-in upgrade -- no code or data migration is required. The release fixes the example app, tightens one system check and retires a redundant one:
- System check
W003is retired. It only looked atDATABASES['default']and duplicatedW007, which checksUSE_LOCAL_SETagainstATOMIC_REQUESTSon every configured alias. Nothing to do unless you referenceW003in a custom check; leaving it inSILENCED_SYSTEM_CHECKSis harmless. W005now also flagsBYPASSRLSroles. A database role with theBYPASSRLSattribute skips every RLS policy exactly like a superuser, sopython manage.py checkmay report a new warning for a role that previously passed. Fix it withALTER ROLE ... NOSUPERUSER NOBYPASSRLS(the hint prints the exact statement).clear_bypass_flag(is_local=True)is new and mirrorsset_bypass_flag(). The argument is keyword-only and defaults toFalse, so existing calls are unchanged.- Security guide threat model is scoped more precisely: arbitrary SQL running on the
application connection can call
set_config()and is now listed under "Not Protected Against". See Security if you rely on that page for your own review.
From 1.2.2 to 1.3.0¶
A drop-in upgrade -- no code changes are required and runtime behaviour is unchanged. The release is additive, with one optional, opt-in performance step.
Optional: adopt the InitPlan policy form (#57)¶
v1.3.0 wraps every GUC read in a policy predicate in an uncorrelated scalar
sub-SELECT -- (SELECT current_setting('rls.current_tenant', true)) -- so
PostgreSQL evaluates the variable once per statement (a planner InitPlan)
instead of once per row. The predicate is otherwise identical, so query results
never change; the benefit shows on large, admin, and raw-SQL scans.
Because RLSConstraint / RLSM2MConstraint serialize identically
(deconstruct() is unchanged), Django generates no new migration, and the
CREATE POLICY ... IF NOT EXISTS guard skips tables that already have a
policy. So new deployments get the InitPlan form automatically, while
existing policies keep the previous inline form until you drop and recreate
them. Adopting it on a running database is optional and can be done
table-by-table.
M2M through tables -- drop the policy, then re-run setup_m2m_rls, which
recreates a policy only on tables that don't have one:
python manage.py setup_m2m_rls # recreates the dropped policy (InitPlan form)
python manage.py check_rls --verbose # confirm the live USING / WITH CHECK
Standard tenant tables -- recreate the policy through the migration that
defines the RLSConstraint. Reversing that migration runs the constraint's
remove_sql (drops the policy and disables RLS); re-applying runs create_sql,
which now emits the v1.3.0 InitPlan SQL:
python manage.py migrate <app> <migration_before_the_RLSConstraint> # drops the old policy
python manage.py migrate <app> <migration_that_adds_the_RLSConstraint> # recreates it
Warning
Reversing the migration briefly disables RLS on that table. Run it inside
a maintenance window, or instead drop and recreate the single policy with
explicit SQL in one transaction. Always confirm with
python manage.py check_rls --verbose, which prints the live USING /
WITH CHECK so you can verify the (SELECT current_setting(...)) form is in
place.
Policy names follow "{table}_tenant_isolation_policy" (standard models) and
"{through_table}_m2m_rls_policy" (M2M join tables).
From 1.2.1 to 1.2.2¶
A drop-in upgrade -- no breaking changes, and no code or data migration is required. The release is purely additive:
- New optional CLI flags:
check_rls --verboseandsetup_m2m_rls --verboseprint each policy's SQL for auditing. Existing invocations behave exactly as before. - New system checks
W008andW009:python manage.py checknow warns whenRLS_TENANTS['TENANT_MODEL']does not resolve to an installed model (W008) or when anRLSProtectedModel's RLS-policy tenant field is missing (W009). These are warnings, not errors -- you may see new output if your configuration has a latent issue, but nothing breaks. - New Celery Tasks guide documenting how to set RLS context in background tasks.
From 1.2.0 to 1.2.1¶
This release has one breaking change: internal helpers have been removed from the top-level package exports.
What Changed¶
-
Removed from top-level exports: Raw state functions (
get_current_tenant_id,set_current_tenant_id,reset_current_tenant_id,get_rls_context_active,set_rls_context_active,reset_rls_context_active) and exception classes (NoTenantContextError,RLSConfigurationError,RLSTenantError) are no longer in__all__or importable viafrom django_rls_tenants import .... -
Still importable from submodules: All removed symbols remain available from their actual modules.
Upgrade Steps¶
-
Update imports that reference the removed symbols from the top-level package:
-
No other changes required. The context managers (
tenant_context,admin_context,with_rls_context) remain top-level exports and are the recommended API for managing RLS state.
From 1.1.0 to 1.2.0¶
This release has one minor breaking change: some ValueError exceptions have
been replaced with custom exception types.
What Changed¶
-
Custom exceptions: The library introduces a custom exception hierarchy in
django_rls_tenants.exceptions.tenant_context(None)and_resolve_user_guc_vars()now raiseNoTenantContextErrorinstead ofValueError.RLSTenantsConfig._get()now raisesRLSConfigurationErrorinstead ofValueError. If you catchValueErrorfrom these functions, update your except clauses. Both are subclasses ofRLSTenantError, which is a subclass ofException. -
Multi-database GUC support: The middleware now sets GUC variables on all database aliases listed in
RLS_TENANTS["DATABASES"](default:["default"]). No changes needed for single-database setups. -
Strict mode (
STRICT_MODE=True): An opt-in setting that raisesNoTenantContextErrorwhen queries execute without an active RLS context. Off by default -- existing behavior is unchanged. -
New public API:
get_rls_context_active(),set_rls_context_active(),reset_rls_context_active()for tracking whether an RLS context is active. These are primarily used internally by strict mode but are available for custom middleware implementations.
Upgrade Steps¶
-
Update the package:
-
Update exception handling (if applicable):
# Before (1.1.0) from django_rls_tenants import tenant_context try: with tenant_context(tenant_id=None): ... except ValueError: ... # After (1.2.0) from django_rls_tenants import tenant_context from django_rls_tenants.exceptions import NoTenantContextError try: with tenant_context(tenant_id=None): ... except NoTenantContextError: ... -
Optional: enable multi-database support:
-
Optional: enable strict mode:
-
Verify: run
python manage.py checkandpython manage.py check_rls.
From 1.0.0 to 1.1.0¶
This release has no breaking changes. All existing code continues to work without modification.
What Changed¶
-
RLS policy SQL:
RLSConstraintnow generatesCASE WHENpolicies instead ofOR-based policies, improving readability and clarifying the evaluation structure. (The primary performance improvement comes from auto-scoping below, which enables composite index usage.) -
Automatic query scoping:
RLSManager.get_queryset()now addsWHERE tenant_id = Xautomatically when a tenant context is active (viatenant_context(),admin_context(), orRLSTenantMiddleware). This enables composite indexes and eliminates sequential scan penalties at scale. -
New public API:
get_current_tenant_id(),set_current_tenant_id(), andreset_current_tenant_id()are available for custom middleware and management commands that need direct access to the auto-scope state.
Upgrade Steps¶
-
Update the package:
-
Generate a new migration to update the RLS policy SQL:
This replaces the
OR-based policy with theCASE WHENstructure. The migration is safe to run on a live database -- it drops and recreates the policy in a single DDL statement. -
Verify: run
python manage.py check_rls.
Behavioral Notes¶
for_user()continues to work exactly as before.- Auto-scoping activates automatically -- no code changes required. If both
auto-scoping and
for_user()are active simultaneously, the query gets two redundantWHERE tenant_id = Xclauses. This is by design for defense-in-depth; the cost of the double equality check per row is negligible. TenantQuerySet.select_related()now auto-propagates tenant filters to joined RLS-protected tables when a tenant context is active.