Per-user SQLite databases for Django for ultimate multi-tenant isolation.
Status: Very experimental, but it works. Not intended for production use yet.
Forked from django-sqlite-user-db by MessyComposer.
- Per-user SQLite databases, created automatically for ultimate isolation
- Configurable settings for tenant databases (with performant defaults)
- Cross-database joins work with Django's ORM and raw SQL queries
- The correct tenant database is automatically used based on the current user
- Sync and async support
- Optional database template creation for fast tenant provisioning
- Django admin integration to access different user's database for superusers
uv add dj-lite-tenantOR
pip install dj-lite-tenantINSTALLED_APPS = [
...
"dj_lite_tenant",
]
MIDDLEWARE = [
...
"django.contrib.auth.middleware.AuthenticationMiddleware",
"dj_lite_tenant.middleware.TenantDatabaseMiddleware", # must follow auth middleware
...
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db/data.sqlite3",
},
}
DATABASE_ROUTERS = ["dj_lite_tenant.routers.TenantDatabaseRouter"]
DJ_LITE_TENANT = {
"DIR": BASE_DIR / "db/tenants",
"APPS": {"app_label", "app_label.ModelName"},
# Optional:
# "ATTACHMENTS": {"default": "shared"},
# "DB_NAME_PATTERN": "tenant_{tenant_pk}.sqlite3",
# "DELETE_TENANT_DB_ON_DELETE": False,
# "MAX_OPEN_CONNECTIONS": 100,
# "TENANT_ID_CALLABLE": "dj_lite_tenant.middleware.get_tenant_pk_from_request",
# "TENANT_MODEL": "app_label.ModelName",
# "TENANT_SETTINGS": {
# "CONN_MAX_AGE": 0,
# "CONN_HEALTH_CHECKS": False,
# "TIME_ZONE": None,
# "AUTOCOMMIT": True,
# "ATOMIC_REQUESTS": False,
# "TEST": {"NAME": None},
# },
# "USE_DATABASE_TEMPLATE": False,
}Each authenticated user gets their own isolated SQLite file for the apps/models specified in the APPS setting. The middleware resolves the correct tenant on every request, the router directs ORM queries to the right file, and the LRU registry manages open connections.
sequenceDiagram
participant U42 as User 42
participant U99 as User 99
participant MW as Middleware
participant Router as DB Router
participant DB42 as tenant_42.sqlite3
participant DB99 as tenant_99.sqlite3
participant Shared as data.sqlite3<br/>(shared)
U42->>MW: GET /notes/
MW->>MW: ContextVar ← "42", open tenant DB
MW->>Router: Note.objects.all()
Router->>Router: read ContextVar → alias "tenant_42"
Router->>DB42: SELECT * FROM notes_note
DB42-->>U42: User 42's notes only
U99->>MW: GET /notes/
MW->>MW: ContextVar ← "99", open tenant DB
MW->>Router: Note.objects.all()
Router->>Router: read ContextVar → alias "tenant_99"
Router->>DB99: SELECT * FROM notes_note
DB99-->>U99: User 99's notes only
Note over DB42,Shared: ForeignKey traversal (note.user)
Router->>Router: User model → alias "default"
Router->>Shared: SELECT * FROM auth_user WHERE id=42
Shared-->>U42: User 42's profile
When a tenant model has a ForeignKey to a shared model (e.g. Note.user → auth_user), each shared database listed in ATTACHMENTS is ATTACHed to the tenant connection as a read-only alias. The custom SQLite backend rewrites dotted table names so the Django ORM generates valid cross-file SQL.
flowchart TD
A[New tenant DB connection opened] --> B["Signal: connection_created\n(attach_shared_databases)"]
B --> C["ATTACH DATABASE\n'file:data.sqlite3?mode=ro'\nAS 'shared'"]
C --> D[Tenant connection can now\nread shared DB tables]
E["ORM query: User model"] --> F["TenantDatabaseRouter\ndb_for_read(User) → 'default'"]
F --> G["Standard SELECT on shared DB\nno JOIN needed"]
H["ORM query with select_related:\nNote.objects.select_related('user')"] --> I["Custom backend quote_name\n'shared.auth_user' → 'shared'.'auth_user'"]
I --> J["SQLite resolves across\nATTACHed file boundary"]
J --> K["Single query result\n(notes + user rows)"]
D -.->|enables| J
style C fill:#f5f5f5,stroke:#999
style I fill:#f5f5f5,stroke:#999
The directory where per-tenant SQLite databases are stored.
Defines which apps or specific models should be stored in tenant databases. Values are app labels ("notes") or "app.Model" strings ("catalog.Product"). Models not explicitly named will be stored in the shared DB.
DJ_LITE_TENANT = {
...
"APPS": {
"notes", # all models from the 'notes' app
"catalog.Product", # only Product model from 'catalog' app
"catalog.Order", # only Order model from 'catalog' app
# Other models in 'catalog' (e.g., catalog.Category) stay in the shared DB
},
}An "app_label.ModelName" string identifying the model used as the tenant. Defaults to None, which uses Django's get_user_model().
Note: Non-user-based tenants (e.g., Organizations where multiple users share a tenant DB, or Workspaces where a user can switch between tenant DBs) are possible via custom
TENANT_MODELandTENANT_ID_CALLABLE, but require you to implement the user-to-tenant resolution and access control logic yourself. The library provides the routing mechanism; the mapping layer is your application's responsibility.
A dotted path to a callable(request) -> str | None that extracts the tenant identifier from the request. Defaults to "dj_lite_tenant.middleware.get_tenant_pk_from_request".
Maps Django DB aliases to SQLite ATTACH aliases. This allows tenant models to reference models in the shared database.
Pattern for tenant database filenames. Defaults to "tenant_{tenant_pk}.sqlite3".
LRU eviction threshold per worker process. Defaults to 100.
When True, copies the first tenant DB as a template instead of using running migrations when a new tenant database is created. Defaults to False. See Enabling database template for details.
When True, automatically deletes the tenant DB file when the tenant instance is deleted. Defaults to False. See Tenant database cleanup for details.
Additional settings for the tenant database. More details in Django documentation.
DJ_LITE_TENANT = {
...
"TENANT_SETTINGS": {
"CONN_MAX_AGE": 600,
},
}Models in the tenant database can have a ForeignKey to a model in the shared database.
Setting `db_constraint=False` is not required, however it does make it explicit for others that SQLite cannot enforce FKs across attached databases.
# models.py
from django.conf import settings
from django.db import models
class Note(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
db_constraint=False, # FK lives in shared DB; tenant DB can't enforce it
)
text = models.TextField()Django's router automatically directs queries to the right database, so foreign key traversal works transparently:
# views.py
def all_notes(request):
# If a user is logged in, the middleware already activated the tenant DB, so this query hits the current user's DB
notes = Note.objects.all()
# note.user hits the default DB automatically
return JsonResponse(
{
"notes": [
{"text": note.text, "user": note.user.username} for note in notes
]
}
)The middleware automatically activates the correct tenant database during HTTP requests, but for out-of-request contexts you can use the tenant_db context manager:
from dj_lite_tenant.middleware import tenant_db
from django.contrib.auth import get_user_model
from django.core.tasks import task
@task
def process_user_notes(user_pk):
user = get_user_model().objects.get(pk=user_pk)
with tenant_db(user):
# All ORM queries for tenant apps now hit this user's database
notes = Note.objects.all()
Note.objects.create(user=user, text="Created from a background task")tenant_db accepts any object with a .pk attribute (typically a User or your custom tenant model). It opens the tenant's database connection on entry and cleans it up on exit.
A ModelAdmin subclass can be used so superusers can access other user's data:
This is designed for simple setups where `TENANT_MODEL` is the User model. For more complex setups with a separate tenant model (e.g., Organization), you'll need a custom solution that maps users to their tenant.
# admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import User
from dj_lite_tenant.admin import SwitchTenantAdmin
admin.site.unregister(User)
@admin.register(User)
class UserAdmin(SwitchTenantAdmin, BaseUserAdmin):
passAdd the URLs to your project's urls.py:
from django.urls import include, path
urlpatterns = [
path("admin/", include("dj_lite_tenant.urls")), # Must come BEFORE admin.site.urls
path("admin/", admin.site.urls),
]When you add a new model to a tenant app, you need to run migrations for all existing tenant databases:
python manage.py migrate_tenant_dbsThis command applies migrations to all existing per-tenant SQLite databases found in DJ_LITE_TENANT['DIR'].
By default, when a tenant (e.g., User) is deleted, the tenant's SQLite database file is not automatically deleted. This is a safety precaution to prevent accidental data loss from cascade deletes, bulk operations, or admin UI actions.
To delete a tenant database file explicitly:
from dj_lite_tenant.utils import delete_tenant_db
delete_tenant_db(str(user.pk)) # Returns True if file was deleted, False if not foundTo automatically delete tenant DB files when the tenant is deleted, set DELETE_TENANT_DB_ON_DELETE=True:
DJ_LITE_TENANT = {
...
"DELETE_TENANT_DB_ON_DELETE": True,
}When enabled, the tenant DB file is deleted immediately after the tenant instance is deleted. When disabled, a warning is logged if the DB file remains after tenant deletion.
By default, every new tenant database is created by running all migrations for the tenant apps. Setting USE_DATABASE_TEMPLATE to True stores a "template" of the database so new tenant databases can be created faster.:
DJ_LITE_TENANT = {
...
"USE_DATABASE_TEMPLATE": True,
}- The first tenant database is created normally via
migrate. - That fully-migrated database is copied to a template file (
.template.sqlite3) insideDJ_LITE_TENANT['DIR']. - Every subsequent tenant database is created by copying the template file instead of running all of the migrations to get to the current state.
- If the template copy fails for any reason, the system falls back to running migrations automatically.
- The template cache is invalidated whenever migrations are applied to any tenant app (via the
post_migratesignal), so it stays in sync with your schema.
from dj_lite_tenant.utils import clear_template_cache
clear_template_cache()