Multi-tenant applications are crucial for efficiently serving multiple clients from a single shared instance, offering cost savings, scalability, and simplified maintenance. Such applications can allow hospitals and clinics to manage patient records securely, can enable financial institutions to provide personalized banking services, and can help streamline inventory management and customer relationship management across multiple stores. The primary functionality of multi-tenant applications resides in their capacity to serve numerous clients through a single installation of the application. In this architecture, each client, referred to as a tenant, maintains complete data isolation, ensuring data privacy and security.
There are multiple third-party libraries available to implement multi-tenancy in Django. However, custom implementations of multi-tenancy can allow developers to innovate and tailor solutions according to unique business needs and use cases. Therefore, in this blog we will show the core logic with a high-level code of how multi-tenancy is implemented using Django.
Multi-tenancy offers to serve diverse requirements that may vary in their constraints. Tenants may have similar data structures and security requirements or might be looking for some flexibility that allows each tenant to have their own schema. Therefore, there are many approaches to achieving multi-tenancy
1. Shared database with shared schema:
This is the simplest method. It has a shared database and schema. All tenant’s data will be stored in the same DB and schema.
Create a tenant app and create two models: Tenant and Tenant Aware Model to store Tenant base information.
class Tenant(models.Model):
name = models.CharField(max_length=200)
subdomain_prefix = models.CharField(max_length=200, unique=True)
class TenantAwareModel(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
class Meta:
abstract = True
Inherit TenantAwareModel into your all app models:
class User(TenantAwareModel):
...
class Order(TenantAwareModel):
...
Identifying Tenants:
One method to identify tenants is to use a subdomain. Let’s say your main domain is www.example.com, and customer subdomains are:
Write a method to extract tenant from request in utils.py file:
from .models import Tenant
def hostname_from_request(request):
# split at `:` to remove port
return request.get_host().split(':')[0].lower()
def tenant_from_request(request):
hostname = hostname_from_request(request)
subdomain_prefix = hostname.split('.')[0]
return Tenant.objects.filter(subdomain_prefix=subdomain_prefix).first()
Use tenant_from_request method in views:
from tenants.utils import tenant_from_request
class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.all()
serializer_class = OrderSerializer
def get_queryset(self):
tenant = tenant_from_request(self.request)
return super().get_queryset().filter(tenant=tenant)
Also update ALLOWED_HOSTS your settings.py. Mine looks like this:
ALLOWED_HOSTS = ['example.com', '.example.com'].
2. Shared database with isolated schema:
In the first option, we used a ForeignKey to separate the tenants. It is simple but there is no way to limit access to a single tenant’s data at the DB level. Also getting the tenant from the request and filtering on it is all over your codebase, rather than a central location.
One solution to the above problem is to create a separate schema within a shared database to isolate tenant wise data. Let’s say you have two schemas cust1 and cust2.
Add this code to utils.py file:
def get_tenants_map():
# cust1 and cust2 are your database schema names.
return {
"cust1.example.com": "cust1",
"cust2.example.com": "cust2",
}
def hostname_from_request(request):
return request.get_host().split(':')[0].lower()
def tenant_schema_from_request(request):
hostname = hostname_from_request(request)
tenants_map = get_tenants_map()
return tenants_map.get(hostname)
def set_tenant_schema_for_request(request):
schema = tenant_schema_from_request(request)
with connection.cursor() as cursor:
cursor.execute(f"SET search_path to {schema}")
We will set the schema in the middleware before any view code comes into picture, so any ORM code will pull and write the data from the tenant’s schema.
Create a new middleware like this:
from tenants.utils import set_tenant_schema_for_request
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
set_tenant_schema_for_request(request)
response = self.get_response(request)
return response
And add it to your settings.MIDDLEWARES
MIDDLEWARE = [
# ...
'tenants.middlewares.TenantMiddleware',
]
3. Isolated database with a shared app server:
In this third option, We will use a separate Database for all tenants. We will use a thread local feature to store db value during the life-cycle of thread.
Add multiple databases in setting file:
DATABASES = {
"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "default.db"},
"cust1": {"ENGINE": "django.db.backends.sqlite3", "NAME": "cust1.db"},
"cust2": {"ENGINE": "django.db.backends.sqlite3", "NAME": "cust2.db"},
}
Add this code to utils.py file:
def tenant_db_from_request(request):
return request.get_host().split(':')[0].lower()
Create a TenantMiddleware like this:
import threading
from django.db import connections
from .utils import tenant_db_from_request
import threading
from django.db import connections
from .utils import tenant_db_from_request
THREAD_LOCAL = threading.local()
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
db = tenant_db_from_request(request)
setattr(THREAD_LOCAL, "DB", db)
response = self.get_response(request)
return response
def get_current_db_name():
return getattr(THREAD_LOCAL, "DB", None)
def set_db_for_router(db):
setattr(THREAD_LOCAL, "DB", db)
Now write a TenantRouter class to get a database name. This TenantRouter will be assigned to DATABASE_ROUTERS in settings.py file.
from tenants.middleware import get_current_db_name
class CustomDBRouter:
def db_for_read(self, model, **hints):
return get_current_db_name()
def db_for_write(self, model, **hints):
return get_current_db_name()
def allow_relation(self, obj1, obj2, **hints):
return get_current_db_name()
def allow_migrate(self, db, app_label, model_name=None, **hints):
return get_current_db_name()
Add TenantMiddleware and CustomDBRouter to settings.py file:
MIDDLEWARE = [
# ...
"tenants.middlewares.TenantMiddleware",
]
DATABASE_ROUTERS = ["tenants.router.TenantRouter"]
You can choose a way to implement multi-tenancy per requirement and complexity level. However, an isolated DB and schema is the best way to keep data isolated when you do not want to mix all tenants data in a single database due to security concerns.