# django-tenants: one PostgreSQL database, one schema per customer, search_path as the router

> django-tenants gives a Django project multitenancy through PostgreSQL schemas, resolving the tenant from the request hostname and switching the connection's search path so ordinary queries hit the right schema. It is the middle option between a database per customer and a shared table with a tenant_id column, and the cost is that the boundary is enforced by connection state rather than by your query code.

**django-tenants/django-tenants** — Django tenants using PostgreSQL Schemas

- Repository: https://github.com/django-tenants/django-tenants
- Stars: 1,897 · Forks: 404
- Language: Python
- License: MIT
- Published: 2026-10-16 · Updated: 2026-10-16 · Language: en
- Canonical page: https://hysenlabs.com/projects/django-tenants-django-tenants

## The three multitenancy options and which one this picks

The README lays out the standard three-way split before describing anything about itself. The isolated approach gives each tenant its own database. The semi-isolated approach shares one database and gives each tenant its own schema. The shared approach puts all tenants in one schema with a tenant table that every other table points at through a foreign key.

django-tenants implements the second, and the README argues it is the right compromise on two grounds: simplicity, in that you barely change existing code and manage one database, and performance, in that tenants share connections, buffers and memory.

That argument is also the design's constraint. Sharing connections and buffers is exactly what makes it fast, and it is exactly what means one tenant's heavy query can degrade another's. The library does not promise isolation between schemas, because the PostgreSQL semantics it relies on do not provide it.

## Tenant resolution is a hostname lookup plus a search_path rewrite

The mechanism is short enough to state precisely. Tenants are identified by hostname, in the form tenant.domain.com. That mapping lives in a table on the `public` schema. When a request arrives, the host name is matched against that table, and on a match the connection's search path is updated to that tenant's schema, so subsequent unqualified queries resolve inside it. If no tenant matches, a 404 is raised.

Two consequences follow directly. First, you need a tenant for your main domain, typically one using the `public` schema, otherwise your own homepage 404s. Second, because routing is by hostname rather than by path, a tenant is not something you switch inside a view; it is a property of the request that arrived.

Django normally identifies a view by the path after the host name, so the same URL can serve different views per tenant. That is what the README means by tenant view-routing, and it is the feature that lets customer.example.com and example.com behave differently without separate deployments.

## Four settings turn a Django project into a multitenant one

The integration surface is four configuration changes. First the database engine has to be swapped for the package's backend, because the package patches the connection to manage search paths:

```python
DATABASES = {
    'default': {
        'ENGINE': 'django_tenants.postgresql_backend',
        # ..
    }
}
```

Second the middleware goes at the top of the list, before anything else touches the database:

```python
MIDDLEWARE = (
    'django_tenants.middleware.main.TenantMainMiddleware',
    #...
)
```

Third the sync router is registered, so migrations apply to the right schema depending on whether an app is shared or tenant-specific:

```python
DATABASE_ROUTERS = (
    'django_tenants.routers.TenantSyncRouter',
)
```

Fourth, `django_tenants` is added to `INSTALLED_APPS`. The README is explicit that this list is a short setup guide and that the complete documentation at django-tenants.readthedocs.org is the thing to follow.

## Your tenant model carries the business fields

You subclass two mixins. `TenantMixin` gives you the schema machinery, `DomainMixin` gives you the hostname record. The README's example puts real business attributes on the tenant itself, which is the pattern to copy:

```python
from django.db import models
from django_tenants.models import TenantMixin, DomainMixin

class Client(TenantMixin):
    name = models.CharField(max_length=100)
    paid_until = models.DateField()
    on_trial = models.BooleanField()
    created_on = models.DateField(auto_now_add=True)

class Domain(DomainMixin):
    pass
```

Both are then declared in settings, in app.Model form:

```python
TENANT_MODEL = "customers.Client" # app.Model
TENANT_DOMAIN_MODEL = "customers.Domain" # app.Model
```

This is where the README's claim about changing little of your code meets reality. A SaaS needs subscription state, and `paid_until` and `on_trial` live on the tenant model, so subscription checks are reads against the shared tenant table, not per-schema lookups. That is the intended division: identity and subscription in `public`, customer data in the schema.

## Migrations are the step people get wrong

Migrations do not run through `migrate`. The package provides its own management command, and the first one you run syncs your apps into the `public` schema:

```bash
python manage.py migrate_schemas --shared
```

Only after that do you create tenants, and creating one is an ordinary model save. The README notes that calling `save` automatically creates and syncs the schema:

```python
from customers.models import Client, Domain

# create your public tenant
tenant = Client(schema_name='tenant1',
                name='My First Tenant',
                paid_until='2014-12-05',
                on_trial=True)
tenant.save()

# Add one or more domains for the tenant
domain = Domain()
domain.domain = 'tenant.my-domain.com'
domain.tenant = tenant
domain.is_primary = True
domain.save()
```

After that, a request to tenant.my-domain.com sets search_path to `tenant1` and `public`, so shared apps remain reachable.

The implicit behavior is the risk. Schema creation is a side effect of a model save, which means a bulk create or an import that bypasses `save` may create a tenant row with no schema behind it, and the failure appears on the first request to that hostname as a 404. Provisioning a large customer base also means one schema creation per customer, synchronously in that call, which is a real cost at onboarding time.

## Shared apps are what make the search_path useful

Tenant-specific apps do not share data between tenants; shared apps hold information available to all of them. Because the search path contains the tenant schema followed by `public`, a query against a table that exists only in `public` resolves there, while a table present in both resolves to the tenant's copy. That is how one view can read subscription state and customer data in the same request without two connections or two queries against different databases.

It also means the two-schema design is load-bearing. If you drop `public` from the search path to tighten isolation, shared apps break. If you create a table with the same name in both a shared app and a tenant app, resolution order decides which one your query sees, and the failure is silent. Schema naming discipline is therefore not a style preference here, it is correctness.

## Supported versions and the shape of the package

Version 4.0.0 was released on 2026-10-09, three days before the last push on 2026-10-12, with v3.14.0 and v3.13.0 both in August 2026. pyproject.toml requires Python 3.10 or newer and pins django>=5.2,<6.2, and the classifiers list Django 5.2, 6.0 and 6.1.

The Django upper bound is the number to plan around. Check the declared compatibility range before upgrading Django.

The package layout is broader than middleware and models. The setuptools configuration includes postgresql_backend, management and management.commands, migration_executors, template and template.loaders, templatetags, test, tests, staticfiles, middleware and files as separate subpackages, which is why the README can offer per-tenant templates, static files and test fixtures without those being new features in a recent release.

## Where the schema approach stops working

The main boundary is PostgreSQL itself. This package is designed specifically for PostgreSQL schemas.

The second boundary is per-tenant isolation. Shared connections, buffers and memory are the stated source of the performance, and the flip side is that a schema boundary is a naming boundary, not a security boundary. Any SQL a tenant's request can influence that runs with elevated privileges or an unqualified cross-schema reference can reach outside the tenant schema. Multi-tenant designs with contractual or regulatory isolation requirements should be looking at separate databases or separate instances instead.

The third is operational scale. There is one schema per customer, and provisioning, per-tenant migrations and backups all scale with customer count rather than with data volume. A product with tens of thousands of small customers is a different problem from one with hundreds of large ones, and the README does not document any threshold.

Finally, this is a package that changes how Django resolves a table. It is classified Development Status 5 - Production/Stable, but any middleware or backend that assumes a fixed search path may conflict with it, and the README's short guide is not enough to discover those conflicts. Read the full documentation before committing.

## Conclusion

django-tenants fits a SaaS on one PostgreSQL instance where per-customer volume is moderate, a single code deployment is a real operational win, and shared and per-customer data both need to be reachable in one request. It does not fit a product with noisy-neighbor isolation requirements, a non-PostgreSQL backend, or tenants whose data must be extractable and deletable one database at a time. Before adopting, read the full documentation rather than the README summary, run `python manage.py migrate_schemas --shared` before any per-tenant migration, and confirm your Django version falls inside the declared django>=5.2,<6.2 range.

## FAQ

### How do I install and set up django-tenants?

Add `django_tenants` to INSTALLED_APPS, set `DATABASES['default']['ENGINE']` to `django_tenants.postgresql_backend`, put `django_tenants.middleware.main.TenantMainMiddleware` first in MIDDLEWARE, and register `django_tenants.routers.TenantSyncRouter` in DATABASE_ROUTERS. Then run `python manage.py migrate_schemas --shared` to sync apps into the public schema.

### How does django-tenants decide which tenant a request belongs to?

Tenants are identified by hostname and the mapping is stored in a table on the public schema. On each request the hostname is matched, the connection search path is set to that tenant's schema, and if no tenant matches a 404 is raised. You therefore need a tenant for your main domain, typically using the public schema.

### What Django and Python versions does django-tenants support?

pyproject.toml requires Python 3.10 or newer and django>=5.2,<6.2, with classifiers for Django 5.2, 6.0 and 6.1. Version 4.0.0 of the package was released on 2026-10-09.

### How do shared apps work in django-tenants?

Tenant-specific apps keep separate data per tenant, while shared apps hold data available to all tenants. Because a request's search_path is set to the tenant schema followed by public, a query against a table that exists only in public resolves there, which is how one request reads both subscription data and customer data.

### Why does django-tenants need PostgreSQL?

The package works by rewriting the connection search_path so unqualified queries resolve inside the tenant's schema, and it relies on PostgreSQL schemas for that. The approach is specific to PostgreSQL schemas.

## Sources

- [django-tenants/django-tenants on GitHub](https://github.com/django-tenants/django-tenants)
- [Issues](https://github.com/django-tenants/django-tenants/issues)
- [License: MIT](https://github.com/django-tenants/django-tenants/blob/master/LICENSE)
- [README](https://github.com/django-tenants/django-tenants/blob/master/README.md)
- [Releases](https://github.com/django-tenants/django-tenants/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/django-tenants-django-tenants
