Multi-tenant Django done properly: schema-per-tenant with PostGIS

Why we chose schema-per-tenant isolation for a construction SaaS, how to keep JWTs tenant-aware, and the custom backend that makes PostGIS geofencing work.

When a SaaS product serves many companies, the first architectural decision is how to keep their data apart. Get it wrong and you’re one missing WHERE clause away from showing a customer someone else’s payroll.

For a construction-management platform with projects, timesheets, payroll and a crew mobile app, we chose schema-per-tenant on PostgreSQL. This post covers why, the two problems that approach creates, and how we solved them, including making PostGIS geofencing work inside a per-schema setup.

Three ways to isolate tenants

Row-level: one schema, a tenant_id column on every table, every query filtered. Simplest to operate, easiest to get wrong, and a nightmare if one tenant ever needs to be exported or deleted wholesale.

Database-per-tenant: full isolation, full operational cost. Migrations run N times, connection pools multiply, and the admin tooling has to fan out to every database.

Schema-per-tenant: one database, one Postgres schema per tenant, shared schema for global tables. Queries can’t cross tenants because the search_path only contains one tenant’s schema. Migrations run per schema but in one database. For a few hundred tenants it’s the sweet spot, and django-tenants makes it close to free in Django.

Payroll data made the decision easy. Row-level filtering would mean trusting every developer, forever, to never forget a filter on a table containing salaries. Schema isolation makes the mistake impossible rather than unlikely.

Problem 1: tokens that don’t know their tenant

django-tenants resolves the tenant from the request hostname. The mobile app doesn’t use subdomains; it sends a JWT. If you issue a plain JWT and let the middleware guess the tenant from a header, a token issued for Company A can be replayed with Company B’s header and the middleware will happily switch schemas.

The fix is to bind the token to the tenant at issue time and verify it on every request:

def issue_token(user, tenant):
    return jwt.encode(
        {"sub": user.id, "tenant": tenant.schema_name, "exp": ...},
        settings.SECRET_KEY,
        algorithm="HS256",
    )

class TenantJWTAuthentication(BaseAuthentication):
    def authenticate(self, request):
        claims = decode(request)  # signature + expiry checked here
        if claims["tenant"] != connection.schema_name:
            raise AuthenticationFailed("token is not valid for this tenant")
        return (get_user(claims["sub"]), None)

connection.schema_name is whatever django-tenants resolved for this request. If the token’s claim doesn’t match, the request fails before any view runs. Every other piece of authorization builds on this check.

Problem 2: PostGIS doesn’t come along for free

The product needed automatic time-clock punches: when a worker’s phone reports a location inside a job site’s radius, clock them in. That’s a geospatial query, and PostGIS is the right tool.

The catch is that django-tenants ships its own database backend wrapping django.db.backends.postgresql, and the PostGIS backend, django.contrib.gis.db.backends.postgis, wraps the same base class. You can only use one ENGINE. Use the tenants backend and PointField doesn’t work; use the PostGIS backend and schemas aren’t switched.

The answer is a small custom backend that inherits from both:

# myproject/db/backends/tenant_postgis/base.py
from django.contrib.gis.db.backends.postgis.base import (
    DatabaseWrapper as PostGISWrapper,
)
from django_tenants.postgresql_backend.base import (
    DatabaseWrapper as TenantWrapper,
)

class DatabaseWrapper(TenantWrapper, PostGISWrapper):
    pass

Method resolution order means TenantWrapper handles search_path switching and PostGISWrapper supplies the GIS features, introspection and operations. Point DATABASES["default"]["ENGINE"] at this module, keep the postgis extension in the public schema, and spatial fields work inside every tenant schema.

The geofence query

With that in place, the punch logic is a database query rather than application code looping over sites:

from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.geos import Point

def matching_site(tenant_sites, lat, lng):
    here = Point(lng, lat, srid=4326)
    return (
        tenant_sites
        .annotate(distance=Distance("location", here))
        .filter(distance__lte=F("geofence_radius_m"))
        .order_by("distance")
        .first()
    )

Each site stores its centre as a PointField and its radius in metres. The query returns the nearest site whose radius contains the point, or nothing. The mobile app calls this when it detects a significant location change; the backend creates the punch if there’s a match and no open punch already exists.

What it bought the client

  • Automated punches reduced manual time entry and raised accountability by more than 40 percent.
  • Tenant data can’t leak by accident; the database won’t let it.
  • A single migration run updates every tenant, and onboarding a new company is one command.

Schema-per-tenant isn’t the right choice for every product. If you expect tens of thousands of tenants, or your tenants are tiny, row-level with a disciplined ORM layer is cheaper. But for B2B SaaS with a few hundred companies and sensitive data, it’s the design we reach for first.

Building a multi-tenant product? We’ve done this a few times.

Where we've done this

More from the blog

Need this built, not just explained?

Tell us what you're building. You'll get a straight answer on scope, timeline and cost, usually within one business day.