A hotel group is not one tenant with many users. It is a hierarchy: a company, a set of properties, departments inside each property, and people who may work across several of them. The architecture has to reflect that hierarchy without letting one property see another property’s data.

Django gives you the tools to build this well. It also gives you enough rope to build a system where a missing filter exposes the whole group. The difference is not the framework. It is the tenant model and the enforcement around it.

Start with the hierarchy, not the database

Before choosing shared tables or separate schemas, model the business relationships explicitly:

  • Organization — the legal or management entity that owns the relationship.
  • Property — a hotel or operating unit with its own departments and budgets.
  • Department — the cost center where requests originate.
  • Membership — a user’s role and scope, which may cover one property, several properties, or the whole organization.

The membership table is where most designs go wrong. A user is not simply an “admin” or a “department head.” They are a department head at a property, possibly with additional rights at another property. The role and the scope must be separate fields.

Three tenancy models and their tradeoffs

Shared database, shared tables

Every row carries an organization or property identifier. Queries are scoped in application code, backed by database constraints and indexes.

This is the default I would choose for most mid-sized hotel groups. It is the cheapest to operate, the easiest to report across properties, and the simplest to migrate. The risk is a missing filter. That risk has to be handled with discipline, not optimism.

Shared database, schema per tenant

Each property or organization gets its own PostgreSQL schema. Django can support this with libraries such as django-tenants, or with a custom router.

This improves isolation and makes per-tenant backups straightforward. It also complicates cross-property reporting, migrations, and connection management. For a group that needs strong separation between owners or jurisdictions, it can be the right trade. For a single operating company with shared reporting, it often adds more complexity than it removes.

Database per tenant

Maximum isolation, maximum operational overhead. This model makes sense when tenants are large enough to justify dedicated infrastructure, or when a contract requires physical separation. It is usually a poor fit for a growing group with dozens of small properties.

Rule of thumb: choose the least complex model that satisfies your isolation and reporting requirements. “We might need it later” is not a requirement; a documented regulatory or contractual constraint is.

Make tenant context explicit

The most dangerous pattern is trusting a tenant identifier from the client. A request should never be able to say “show me property 42” and get property 42 unless the authenticated user has a membership that includes it.

Resolve the active context on the server:

  1. Authenticate the user.
  2. Load their memberships and permitted scopes.
  3. Resolve the requested property or organization from the URL or session.
  4. Reject the request if the scope is not permitted.
  5. Attach the resolved scope to the request for the rest of the pipeline.

Middleware can attach the active organization and property, but middleware alone is not enforcement. Every queryset that touches tenant data needs a scoped manager or an explicit filter. The best systems make the unscoped query the awkward one.

Use the database as a second line of defense

Application code is where authorization lives, but the database should still enforce the shape of the data:

  • Composite unique constraints such as (property, request_number).
  • Foreign keys that include the tenant where practical.
  • Indexes that lead with the tenant column for common queries.
  • Check constraints for status transitions and positive quantities.

These constraints do not replace authorization. They prevent a bug from creating data that cannot be reconciled later.

Background jobs and reporting are part of the tenant boundary

Tenant isolation is usually tested on web requests and forgotten in async work. A nightly job that recalculates budgets must know which property it is operating on. An export must not quietly include every property because the filter was omitted in a management command.

Pass the tenant context explicitly into jobs. Do not rely on a global variable that happens to be set in the web process. For reporting, build a separate read path with its own authorization checks rather than reusing an unscoped model manager.

Test for leakage, not just for features

Every multi-tenant system needs tests that try to break the boundary:

  • A user from Property A requests a record from Property B by ID.
  • A user with access to two properties switches context and sees only the active one.
  • A department head cannot approve a request outside their department scope.
  • An export respects the same filters as the on-screen list.
  • A background job for one property does not update another.
  • An admin user’s broader access is explicit and audited, not accidental.

These tests are cheap compared with the cost of a cross-property data leak. Make them part of the definition of done for every new model and endpoint.

Isolation is a product feature

Hotel groups do not buy “multi-tenancy.” They buy confidence that a general manager sees their property, a cluster director sees their cluster, and group finance sees the consolidated view — without anyone seeing more than they should.

The architecture is successful when that confidence is designed in, enforced in code, checked by the database, and tested continuously. Django makes the application pleasant to build. The tenant model is what makes it safe to operate.