Post

Multi-Tenancy in Django

Have you ever wanted to build an application that canna serve more than one customer? And not just serving but also having each customer’s data isolated in their own separate databases?

In this article we will learn how to implement multi tenancy in django.

We have seen many organizations trying to move to the Software as a Service (SaaS) direction and with this you might find yourself wanting to serve different customers with the same application and mixing up their data in one database is not a good idea, so how can we combat this problem.

Before beginning this article I take it you have some basic understanding of both python and django.

Create a Django Application

For this article we try create a system that stores different books that a library has and you want this system to be used by different libraries.

Go to your preferred directory and create a new directory with any name of your choice, I’ll be using multiTenancyProject and navigate into the folder.

Create a virtual environment by running the command below:

1
virtualenv .venv

.venv is the name of the virtual environment You might get an error if you haven’t installed virtualenv. You can install virtualenv by running pip install virtualenv

Activate the virtual environment and install the required libraries which in our case is django.

1
2
3
source .venv/Scripts/activate # for linux and macOS
.venv\Scripts\activate # for windows
pip install django

After the libraries have finished installing we can create our project by running:

1
django-admin startproject multitenant .

Then we can create our app named library

1
py manage.py startapp library

We now need to register our newly created app into the list of installed apps in settings.py

1
2
3
4
5
6
7
8
9
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "library"   #new
]

Add and register our models

After that is set we can go ahead and define the schema of our database. Inside the library folder, there is models.py file, add the following:

1
2
3
4
5
6
7
8
class Book(models.Model):
    title = models.CharField(max_length=100)
    author = models.CharField(max_length=100)
    publisher = models.CharField(max_length=100)
    publication_date = models.DateField()

    def __str__(self):
        return self.title

The above code specifies that we will have a Books table that will have title, author, publisher and publication_date fields. We now need to register the model in admin.py file so that we can be able to see it in django’s admin site.

Open admin.py file in the library folder and add the following

1
2
3
4
5
6
7
8
9
10
# admin.py 

from django.contrib import admin
from .models import Book

class BookAdmin(admin.ModelAdmin):
    # This will display the fields in the admin interface
    list_display = ('title', 'author', 'publisher', 'publication_date')

admin.site.register(Book, BookAdmin)

Create a view

Inside views.py file in the library folder, we will add a function that fetches data form our database and returns them to be viewed by the user.

1
2
3
4
5
6
7
8
9
10
11
12
13
# views.py 

from django.shortcuts import render
from .models import Book

def book_list(request):
    template_name = 'library/index.html'
    books = Book.objects.all()
    context = {
        'books': books
    }

    return render(request, template_name, context)

Configure the URLs

We now need o create a URL pattern that points to the view we created above. For this we need to create a new file called urls.py inside the library folder and add the following code:

1
2
3
4
5
6
7
8
# urls.py

from django.urls import path
from . import views

urlpatterns = [
    path('', views.book_list, name='index'),
]

After, we have to point the newly created urls.py file to the main urls.py file in the root project directory. Open the file and add the code below:

1
2
3
4
5
6
7
8
9
# urls.py - at project level

from django.contrib import admin
from django.urls import path, include # added include

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("library.urls")) # new 
]

Templates

Create a new directory and name it templates at the project level and inside it create another directory called library, then a file named index.html inside the library directory and add the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<!-- index.html -->

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <meta name="Description" content="Enter your description here" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.0/css/bootstrap.min.css">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
    <link rel="stylesheet" href="assets/css/style.css">
    <title>Books List</title>
</head>

<body>
    <div class="container">
        <div class="row">
            <div class="col-md-12">
                <h1>Books List</h1>
            </div>
        </div>
        <div class="row">
            <div class="col-md-12">
                <table class="table table-striped">
                    <thead>
                        <tr>
                            <th scope="col">ID</th>
                            <th scope="col">Title</th>
                            <th scope="col">Author</th>
                            <th scope="col">Publisher</th>
                            <th scope="col">Actions</th>
                        </tr>
                    </thead>
                    <tbody>
                        
                    </tbody>
                </table>
            </div>
        </div>
    </div>


    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.slim.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.16.1/umd/popper.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.0/js/bootstrap.min.js"></script>
</body>

</html>

We need to tell django where to get the templates folder from. open settings.py file and look for a section with TEMPLATES and make it look like below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# settings.py 

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": ['templates'], # added templates
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

Running the Project

By default django uses sqlite database, so will just run our migrations and create a superuser thereafter so that we can be able to log into django’s admin site.

To run our migrations we run the following commands:

1
2
py manage.py makemigrations
py manage.py migrate

Run the commands in that order. The first command makemigrations is used because we create a new model Book then the second command now commit the changes to our database.

To create a super user, we do that by running:

1
py manage.py createsuperuser

Populate the details that you will be prompted to enter and remember those details as we will use them to log into the admin site. Run the project now by using:

1
py manage.py runserver

If everything runs without any issue, then we are good to proceed, navigate to http://localhost:8000/admin and login with the details you had created to access the admin site. Add some books and navigate to the home page i.e http://localhost:8000/ to see if the books will be visible. You should see something like what is below:

books

Implementing Multi-Tenancy

We need to structure our system to support multiple databases i.e each client will be assigned their own database.

Add Multiple databases

Based on the number of clients/tenants, we need to define each database for each one of them. Assuming the system is being used by three clients, namely Acumen, Maximus and Grand, we will add them in the databases dictionary in settings.py file as shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# settings.py 

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
    },
    "acumen": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "acumen.sqlite3",
    }, # new
    "maximus": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "maximus.sqlite3",
    }, # new
    "grand": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "grand.sqlite3",
    }, # new
}

Determining which database to be accessed

Our app must be able to determine which database the tenant should read from when a request is delivered to the server and we can accomplish that with the aid of a few helper functions.

Inside our library folder, we will add a file called utils.py and add the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# utils.py 

from django.db import connection

def get_hostname(request):
    return request.get_host().split(':')[0].lower()

def get_tenants_map():
    return {
        "acumen.library.local": "acumen",
        "maximus.library.local": "maximus",
        "grand.library.local": "grand",
    }

def get_db_name(request):
    hostname = get_hostname(request)
    tenants_map = get_tenants_map()
    return tenants_map.get(hostname, "default")

From the above code:

  • get_hostname() - this takes the request and removes the ports and returns a bare URL
  • get_tenants_maps() - this returns a dictionary with the added tenant’s urls as keys and the database names as values.
  • get_db_name() - this returns the name of the database that matches the request passed to it.

Middleware

Middleware is a framework that helps ypu plug into the request or response processing in django.

In the library folder, create a new file and name it middleware.py and add the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# middleware.py

import threading
from django.db import connections
from .utils import get_db_name

ThreadLocal = threading.local()

class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        db = get_db_name(request)
        setattr(ThreadLocal, 'tenant', db)
        response = self.get_response(request)
        return response


def get_current_tenant():
    return getattr(ThreadLocal, 'tenant', 'default')

def set_current_tenant(db):
    setattr(ThreadLocal, 'tenant', db)

In the above code, using the callable object which gets the name of the database and passes it to the ThreadLocal variable.

We also have a function that gets the current database name from the ThreadLocal variable and another function to set the name from the database router.

Route the databases

Sung the data passed to the middleware we can be able to hook that into our database routing process which will create a central place where django can look up the database that the tenant request should call.

Create a new file named router.py in the library directory and add the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
# router.py 

from . middleware import get_current_tenant

class TenantRouter:
    def db_for_read(self, model, **hints):
        return get_current_tenant()

    def db_for_write(self, model, **hints):
        return get_current_tenant()

    def allow_relation(self,  *args, **kwargs):
        return True

From the above code, we have specified points to a database to read or write which returns the name of the current database and we have allowed relationships between two objects in our models.

Register Middleware and Router

In settings.py we will update the MIDDLEWARE list to look as what we have below and also add the DATABASE_ROUTERS list.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# settings.py

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
    "library.middleware.TenantMiddleware", # new
]

DATABASE_ROUTERS = ["library.router.TenantRouter"] # new 

Configure Host Names

We need to map the hostname to our local machines and to do that we need to edit the hosts file which can be found in the path:

1
2
C:\Windows\System32\drivers\etc\    # For Windows
/etc/hosts                          # For Linux

Open the file with any text editor of your choice and add the following hosts:

1
2
3
4
5
6
# hosts

127.0.0.1 library.local
127.0.0.1 acumen.library.local
127.0.0.1 maximus.library.local
127.0.0.1 grand.library.local

Our ALLOWED_HOSTS in settings.py file also needs to be updated to be as follows:

1
ALLOWED_HOSTS = ['library.local', 'acumen.library.local', 'maximus.library.local', 'grand.library.local']

Making Migrations

We need to run migrations for all the databases we created and create superusers for each one of them. FOr this we need to a custom manage.py file to handle our case of having multiple databases.

Create a new file and name it library_manage.py at the project level directory and add the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# library_manage.py 

#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys

from library.middleware import set_current_tenant 


def main():
    """Run administrative tasks."""
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "multitenant.settings")
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError(
            "Couldn't import Django. Are you sure it's installed and "
            "available on your PYTHONPATH environment variable? Did you "
            "forget to activate a virtual environment?"
        ) from exc
    execute_from_command_line(sys.argv)

    from django.db import connection

    args = sys.argv
    db = args[1]
    with connection.cursor() as cursor:
        set_current_tenant(db)
        del args[1]
        execute_from_command_line(args)


if __name__ == "__main__":
    main()

In the above code:

  • args - this stores sys.args which is a list of command-line arguments that gets passed.
  • db = args[1] - from the arguments passed, we get the value at position 1 in the array.
  • with-connection.cursor() - opens the connection for the queries in it to be executed.
  • set_current_tenant - uses the name we pass as arg[1] to route the database specified.
  • del args[1] - deletes the database name argument after the routing has been done.
  • execute_from_command_line(args) - executes the command that you type .

Running the Commands

After everything is set, we can now run the migrations and proceed with creating of superusers.

1
py manage.py makemigrations library

For Acumen

1
2
py manage.py migrate --database=acumen
py library_manage.py createsuperuser --database=acumen

For Maximus

1
2
py manage.py migrate --database=maximus
py library_manage.py createsuperuser --database=maximus

For Grand

1
2
py manage.py migrate --database=grand
py library_manage.py createsuperuser --database=grand

Testing

To run the local server and test we run:

1
py manage.py runserver library.local:8000

You can go ahead and the view the different multitenant sites by the following urls:

  • Tenant Default :
    • Main Site: http://library.local:8000/
    • Admin Site: http://library.local:8000/admin/
  • Tenant Acumen :
    • Main Site: http://acumen.library.local:8000/
    • Admin Site: http://acumen.library.local:8000/admin/
  • Tenant Maximus :
    • Main Site: http://maximus.library.local:8000/
    • Admin Site: http://maximus.library.local:8000/admin/
  • Tenant Grand :
    • Main Site: http://grand.library.local:8000/
    • Admin Site: http://grand.library.local:8000/admin/

You can log into the different admin sites and uploading different books and see the changes.

Incase you need the codebase, you can access it through this Github link.

This post is licensed under CC BY 4.0 by the author.