Troubleshooting
Symptom-first reference for django-boundary. Find your symptom, read the likely cause, apply the fix. For the exhaustive settings and check tables, see the README.
All boundary exceptions subclass boundary.exceptions.BoundaryError, so you can catch the whole family with a single except BoundaryError.
Quick lookup
| Symptom | Likely cause | Fix |
|---|---|---|
TenantNotSetError on a query |
No active tenant in context and BOUNDARY_STRICT_MODE is on |
Wrap the call in TenantContext.using(tenant) or run inside a request handled by TenantMiddleware |
| HTTP 404 "Tenant not found" | No resolver matched the request and BOUNDARY_REQUIRED is on |
Fix the resolver input (subdomain, header, session) or set BOUNDARY_REQUIRED = False |
| HTTP 403 "Tenant is inactive" | Resolved tenant has is_active = False |
Reactivate the tenant or expect this for suspended tenants |
TenantNotFoundError in a Celery task |
Task header references a tenant pk that no longer exists | Configure dead-letter routing; do not retry (it is excluded from autoretry_for) |
RegionNotConfiguredError |
Active tenant's region is not in BOUNDARY_REGIONS |
Add the region to BOUNDARY_REGIONS or correct the tenant's region value |
| Queries return nothing | No tenant set, or wrong tenant set | Set the correct tenant in context before querying |
| Data not isolated between tenants | BOUNDARY_STRICT_MODE = False and/or RLS not applied |
Enable strict mode and add the EnableRLS migration operation |
| Tenant context lost in a Celery task or thread | Context does not auto-propagate to new threads/processes | Use TenantTask / @tenant_task, or set context manually inside the worker |
boundary.E001 at startup |
BOUNDARY_TENANT_MODEL missing or invalid |
Set BOUNDARY_TENANT_MODEL = "app_label.ModelName" |
boundary.E003 at startup |
A resolver in BOUNDARY_RESOLVERS cannot be imported |
Fix the dotted path |
boundary.E004 at startup |
TenantMiddleware not in MIDDLEWARE |
Add boundary.middleware.TenantMiddleware to MIDDLEWARE |
boundary.E006 at startup |
A tenant-scoped table is missing forced RLS | Run the EnableRLS migration operation for that model |
boundary.E007 at startup |
An expected-adopted table is missing its tenant_id column, its RLS state, a policy, or its composite unique constraints |
Apply an AdoptTenantApp migration for that app, or exclude the model |
boundary.E008 at startup or at test-database creation |
DEBUG is False and BOUNDARY_STRICT_MODE or BOUNDARY_SET_DB_SESSION_VAR is False. The test runner sets DEBUG = False, so a test settings module trips this too |
Restore the setting to True, or add "boundary.E008" to SILENCED_SYSTEM_CHECKS in the settings module that disables it |
IntegrityError on tenant_id with no tenant |
A write to an adopted table with no active tenant context | Wrap the write in TenantContext.using(tenant); admin_bypass() does not help |
IntegrityError on a value another tenant already uses |
unique=True on a tenant-scoped model is global, not per-tenant |
Replace it with tenant_unique("field") in Meta.constraints |
boundary.W001 at startup |
BOUNDARY_STRICT_MODE is off |
Set BOUNDARY_STRICT_MODE = True |
Exceptions
TenantNotSetError
Triggers when: you run a query through a TenantManager (the default manager on tenant-scoped models) while no tenant is active in context, and BOUNDARY_STRICT_MODE is True (the default). It is also raised by TenantContext.require() directly.
The message names your configured tenant label, for example "No merchant is active in context" when BOUNDARY_TENANT_LABEL (or BOUNDARY_TENANT_FK_FIELD) is merchant.
Fix: establish a tenant before the query.
from boundary.context import TenantContext
with TenantContext.using(tenant):
bookings = Booking.objects.all() # filtered to tenant
Inside a normal request this is handled for you by TenantMiddleware. The error usually appears in management commands, shells, Celery tasks, or background threads where no middleware ran. See Common gotchas below.
For a service function or task that already receives the tenant as an argument, the @tenant_scoped decorator is tidier than a manual with block:
from boundary.context import tenant_scoped
@tenant_scoped("merchant")
def run_audit(merchant, since):
return AccountAudit.objects.filter(created__gte=since) # auto-scoped
In a test that calls a class-based view directly (where RequestFactory bypasses TenantMiddleware), use call_view from boundary.testing, which activates the tenant for you:
from boundary.testing import call_view
response = call_view(BookingListView, tenant=tenant_a)
Note: a
strict_mode_violationsignal fires just before this exception is raised, so you can hook logging or metrics onto it.
TenantResolutionError
Triggers when: a resolver raises an unexpected exception during resolution.
In the shipped TenantMiddleware, a failing resolver is wrapped in a TenantResolutionError, logged, and passed to the _on_resolver_error(request, resolver_path, error) hook before the chain falls through to the next resolver. The default hook is a no-op (skip and continue). Override it in a TenantMiddleware subclass and raise error if you want a failing resolver to abort resolution rather than be skipped. In custom resolution code, raise TenantResolutionError for unexpected failures and let return None mean "no match".
Fix: inspect the logged traceback for the failing resolver (logged under boundary.middleware with resolver_name), then fix the resolver logic or its inputs.
TenantInactiveError
Triggers when: a resolved tenant has is_active = False.
In a request, TenantMiddleware builds a TenantInactiveError and hands it to the _handle_inactive_tenant(request, tenant, exc) hook, which by default translates it to an HTTP 403 ("
Fix: reactivate the tenant (is_active = True) if the rejection is wrong, or treat the 403 as expected for suspended tenants.
TenantNotFoundError
Triggers when: a Celery task carries a tenant pk in its headers, but that tenant no longer exists in the database when the worker tries to restore context (_restore_tenant_context).
Fix: this is deliberately excluded from autoretry_for (BR-CEL-003) because retrying will never succeed. Configure dead-letter routing for affected tasks so they are parked rather than retried forever. If tenants are deleted while tasks are in flight, drain or revoke their queued tasks as part of deprovisioning (see BOUNDARY_PRE_DEPROVISION_HOOK).
RegionNotConfiguredError
Triggers when: boundary.routing.require_region() is called and the active (or given) tenant's region is not present in BOUNDARY_REGIONS, no tenant is active, or BOUNDARY_REGIONS is unset.
Fix: add the region to BOUNDARY_REGIONS, or correct the tenant's region value. Note that RegionalRouter itself never raises this: a Django router must return an alias, so it falls back to the default database (logging at debug level). Call require_region() where silent fallback to default is unacceptable, for example at provisioning time or before a regional batch job.
Fix: add the missing region key to BOUNDARY_REGIONS, or correct the value stored on the tenant's region field (BOUNDARY_REGION_FIELD, default region).
System checks
Boundary registers Django system checks that run at startup and during test collection. Resolve errors (E0xx) before deploying; warnings (W0xx) are advisory.
boundary.E001: tenant model missing or invalid
Triggers when: BOUNDARY_TENANT_MODEL is unset, or its value does not refer to an installed model.
Fix:
# settings.py
BOUNDARY_TENANT_MODEL = "accounts.Tenant" # app_label.ModelName
Confirm the app is in INSTALLED_APPS and the app_label.ModelName format is exact.
boundary.E003: resolver cannot be imported
Triggers when: a dotted path in BOUNDARY_RESOLVERS raises ImportError when imported.
Fix: correct the path. The default is ["boundary.resolvers.SubdomainResolver"]. Each entry must be an importable resolver class.
boundary.E004: middleware missing
Triggers when: neither boundary.middleware.TenantMiddleware nor a
supported external tenant-context middleware is in MIDDLEWARE.
Fix: add boundary's middleware before SessionMiddleware if you use its
session resolver:
MIDDLEWARE = [
"boundary.middleware.TenantMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
# ...
]
An ICV consumer using icv-tenants instead configures only
icv_tenants.middleware.TenantContextMiddleware. It authorises the tenant
selection and bridges it into boundary. Do not configure both middleware
classes. The legacy
icv_identity.tenants.middleware.TenantContextMiddleware remains recognised
during the migration window.
boundary.E006: Row Level Security not enabled
Triggers when: running on PostgreSQL, a tenant-scoped table does not have RLS both enabled and forced (relrowsecurity and relforcerowsecurity in pg_class). The check recognises models using TenantMixin, make_tenant_mixin(), or any model exposing a _boundary_fk_field attribute. Tables that do not exist yet (pre-migration) are skipped, and the check is a no-op on non-PostgreSQL backends.
Fix: add the EnableRLS migration operation for the affected model so the database enforces isolation even for raw SQL and superuser-less connections. RLS is the defence-in-depth layer beneath ORM filtering; do not rely on the ORM alone.
boundary.E007: adopted table is not in the expected state
Triggers when: running on PostgreSQL, a table boundary expects to be adopted is not in the state adoption produces. The expected set is every concrete model of an app listed in BOUNDARY_TENANT_APPS, minus BOUNDARY_ADOPT_EXCLUDE, minus models already scoped by a boundary mixin or path mixin, minus the tenant model itself. Per table, the check reports:
- the
tenant_idcolumn is absent, or present with a type other than the one your tenant model's primary key derives; - Row Level Security is not both enabled and forced;
- the
boundary_tenant_isolationpolicy is absent; - the
boundary_admin_bypasspolicy is absent; - a unique constraint or unique index other than the primary key is not composite leading with
tenant_id.
It also reports a deny-listed or uninstalled app label in BOUNDARY_TENANT_APPS, which is the one condition needing no database connection. Like boundary.E006, the check is PostgreSQL-only, skips when the connection is unavailable, and reports boundary.W007 when a probe fails on a live connection rather than staying silent.
Fix: add an AdoptTenantApp migration for the app in your own app's migrations, or exclude the offending model via BOUNDARY_ADOPT_EXCLUDE. See Adopt a third-party app into your tenancy.
boundary.E007 appeared after a package upgrade
Cause: one of two things, and the report tells you which.
- A model added upstream.
boundary.E007derives its expected set from the live app registry, whileAdoptTenantAppderived its set from the historical migration state at the time you wrote it. A model the new package version added is in one and not the other, so its table has notenant_idcolumn and the check reports condition 1. This is the intended signal, not a false positive. - A unique constraint recreated globally. An upstream migration that drops and recreates an index replaces your composite constraint with a global one, restoring cross-tenant uniqueness with no other signal anywhere in the system, and the check reports condition 5. This is why
boundary.E007is an Error rather than a Warning, and whymanage.py checkbelongs in your deploy pipeline before any adopted-package upgrade reaches production.
Fix: add a second AdoptTenantApp migration for the same app:
# myapp/migrations/0011_adopt_allauth_account_again.py
operations = [
AdoptTenantApp("account"),
]
The operation is idempotent per table: a table already carrying a tenant_id column of the expected type is skipped unchanged, so this adopts only the newly added tables and re-establishes the constraints that drifted. If the new table already holds rows, supply backfill_tenant with the tenant those rows belong to, or the operation refuses (see the how-to). A table carrying a tenant_id column of a different type is refused rather than altered: boundary cannot tell its own column from one the package now genuinely declares.
boundary.E008: unsafe posture with DEBUG = False
Triggers when: settings.DEBUG is False and BOUNDARY_STRICT_MODE or BOUNDARY_SET_DB_SESSION_VAR is False. One Error per offending setting, each naming its own.
Both settings default to the safe value, so reaching this state took an explicit opt-out somewhere. The check exists because an opt-out set during development and never revisited is precisely how a deployment reaches production with no enforced boundary: BOUNDARY_STRICT_MODE = False means a query with no active tenant returns every tenant's rows instead of raising, and BOUNDARY_SET_DB_SESSION_VAR = False means nothing writes the variable every RLS policy reads.
Fix: set the named setting back to True. If the choice is deliberate (a genuinely ORM-only deployment), record it by adding "boundary.E008" to SILENCED_SYSTEM_CHECKS in the settings module that disables the flag.
boundary.E008 at test-database creation
You upgraded, and now pytest or manage.py test fails before a single test runs, with SystemCheckError naming boundary.E008:
SystemCheckError: System check identified some issues:
ERRORS:
?: (boundary.E008) BOUNDARY_STRICT_MODE is False with DEBUG = False. ...
Cause: Django's test runner sets DEBUG = False for the duration of the run, and migrate runs the system checks when it creates the test database. Your test settings module sets BOUNDARY_STRICT_MODE = False or BOUNDARY_SET_DB_SESSION_VAR = False, which was previously harmless because both conditions were only warnings. This is the first place most projects meet E008; it is not telling you anything about your production settings.
Fix: decide which one you meant.
If the test suite genuinely needs strict mode off (tests that query without a tenant on purpose, for example), keep it and record the choice in the test settings module:
# myproject/settings/test.py
from .base import * # noqa: F403
BOUNDARY_STRICT_MODE = False
SILENCED_SYSTEM_CHECKS = ["boundary.E008"]
If only a handful of tests need it, prefer removing the module-level line and overriding per test instead, which keeps the rest of the suite honest. The surrounding check run has already happened by then, so override_settings never trips E008:
from django.test import override_settings
@override_settings(BOUNDARY_STRICT_MODE=False)
def test_unscoped_query_returns_everything():
...
Note that SILENCED_SYSTEM_CHECKS in your production settings does not help here, and silencing it there to fix a CI failure would hide the production case the check exists for. Put the entry in the module that sets the flag.
boundary.W001: strict mode off
Triggers when: BOUNDARY_STRICT_MODE is False.
With strict mode off, a TenantManager query with no active tenant returns the full, unfiltered queryset instead of raising TenantNotSetError. That is a silent cross-tenant leak risk.
Fix: set BOUNDARY_STRICT_MODE = True (the default) so missing context fails loudly. Only disable it deliberately, and only when RLS (E006) is enforcing isolation at the database layer.
Common gotchas
Queries return nothing
Cause: no tenant is set, or the wrong tenant is set. A TenantManager filters by the active tenant via TenantContext.get(). With strict mode off and no tenant, you instead get everything; with the wrong tenant, you get that tenant's rows only.
Fix: confirm the active tenant before querying.
from boundary.context import TenantContext
assert TenantContext.get() is not None # who is active?
with TenantContext.using(expected_tenant):
rows = Booking.objects.all()
In a request, verify TenantMiddleware resolved a tenant (request.tenant, or request.<BOUNDARY_REQUEST_ATTR>). Outside a request, you must set context yourself.
Data not isolated between tenants
Cause: isolation has two layers and one is missing.
- ORM filtering depends on tenant-scoped models using
TenantManagerandBOUNDARY_STRICT_MODE = True. With strict mode off, unscoped queries leak across tenants (W001). - Row Level Security is the database-level guarantee. If RLS is not enabled and forced, raw SQL,
.using()to a region, or queries through a non-tenant manager can cross boundaries (E006).
Fix: keep strict mode on, ensure scoped models use TenantMixin / make_tenant_mixin(), and apply the EnableRLS migration operation. Resolve any E006 errors before trusting isolation in production.
IntegrityError on tenant_id with no tenant
Cause: a write to an adopted table (one belonging to an app in BOUNDARY_TENANT_APPS, given its tenancy by the AdoptTenantApp migration operation) while no tenant is active in context. The column is NOT NULL DEFAULT boundary_current_tenant_id(); with no tenant set, that default evaluates to NULL and the NOT NULL constraint rejects the row.
This is fail-closed behaviour working as designed. Do not make the column nullable and do not relax the isolation policy to get past it.
admin_bypass() is not the fix. The NOT NULL constraint is evaluated before any policy is consulted, and the bypass flag has no bearing on what boundary_current_tenant_id() returns. A write inside admin_bypass() with no tenant active raises exactly the same IntegrityError.
Fix: run the write inside a tenant context for the tenant the rows belong to.
from boundary.context import TenantContext
with TenantContext.using(tenant):
EmailAddress.objects.create(user=user, email="a@example.com")
Unlike a mixin-scoped model, there is no TenantNotSetError and no strict-mode branch to catch this earlier: an adopted table has no ORM layer at all, so the database is the first thing that notices.
The paths that commonly hit this are createsuperuser and loaddata (both fixable by wrapping them in a tenant context), plus post_migrate handlers and an adopted app's own data migrations, which run inside migrate with no tenant and no call site to wrap. Those last two have no remedy: exclude the model via BOUNDARY_ADOPT_EXCLUDE, or seed the rows per tenant from your own code. See Adopt a third-party app into your tenancy and Run cross-tenant admin operations.
IntegrityError on a value another tenant already holds
Cause: unique=True on a field of a tenant-scoped model is enforced across every tenant, not within one. Django built a single global unique index, so the second tenant to use INV-001 collides with the first tenant's row, which is one they cannot see. Meta.unique_together and a hand-written UniqueConstraint whose fields do not lead with the tenant column behave the same way.
The symptom is distinctive: the constraint name in the error mentions neither tenancy nor the other tenant, and the offending row is invisible to the user, the view and the form that produced it. If a user tells you "it says this reference already exists but I have never used it", this is why.
Fix: drop the unique=True and express the constraint per tenant:
from boundary.models import TenantModel, tenant_unique
class Invoice(TenantModel):
reference = models.CharField(max_length=32)
class Meta:
constraints = [tenant_unique("reference")]
Changing an existing constraint is a drop and a create, and it fails if current rows already violate the new form, so read Uniqueness within a tenant before running the migration in production.
If the field really is meant to be globally unique (the tenant model's own slug, an external reference issued by a third party), leave it alone; global is correct there.
Regional queries all hit the default database
Cause: BOUNDARY_REGIONS is set, but boundary.routing.RegionalRouter is
not in DATABASE_ROUTERS. Without the router, tenant-scoped queries are never
routed to their region database, so everything falls through to default. There
is no system check for this, so it fails silently.
Fix:
DATABASE_ROUTERS = ["boundary.routing.RegionalRouter"]
See Deploy across multiple regions.
Context lost in Celery or background threads
Cause: TenantContext is backed by a ContextVar. It propagates correctly across sync and async views, middleware, and management commands within the same execution context, but it does not automatically cross into a Celery worker process or a freshly spawned thread.
Fix for Celery: use the provided helpers, which serialise the tenant (and region) into task headers at dispatch and restore context on the worker.
from boundary.celery import tenant_task, TenantTask
# Function decorator (worker side restore)
@app.task
@tenant_task
def send_confirmation(booking_id):
booking = Booking.objects.get(id=booking_id)
# Or a base class that handles both dispatch and execution
class GenerateReport(TenantTask, app.Task):
def run(self, report_id):
...
If the referenced tenant has been deleted by the time the task runs, you get TenantNotFoundError (see above).
Fix for threads: set context explicitly inside the new thread, since ContextVar values are not copied automatically across thread boundaries here.
from boundary.context import TenantContext
def worker(tenant):
with TenantContext.using(tenant):
... # tenant is active in this thread
threading.Thread(target=worker, args=(tenant,)).start()
Related
- README: full settings reference and system check table.
- Configuration keys referenced here:
BOUNDARY_TENANT_MODEL,BOUNDARY_STRICT_MODE,BOUNDARY_REQUIRED,BOUNDARY_RESOLVERS,BOUNDARY_REGIONS,BOUNDARY_REGION_FIELD,BOUNDARY_TENANT_FK_FIELD,BOUNDARY_TENANT_LABEL,BOUNDARY_REQUEST_ATTR.