django-tenants gives you a schema per customer in about twenty lines of settings. The quickstart works, the first tenant is created, and you feel like the hard part is over.
The hard part starts about three weeks later, when you try to build an immutable Docker image and django.setup() refuses to run because there is no database at build time. Then when a router that is obviously correct is rejected. Then when a middleware ordering you never thought about turns a health check into a 500.
This is the configuration behind names.legal — a multi-tenant SaaS where each customer gets an isolated PostgreSQL schema on their own domain — with the reasoning for every non-obvious line. Most of these were learned by breaking something.
The base
TENANT_MODEL = "customers.Client"
TENANT_DOMAIN_MODEL = "customers.Domain"
PUBLIC_SCHEMA_NAME = "public"
PUBLIC_SCHEMA_URLCONF = "core.urls"
ROOT_URLCONF = "core.tenant_urls"
ORIGINAL_BACKEND = "django.contrib.gis.db.backends.postgis"
DATABASES = {
"default": {
"ENGINE": "django_tenants.postgresql_backend",
...
}
}
Two URLconfs, and this is the first thing people get wrong. ROOT_URLCONF is what a tenant serves; PUBLIC_SCHEMA_URLCONF is what the platform domain serves. They are different sites: names.legal has pricing, signup and a blog; client.names.legal has the client’s own site and a /cms/ console. Pointing both at the same module gives every tenant a copy of your pricing page on their own domain.
ORIGINAL_BACKEND matters when you use PostGIS. django_tenants.postgresql_backend wraps a backend; without this line it wraps the plain one and every geometry field breaks with an error that names neither tenants nor GIS.
Multi-type tenants: two app lists, one project
HAS_MULTI_TYPE_TENANTS = True
MULTI_TYPE_DATABASE_FIELD = "type"
TENANT_TYPES = {
"public": {
"APPS": [
"django_tenants", # mandatory
"customers",
"django.contrib.admin",
...
"platform_admin",
"pricing",
"main_blog",
"main_jobs",
"mailing",
],
},
"client": {
"APPS": [ ... ],
},
}
The standard SHARED_APPS / TENANT_APPS split assumes two categories. TENANT_TYPES lets a schema declare which apps it contains, and it is worth the extra concept for one reason: the public schema and a tenant schema are genuinely different products here. The platform has pricing, a jobs board and a marketing blog. A client site has none of those, and creating those tables in every customer schema would be several hundred empty tables per customer.
The line people forget:
for schema in TENANT_TYPES:
INSTALLED_APPS += [
app for app in TENANT_TYPES[schema]["APPS"] if app not in INSTALLED_APPS
]
TENANT_TYPES tells django-tenants what belongs where. It does not populate INSTALLED_APPS, and Django still needs the union of everything to load models at all. Omit this loop and you get ImportError on an app that is plainly listed in your settings — because it is listed in a dict Django has never heard of.
The router that must be a string
TENANT_SYNC_ROUTER = "core.db_router.TenantShardRouter"
DATABASE_ROUTERS = [TENANT_SYNC_ROUTER]
This looks like a stylistic choice. It is not.
django_tenants/apps.py validates at startup that your router is configured, and the check is a literal string comparison against the entries of DATABASE_ROUTERS — not an issubclass test, not an import-and-inspect. Subclass TenantSyncRouter in your own module and register the subclass, and the check fails even though your router is, by every reasonable definition, a tenant sync router.
So the constant exists to make the two references impossible to drift apart, and the comment in the file says why. This is the kind of thing that costs an afternoon precisely because the failure message is about routers and the cause is about strings.
The line that decides whether your image builds
This is the most valuable thing in this article, and it is one setting.
PG_EXTRA_SEARCH_PATHS = ["extensions"]
SKIP_PG_EXTRA_VALIDATION = True
django-tenants validates PG_EXTRA_SEARCH_PATHS in its AppConfig.ready(). Look at what that validation does — django_tenants/utils.py, validate_extra_extensions: it opens a cursor.
An AppConfig.ready() runs during django.setup(). django.setup() runs for every management command, every worker boot, every collectstatic. So this validation makes a database connection a hard prerequisite for the application to start at all.
Two measured consequences.
You cannot build an immutable image. collectstatic and compilemessages belong in the Docker build: they are deterministic, they depend on the source, and running them at boot means every container in the fleet redoes the same work and can produce a different result. But at build time there is no database. So django.setup() raises OperationalError, and the build fails on a step that has nothing to do with the database.
A short Postgres outage becomes a long one. Postgres blips for forty seconds. Any application process that restarts during that window does not start and wait — it dies at django.setup(). With four nodes behind a load balancer and a restart policy, you now have processes crash-looping on a database that came back thirty seconds ago. The database outage lasted forty seconds; the site outage lasted as long as it took someone to notice.
The fix is to see the validation for what it is: a check on configuration constants, not on data. It reads values out of a settings file. It has no business in the boot path.
SKIP_PG_EXTRA_VALIDATION = True
and the same check is re-run, unchanged, as manage.py check_pg_search_paths in the release phase of the entrypoint — the one place where a database is guaranteed to exist and where failing is the correct outcome.
The general rule this taught us: a check that opens a socket is not a startup check. Move it to a place that already assumes the world is up.
Middleware, in an order that is not arbitrary
MIDDLEWARE = [
"core.middleware.HealthCheckMiddleware",
"django_tenants.middleware.main.TenantMainMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
"intl.middleware.GeoCurrencyLanguageMiddleware",
"django.middleware.locale.LocaleMiddleware",
"core.middleware.SmartAuthI18nMiddleware",
"core.middleware.TenantLanguageMiddleware",
"core.middleware.TenantSuspensionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
...
]
Four positions carry an argument.
HealthCheckMiddleware is first, above tenant resolution. /healthz and /readyz are called by the load balancer, by a hostname that is not a tenant domain — usually an internal IP or a service name. Put the health check anywhere below TenantMainMiddleware and the load balancer’s probe has to resolve a tenant, fails to, and your healthy node is marked unhealthy. The probe must answer before sessions, before HTTPS redirection, before anything.
TenantMainMiddleware is second. Everything after it can assume a schema. Anything before it must not touch the database, because the connection’s search_path has not been set and you will silently read the public schema.
GeoCurrencyLanguageMiddleware is before LocaleMiddleware. It resolves the visitor’s country and currency from GeoIP and populates the language cookie on the request when it is absent — so LocaleMiddleware, which runs next, picks up a geographic default instead of falling back to LANGUAGE_CODE. Reverse the two and the feature does nothing, silently.
TenantSuspensionMiddleware is after TenantLanguageMiddleware and before CommonMiddleware. After, because the “this site is suspended” page must render in the tenant’s own language — a French client’s visitors should not get an English notice. Before everything else, because a suspended tenant must execute no view at all. Both constraints are real, and together they pin it to exactly one slot.
TENANT_SUSPENSION_GRACE_DAYS = int(
os.environ.get("TENANT_SUSPENSION_GRACE_DAYS", "15"))
The fallback for a hostname that is not a tenant
SHOW_PUBLIC_IF_NO_TENANT_FOUND = True
By default, a request for an unknown hostname gets a raw 404 from django-tenants. That is defensible, and it broke on-demand TLS.
Caddy, issuing certificates on demand, calls an internal ask endpoint to confirm a hostname should get one. That call arrives over the Docker network under a hostname that is, by construction, not yet in customers.Domain — that is the entire point of asking. A raw 404 means no certificate is ever issued, which means the tenant’s domain never works, which means the domain is never added.
Setting this to True sends unknown hostnames to the public schema, where the ask view lives. The comment in the file bounds the decision: this is the only use case, and no route is exposed that is not already public on names.legal. That bound matters — this setting is a decision to serve something to anyone, and it should be made once, with the reason written next to it.
Naming a schema is not naming a customer
A schema name is a PostgreSQL identifier, and PostgreSQL has opinions.
class Client(TenantMixin):
auto_create_schema = True
auto_drop_schema = True
def clean(self):
if not self.schema_name:
self.schema_name = self.generate_schema_name()
if self.schema_name.lower() in [n.lower() for n in RESERVED_SCHEMA_NAMES]:
raise ValidationError(
f"Le nom '{self.schema_name}' est réservé et ne peut pas être utilisé.")
if not re.match(r"^[a-z][a-z0-9_]*$", self.schema_name):
raise ValidationError(...)
if len(self.schema_name) > 63:
raise ValidationError(...)
Three rules, three different reasons:
Reserved names. public, information_schema, pg_catalog, and anything starting pg_. A customer called “Public SA” is not hypothetical, and creating that schema does not fail cleanly — it collides with the schema your platform lives in.
The identifier pattern. Must start with a letter, then lowercase alphanumerics and underscores. Not because django-tenants says so, but because anything else has to be quoted everywhere, forever, in every raw query anyone ever writes against this database. A hyphen in a schema name is a decision your successors pay for.
Sixty-three characters. PostgreSQL’s NAMEDATALEN limit, minus one. Over it, PostgreSQL does not raise — it truncates. Two customers whose names differ at character 70 get the same schema, silently, and the second one lands in the first one’s data. This is the single most important validation in the file.
The generator handles collisions explicitly:
while (Client.objects.filter(schema_name=slug).exists() or ...):
The trap that catches everyone
with tenant_context(client):
User.objects.create_superuser(...)
CustomUser is duplicated per schema. The public schema has a users table; every tenant schema has its own users table. Creating an account on the platform does not create it inside a tenant.
This is correct — it is the isolation you asked for — and it violates the intuition of everyone who has ever written Django, where User is User. Every management command, every shell session, every data-fix script has to know which schema it is standing in. There is no way around it and no way to make it feel natural. Write it at the top of your runbook.
What to take away
If you configure django-tenants today, four things will save you a week:
- Populate
INSTALLED_APPSfromTENANT_TYPESyourself. - Register your router as a literal string constant used in both places.
- Set
SKIP_PG_EXTRA_VALIDATION = Trueand re-run the check where a database exists. This is the difference between an immutable image and a build that needs a database. - Validate schema names against 63 characters, the identifier pattern, and a reserved list — before PostgreSQL truncates one into another.
None of these are in the quickstart. All four are load-bearing.