Multi-tenant applications are key to efficiently serving multiple clients from a single shared instance, offering cost savings, scalability and simplified maintenance. Such applications can enable hospitals and clinics to securely manage patient records, enable financial institutions to provide personalized banking services, and help streamline inventory management and customer relationships across multiple stores. The primary functionality of multi-tenant applications lies in their ability to serve numerous clients through a single application installation. In this architecture, each client, called a tenant, maintains complete data isolation, ensuring data privacy and security.
A number of third-party libraries are available for implementing multipurpose services in Django. However, custom multi-tenancy implementations can allow developers to innovate and tailor solutions to unique business needs and use cases. Therefore, in this blog, we will show the underlying logic of how to implement multi-tenancy using Django with high-level code.
Multi-tenancy approaches
Multipurpose offerings serve different requirements that may vary in their limitations. Tenants may have similar data structures and security requirements, or they may be looking for some flexibility that allows each tenant to have its own schema. There are therefore many approaches to achieving multi-tenancy:
- A common database with a common schema
- A shared database with an isolated schema
- Isolated database with a shared application server.
1. A common database with a common schema
This is the simplest way. It has a common database and schema. All tenant data will be stored in the same database and schema.
Create a tenant application and create two models: Tenant and Tenant Aware Model to store data about the tenant database.
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 of the methods of identifying tenants is to use a subdomain. Let’s say your main domain is www.example.com and the user’s subdomains are:
- customer1.example.com
- customer2.example.com
Write a method to extract the tenant from the request in the 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, please 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 ForeignKey to separate tenants. It’s simple, but there’s no way to restrict access to a single tenant’s data at the database level. Also, getting a tenant from a request and filtering on it happens across your entire codebase, not in a central location.
One solution to the above problem is to create a separate schema within the shared database to isolate tenant data. Let’s say you have two schemas cust1 and cust2.
Add this code to the 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’ll set up the schema in the middleware before any view code comes into the picture, so that any ORM code will pull and write data from the tenant 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 yours settings.MIDDLEWARES
MIDDLEWARE = [
# ...
'tenants.middlewares.TenantMiddleware',
]
3. Isolated database with shared application server
In this third option, we will use a separate database for all tenants. We will use thread local feature to store DB values during the thread life cycle.
Add multiple databases to the settings 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 the utils.py file:
def tenant_db_from_request(request):
return request.get_host().split(':')[0].lower()
To create 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 the database name. This TenantRouter will be assigned DATABASE_ROUTERS
in the 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()
To add TenantMiddleware
and CustomDBRouter
in the settings.py file:
MIDDLEWARE = [
# ...
"tenants.middlewares.TenantMiddleware",
]
DATABASE_ROUTERS = ["tenants.router.TenantRouter"]
Conclusion
You can choose the way to implement multi-tenancy according to your requirement and level of complexity. However, an isolated DB and schema is the best way to isolate data when you don’t want to mix all tenant data into one database for security reasons.