Scalable row-level multi-tenancy for Django with PostgreSQL RLS
Project description
django-boundary
Scalable row-level multi-tenancy for Django with PostgreSQL Row Level Security.
Who Is This For?
django-boundary is for Django projects that serve multiple tenants from a single database. If your users belong to organisations, workspaces, teams, schools, clinics, clubs, or any other entity that should only see its own data — boundary handles the isolation.
Common use cases
SaaS platforms — Each customer (organisation, workspace, account) is a tenant. Their data is isolated at the ORM and database level. New tenants are provisioned via management command; no schema migrations required.
Marketplace platforms — Sellers, venues, or merchants each have their own
tenant. Products, orders, and analytics are scoped per-tenant. Platform-wide
reporting uses the unscoped manager.
Education / healthcare / government — Schools, clinics, or departments are tenants. Data residency requirements are met via regional routing (e.g. UK data stays in UK database, EU data in EU database).
Agency or white-label products — Each client gets their own tenant, resolved
by subdomain (client-a.app.com) or JWT claim from the auth provider.
Internal tools — Departments or business units are tenants, resolved via
session or header. STRICT_MODE catches accidental cross-department data
exposure during development.
When NOT to use boundary
- Single-tenant apps — no need for isolation machinery.
- Schema-per-tenant — use django-tenants instead (different trade-offs at scale).
- Non-PostgreSQL databases — the ORM layer works on any database, but RLS enforcement requires PostgreSQL 14+.
Features
- Automatic ORM filtering — queries are scoped to the active tenant by default
- PostgreSQL RLS — database-level enforcement as a second layer of defence
- Async-native — context propagation via
contextvars, works with sync and async Django - Pluggable resolvers — subdomain, header, JWT claim, session, or custom
- Strict mode — raises on unscoped queries (default: on), catches data leaks at development time
- Regional routing — route queries to geographically distinct databases for data residency compliance
- Celery integration — tenant context propagated via task headers, restored on workers
- Management commands — provision, deprovision (with NDJSON export), scoped run, run-all with parallelism
- Test utilities —
set_tenant(),TenantTestMixin,tenant_factory() - System checks — validates configuration at startup
- LEAKPROOF RLS functions — prevents query planner information leakage
- Zero assumptions — no opinion on auth, URL structure, or domain model
Installation
pip install django-boundary
Add to INSTALLED_APPS:
INSTALLED_APPS = [
...
"boundary",
...
]
Quick Start
1. Define your tenant model
# tenants/models.py
from boundary.models import AbstractTenant
class Organisation(AbstractTenant):
# Inherits: name, slug, region, is_active, created_at, updated_at
plan = models.CharField(max_length=50, default="free")
2. Configure settings
# settings.py
BOUNDARY_TENANT_MODEL = "tenants.Organisation"
BOUNDARY_STRICT_MODE = True # default — raises on unscoped queries
# Resolver chain: first match wins.
# For public-facing apps, SubdomainResolver should be first.
BOUNDARY_RESOLVERS = [
"boundary.resolvers.SubdomainResolver",
]
3. Add middleware
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"boundary.middleware.TenantMiddleware", # before session/auth
"django.contrib.sessions.middleware.SessionMiddleware",
...
]
4. Make models tenant-scoped
# bookings/models.py
from boundary.models import TenantModel
class Booking(TenantModel):
court = models.IntegerField()
start_time = models.DateTimeField()
That's it. Booking.objects.all() now automatically filters by the active
tenant. Creating a booking auto-populates the tenant field from context.
Example Configurations
SaaS with subdomain routing
Each customer gets a subdomain: acme.app.com, globex.app.com.
# models.py
class Workspace(AbstractTenant):
plan = models.CharField(max_length=20, default="starter")
max_users = models.IntegerField(default=5)
class Project(TenantModel):
name = models.CharField(max_length=200)
class Task(TenantModel):
project = models.ForeignKey(Project, on_delete=models.CASCADE)
title = models.CharField(max_length=200)
completed = models.BooleanField(default=False)
# settings.py
BOUNDARY_TENANT_MODEL = "core.Workspace"
BOUNDARY_RESOLVERS = ["boundary.resolvers.SubdomainResolver"]
# In a view — no tenant filtering needed, it's automatic
def dashboard(request):
projects = Project.objects.all() # only this workspace's projects
tasks = Task.objects.filter(completed=False) # only this workspace's tasks
return render(request, "dashboard.html", {"projects": projects, "tasks": tasks})
API with JWT-based tenancy
A React/mobile frontend sends a JWT containing the tenant ID. Useful for single-page apps where subdomains aren't practical.
# settings.py
BOUNDARY_TENANT_MODEL = "accounts.Account"
BOUNDARY_RESOLVERS = [
"boundary.resolvers.JWTClaimResolver", # reads tenant_id from JWT
]
BOUNDARY_JWT_CLAIM = "org_id" # custom claim name
The JWT is validated by your auth middleware (DRF, django-allauth, etc.). Boundary only reads the claim — it never validates signatures.
Marketplace with seller isolation
Sellers manage their own products, orders, and inventory. Platform admins
see everything via the unscoped manager.
class Seller(AbstractTenant):
contact_email = models.EmailField()
stripe_account_id = models.CharField(max_length=100, blank=True)
class Product(TenantModel):
name = models.CharField(max_length=200)
price = models.DecimalField(max_digits=10, decimal_places=2)
class Order(TenantModel):
product = models.ForeignKey(Product, on_delete=models.PROTECT)
quantity = models.IntegerField()
# settings.py
BOUNDARY_TENANT_MODEL = "sellers.Seller"
BOUNDARY_RESOLVERS = [
"boundary.resolvers.HeaderResolver", # internal API, trusted clients
]
# Seller's view — only sees their own products
def my_products(request):
return Product.objects.all()
# Admin analytics — sees all sellers
def platform_revenue():
return Order.unscoped.aggregate(total=Sum("product__price"))
Multi-region with data residency
UK customer data must stay in the UK database; EU data in the EU database.
# settings.py
BOUNDARY_TENANT_MODEL = "orgs.Organisation"
BOUNDARY_REGIONS = {
"uk": {"ENGINE": "django.db.backends.postgresql", "HOST": "uk.db.example.com", ...},
"eu-west": {"ENGINE": "django.db.backends.postgresql", "HOST": "eu.db.example.com", ...},
"us-east": {"ENGINE": "django.db.backends.postgresql", "HOST": "us.db.example.com", ...},
}
DATABASE_ROUTERS = ["boundary.routing.RegionalRouter"]
# Tenant has region="uk" — all queries automatically hit the UK database
with TenantContext.using(uk_tenant):
Patient.objects.create(name="Smith", nhs_number="123") # stored in UK DB
# Platform-wide reporting across all regions
from boundary.routing import all_regions
with all_regions() as aliases:
for alias in aliases:
count = Patient.objects.using(alias).count()
print(f"{alias}: {count} patients")
Internal tool with session-based switching
Staff users switch between departments via a dropdown. The selected department is stored in the session.
# settings.py
BOUNDARY_TENANT_MODEL = "departments.Department"
BOUNDARY_REQUIRED = False # allow unauthenticated pages
BOUNDARY_RESOLVERS = [
"boundary.resolvers.SessionResolver",
]
# Switch department view
def switch_department(request, dept_id):
dept = Department.objects.get(pk=dept_id)
request.session["boundary_tenant_id"] = str(dept.pk)
return redirect("dashboard")
How It Works
Architecture
HTTP Request / Celery Task / Management Command
|
v
RESOLUTION LAYER — TenantMiddleware + pluggable Resolvers
|
v
CONTEXT LAYER — TenantContext (ContextVar + DB session variable)
|
v
ORM LAYER — TenantManager auto-filters every queryset
|
v
ROUTING LAYER (optional) — RegionalRouter per-tenant DB alias
|
v
DATABASE LAYER — PostgreSQL RLS policies (defence in depth)
Defence in Depth
Two independent layers enforce tenant isolation:
- ORM layer —
TenantManagerfilters every queryset by the active tenant. This catches standard Django ORM usage. - PostgreSQL RLS — Row Level Security policies enforce isolation at the database level, catching raw SQL, third-party packages, and ORM bugs.
A bug in one layer is caught by the other.
Models
AbstractTenant
Convenience base for your tenant model. Provides common fields:
| Field | Type | Description |
|---|---|---|
name |
CharField(200) | Tenant name |
slug |
SlugField(unique) | URL-safe identifier |
region |
CharField(50) | Regional routing key (blank if single-region) |
is_active |
BooleanField | Inactive tenants are rejected by middleware (403) |
created_at |
DateTimeField | Auto-set on creation |
updated_at |
DateTimeField | Auto-set on save |
TenantModel / TenantMixin
Base class for tenant-scoped data models. Adds:
tenantForeignKey to your tenant model (CASCADE, non-nullable)objects—TenantManagerthat auto-filters by active tenantunscoped— plainManagerfor cross-tenant operations (admin, analytics)
class Booking(TenantModel):
court = models.IntegerField()
Auto-populate on save: When no tenant is set explicitly,
TenantModel.save() reads from TenantContext automatically.
Bulk operations:
bulk_create()— auto-populates tenant on objects wheretenant_idis Nonebulk_update()— validates all objects belong to the active tenant
Custom FK Field Names — make_tenant_mixin()
If your domain uses a different name for the tenant relationship (e.g.
merchant, organisation, workspace), use the factory instead of
TenantMixin:
from boundary.models import make_tenant_mixin
MerchantMixin = make_tenant_mixin("merchant")
class Product(MerchantMixin):
sku = models.CharField(max_length=50)
# Product.merchant is the FK — auto-filtering, auto-populate, bulk ops all work
# Product.objects.all() — filters by active tenant via the "merchant" field
# product.merchant — returns the tenant instance
The factory accepts the same FK options as Django's ForeignKey:
make_tenant_mixin(
"merchant",
on_delete=models.PROTECT, # default: CASCADE
related_name="products", # default: "%(app_label)s_%(class)s_set"
db_index=True, # default: True
null=False, # default: False
)
Alternatively, set BOUNDARY_TENANT_FK_FIELD in your settings to change
the default field name globally. TenantMixin itself always uses "tenant",
but the factory reads the setting when no explicit fk_field is passed.
Custom Terminology
Boundary error messages, verbose_name on FK fields, and middleware HTTP
responses all use a configurable label. By default the label tracks
BOUNDARY_TENANT_FK_FIELD, so a single setting changes everything:
# settings.py
BOUNDARY_TENANT_FK_FIELD = "merchant"
# → "No merchant is active in context."
# → 404 body: "Merchant not found."
# → FK verbose_name: "merchant"
To override independently, set BOUNDARY_TENANT_LABEL (used in user-facing
strings) and/or BOUNDARY_REQUEST_ATTR (the alias attached to the request
alongside request.tenant):
BOUNDARY_TENANT_FK_FIELD = "merchant" # FK column name
BOUNDARY_TENANT_LABEL = "shop" # error/UI copy says "shop"
BOUNDARY_REQUEST_ATTR = "merchant" # views read request.merchant
request.tenant is always set for backwards compatibility; the alias is
added in addition, never as a replacement.
Model Introspection
from boundary.models import is_tenant_model, get_tenant_fk_field
is_tenant_model(Product) # True
get_tenant_fk_field(Product) # "merchant"
is_tenant_model(Booking) # True
get_tenant_fk_field(Booking) # "tenant"
System checks, regional routing, and RLS verification all use
is_tenant_model() internally, so custom FK models are automatically
recognised.
Context
TenantContext
The core API for tenant context management:
from boundary.context import TenantContext
# Set and get
token = TenantContext.set(tenant)
tenant = TenantContext.get() # returns tenant or None
tenant = TenantContext.require() # returns tenant or raises TenantNotSetError
TenantContext.clear(token)
# Context manager (recommended)
with TenantContext.using(tenant):
Booking.objects.all() # filtered to this tenant
# Context automatically restored on exit
The context manager is savepoint-safe: it explicitly restores the DB session variable on exit rather than relying on PostgreSQL savepoint rollback.
Resolvers
Resolvers determine which tenant applies to an incoming request. Configure
via BOUNDARY_RESOLVERS — first match wins.
| Resolver | Source | Setting |
|---|---|---|
SubdomainResolver |
club.example.com -> slug lookup |
BOUNDARY_SUBDOMAIN_FIELD |
HeaderResolver |
X-Tenant-ID header (UUID first, slug fallback) |
BOUNDARY_HEADER_NAME |
JWTClaimResolver |
JWT payload claim (no signature validation) | BOUNDARY_JWT_CLAIM |
SessionResolver |
Django session key | BOUNDARY_SESSION_KEY |
ExplicitResolver |
request.boundary_tenant set by upstream code |
None |
Security note: Resolver ordering determines precedence. Placing
HeaderResolver first allows any HTTP client to set the tenant via header.
For public-facing apps, place SubdomainResolver first.
Custom resolvers
from boundary.resolvers import BaseResolver
class PathResolver(BaseResolver):
def resolve(self, request):
parts = request.path.split("/")
if len(parts) >= 3 and parts[1] == "t":
TenantModel = self.get_tenant_model()
try:
return TenantModel.objects.get(slug=parts[2], is_active=True)
except TenantModel.DoesNotExist:
return None
return None
Resolver cache
Resolvers that perform DB lookups cache results in a process-local LRU cache. Cache is invalidated automatically on tenant save/delete via Django signals, and by TTL (default: 60 seconds).
Row Level Security
RLS provides database-level enforcement independent of application code.
Migration operations
# In your migration file
from boundary.migrations_ops import EnableRLS, CreateTenantPolicy
class Migration(migrations.Migration):
operations = [
migrations.CreateModel(name="Booking", ...),
EnableRLS("Booking"),
CreateTenantPolicy("Booking"),
]
CreateTenantPolicy generates:
- A
LEAKPROOFhelper function (boundary_current_tenant_id()) that safely casts the session variable to the correct type - An isolation policy with
USING+WITH CHECK(enforces on SELECT, INSERT, UPDATE, DELETE) - An admin bypass policy for management commands
Type-aware
The RLS function detects whether your tenant model uses UUID or integer primary keys and generates the appropriate type cast.
Reversible
All operations are fully reversible via migrate --reverse.
Regional Routing
Route queries to geographically distinct databases for data residency compliance.
# settings.py
BOUNDARY_REGIONS = {
"eu-west": {"ENGINE": "django.db.backends.postgresql", "HOST": "eu.db.example.com", ...},
"us": {"ENGINE": "django.db.backends.postgresql", "HOST": "us.db.example.com", ...},
}
DATABASE_ROUTERS = ["boundary.routing.RegionalRouter"]
Tenant-scoped queries are routed to the tenant's region. Non-tenant models
(auth, sessions, etc.) always route to default.
from boundary.routing import all_regions, specific_region
# Iterate all regions
with all_regions() as aliases:
for alias in aliases:
count = Booking.objects.using(alias).count()
# Pin to a specific region
with specific_region("eu-west"):
bookings = Booking.objects.all()
Celery Integration
Tenant context is propagated to Celery tasks via headers.
from boundary.celery import tenant_task
@app.task
@tenant_task
def send_confirmation(booking_id):
# TenantContext.get() returns the correct tenant
booking = Booking.objects.get(id=booking_id)
For class-based tasks:
from boundary.celery import TenantTask
class GenerateReport(TenantTask, app.Task):
def run(self, report_id):
...
Management Commands
boundary_provision
python manage.py boundary_provision --name "Club A" --slug "club-a" --region eu-west
# Outputs: the new tenant's PK
boundary_deprovision
python manage.py boundary_deprovision --tenant club-a --export data.ndjson --yes
# Streams tenant data to NDJSON, then deletes
Supports --dry-run, --batch-size, --yes (skip confirmation).
boundary_run
python manage.py boundary_run --tenant club-a send_reminders
# Runs send_reminders with tenant context active
boundary_run_all
python manage.py boundary_run_all send_reminders --parallel 4 --region eu-west --json
# Runs against all active tenants, 4 workers, EU only, NDJSON output
Settings Reference
| Setting | Default | Description |
|---|---|---|
BOUNDARY_TENANT_MODEL |
Required | Dotted path to tenant model, e.g. "tenants.Organisation" |
BOUNDARY_TENANT_FK_FIELD |
"tenant" |
Default FK field name used by make_tenant_mixin() when no explicit name is passed |
BOUNDARY_TENANT_LABEL |
BOUNDARY_TENANT_FK_FIELD |
Human-readable term used in error messages, FK verbose_name, and middleware HTTP response bodies |
BOUNDARY_REQUEST_ATTR |
BOUNDARY_TENANT_FK_FIELD |
Extra attribute set on the request object alongside request.tenant (e.g. request.merchant). When equal to "tenant", no second attribute is added |
BOUNDARY_STRICT_MODE |
True |
Raise TenantNotSetError on unscoped queries |
BOUNDARY_REQUIRED |
True |
Return 404 if no resolver matches |
BOUNDARY_RESOLVERS |
["...SubdomainResolver"] |
Ordered resolver class paths |
BOUNDARY_SUBDOMAIN_FIELD |
"slug" |
Tenant field for subdomain lookup |
BOUNDARY_HEADER_NAME |
"X-Tenant-ID" |
HTTP header for HeaderResolver |
BOUNDARY_JWT_CLAIM |
"tenant_id" |
JWT payload claim |
BOUNDARY_SESSION_KEY |
"boundary_tenant_id" |
Session key for SessionResolver |
BOUNDARY_REGIONS |
None |
Regional DB configs (activates routing) |
BOUNDARY_REGION_FIELD |
"region" |
Tenant field storing region key |
BOUNDARY_DB_SESSION_VAR |
"app.current_tenant_id" |
PostgreSQL session variable |
BOUNDARY_WRAP_ATOMIC |
True |
Wrap requests in transaction.atomic() |
BOUNDARY_RESOLVER_CACHE_SIZE |
1000 |
LRU cache max entries |
BOUNDARY_RESOLVER_CACHE_TTL |
60 |
Cache TTL in seconds |
BOUNDARY_POST_PROVISION_HOOK |
None |
Callable after tenant provisioning |
BOUNDARY_PRE_DEPROVISION_HOOK |
None |
Callable before tenant deletion |
System Checks
| ID | Severity | Condition |
|---|---|---|
boundary.E001 |
Error | BOUNDARY_TENANT_MODEL missing or invalid |
boundary.E003 |
Error | Resolver class cannot be imported |
boundary.E004 |
Error | TenantMiddleware not in MIDDLEWARE |
boundary.E005 |
Error | BOUNDARY_REGIONS set but RegionalRouter not in DATABASE_ROUTERS |
boundary.E006 |
Error | Tenant-scoped table missing RLS — recognises TenantMixin and make_tenant_mixin models |
boundary.W001 |
Warning | STRICT_MODE is False |
Testing
In your tests
from boundary.testing import set_tenant, tenant_factory, TenantTestMixin
# Context manager
def test_isolation():
tenant_a = tenant_factory(name="A", slug="a")
tenant_b = tenant_factory(name="B", slug="b")
with set_tenant(tenant_a):
Booking.objects.create(court=1)
with set_tenant(tenant_b):
assert Booking.objects.count() == 0 # tenant_b sees nothing
# Mixin for TestCase
class BookingTests(TenantTestMixin, TestCase):
def test_auto_populate(self):
booking = Booking.objects.create(court=1)
assert booking.tenant == self.tenant
Unscoped operations
# Cross-tenant admin/analytics queries
all_bookings = Booking.unscoped.all()
# Explicitly set tenant on unscoped create
Booking.unscoped.create(court=1, tenant=specific_tenant)
Signals
| Signal | Arguments | Fired when |
|---|---|---|
tenant_resolved |
tenant, resolver, request |
After successful resolution |
tenant_resolution_failed |
request |
No resolver matched (REQUIRED=True) |
strict_mode_violation |
model, queryset |
Before TenantNotSetError is raised |
Requirements
- Python 3.12+
- Django 5.2+ (5.2 LTS and 6.0 supported)
- PostgreSQL 14+ (for RLS; ORM layer works with any database)
Comparison with django-tenants
| django-tenants | django-boundary | |
|---|---|---|
| Isolation | PostgreSQL schemas | Row-level + RLS |
| Scale ceiling | ~500 tenants | No architectural ceiling |
| Migration cost | O(n tenants) | O(1) |
| Async support | Thread-local (breaks async) | contextvars (native async) |
| Celery | Manual | Automatic via headers |
| Regional routing | Not supported | First-class |
| Dev enforcement | None | STRICT_MODE |
Licence
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_boundary-0.4.0.tar.gz.
File metadata
- Download URL: django_boundary-0.4.0.tar.gz
- Upload date:
- Size: 55.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2f5dbf0d2b26ba814ab41b60d70cbbf415dc05f24cbca1c1579b509eb01d40a8
|
|
| MD5 |
3dc4ef592e373c9e01dcbfff6cda6e7e
|
|
| BLAKE2b-256 |
e4c6c283e8db8279b8d589683eda9d6bbba8c45707ca36442184da55e5a9f1e4
|
Provenance
The following attestation bundles were made for django_boundary-0.4.0.tar.gz:
Publisher:
publish-boundary.yml on nigelcopley/icv-oss
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_boundary-0.4.0.tar.gz -
Subject digest:
2f5dbf0d2b26ba814ab41b60d70cbbf415dc05f24cbca1c1579b509eb01d40a8 - Sigstore transparency entry: 1980280441
- Sigstore integration time:
-
Permalink:
nigelcopley/icv-oss@1bf6bc965e3b9d6b834ab415374eb40e7c212202 -
Branch / Tag:
refs/tags/django-boundary/v0.4.0 - Owner: https://github.com/nigelcopley
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-boundary.yml@1bf6bc965e3b9d6b834ab415374eb40e7c212202 -
Trigger Event:
push
-
Statement type:
File details
Details for the file django_boundary-0.4.0-py3-none-any.whl.
File metadata
- Download URL: django_boundary-0.4.0-py3-none-any.whl
- Upload date:
- Size: 38.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a4e2a5610728b7ba7bb13f5ecb36073d20e43b21be33fd6cee03a6fffaf165af
|
|
| MD5 |
ada2ff5df07883cdce971bcb7035da76
|
|
| BLAKE2b-256 |
c90c2c1f36f133e077f9da381a9b370dd302e5c42d53a6faf3a700977ebc5ace
|
Provenance
The following attestation bundles were made for django_boundary-0.4.0-py3-none-any.whl:
Publisher:
publish-boundary.yml on nigelcopley/icv-oss
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_boundary-0.4.0-py3-none-any.whl -
Subject digest:
a4e2a5610728b7ba7bb13f5ecb36073d20e43b21be33fd6cee03a6fffaf165af - Sigstore transparency entry: 1980280682
- Sigstore integration time:
-
Permalink:
nigelcopley/icv-oss@1bf6bc965e3b9d6b834ab415374eb40e7c212202 -
Branch / Tag:
refs/tags/django-boundary/v0.4.0 - Owner: https://github.com/nigelcopley
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-boundary.yml@1bf6bc965e3b9d6b834ab415374eb40e7c212202 -
Trigger Event:
push
-
Statement type: