Back to Blog
Engineering

How to Design a Multi-Tenant Database Schema (Without Painting Yourself Into a Corner)

The moment your app has two customers, you have a multi-tenancy problem — two organizations' data living in one system, with an iron requirement that neither ever sees the other's. How you structure that in the database is one of the few decisions you make in week one that you will still be living with in year five. This is a practical, vendor-neutral guide to the three isolation models, the tenant_id discipline that keeps the common one safe, and how to verify isolation instead of hoping. Examples use SQL and Python/SQLAlchemy for concreteness, but the patterns apply to any stack.

The Stakes: One Bug Class Above All

Most bugs cost you a fix. A cross-tenant leak — customer A seeing customer B's invoices, files, or users — costs you trust, contracts, and sometimes the company. It is the one bug a SaaS cannot shrug off. Every choice below is really about one question: how hard does the structure make it for that bug to exist? Good multi-tenant design is leak-proofing by architecture rather than by vigilance, because vigilance has a bad on-call schedule.

The Three Isolation Models

There are exactly three places to draw the boundary, and each trades isolation strength against operational cost:

Choosing without agonizing

Start shared-schema unless you have a forcing function: regulated customers demanding physical separation, one tenant orders of magnitude bigger than the rest, or contractual single-tenant requirements. The models are not forever — teams routinely promote a whale tenant to its own database later — but that promotion is only sane if your shared-schema discipline was clean enough that “everything belonging to tenant X” is a well-defined set. Which is the real argument for the discipline below.

Shared Schema, Done Properly

The shared-schema model lives or dies on a handful of rules. None is exotic; all of them matter.

First: tenant_id on every tenant-owned table, NOT NULL, with a foreign key. Not just the top-level tables — every table whose rows belong to a tenant, including children and join tables:

CREATE TABLE projects (
    id         BIGSERIAL PRIMARY KEY,
    tenant_id  BIGINT NOT NULL REFERENCES tenants(id),
    name       TEXT NOT NULL
);

CREATE TABLE tasks (
    id         BIGSERIAL PRIMARY KEY,
    tenant_id  BIGINT NOT NULL REFERENCES tenants(id),  -- yes, again — not only on the parent
    project_id BIGINT NOT NULL REFERENCES projects(id),
    title      TEXT NOT NULL
);

Putting tenant_id on child tables looks redundant — a task's tenant is knowable through its project — but the redundancy is the point. It lets every query filter directly without joining up the ancestry, lets indexes do their job, and makes “delete/export everything for tenant X” a single predicate instead of a graph traversal.

Second: uniqueness is per-tenant. The classic mistake is a global unique constraint on something tenants name themselves. Two customers will both want a project called onboarding, and they should both get one:

-- Wrong: the first tenant to use a slug takes it hostage for everyone
CONSTRAINT projects_slug_key UNIQUE (slug)

-- Right: names are unique within a tenant's world
CONSTRAINT projects_tenant_slug_key UNIQUE (tenant_id, slug)

Third: indexes lead with tenant_id. Effectively every live query in the system filters by tenant, so composite indexes should put tenant_id first — (tenant_id, created_at), (tenant_id, status) — letting the database jump straight to one tenant's slice instead of scanning everyone's rows and filtering late.

Fourth: children cannot cross tenants. A subtle corruption: a task whose tenant_id says tenant A but whose project_id points into tenant B. A composite foreign key makes that state unrepresentable:

-- Parent exposes (tenant_id, id) as a referenceable pair
ALTER TABLE projects ADD CONSTRAINT projects_tenant_id_id_key UNIQUE (tenant_id, id);

-- Child references BOTH columns — a task's project must share its tenant
ALTER TABLE tasks
    ADD CONSTRAINT tasks_project_same_tenant_fk
    FOREIGN KEY (tenant_id, project_id) REFERENCES projects (tenant_id, id);

The Bug Class: the Forgotten WHERE Clause

With the schema solid, the remaining risk lives in queries. Every leak in a shared-schema system is ultimately the same line of code — a query somebody wrote without the tenant filter:

# The bug. It works perfectly in every demo, because demos have one tenant.
project = db.query(Project).filter(Project.id == project_id).first()

# The fix — identity AND ownership, together, every time.
project = db.query(Project).filter(
    Project.id == project_id,
    Project.tenant_id == current_tenant.id,
).first()

You will not win this by asking everyone to remember. You win it by making the scoped path the only path:

404, Not 403

When a user requests a resource that exists but belongs to another tenant, return the same 404 you would for a resource that does not exist at all. A distinct “forbidden” response confirms to an attacker that the id they guessed is real — turning your error codes into an enumeration oracle for other tenants' data. Wrong tenant and nonexistent should be indistinguishable from outside.

Test Isolation Like You Mean It

Tenant isolation is a property you can test mechanically, and the test is almost embarrassingly simple: create two tenants and try to cross the wall. Seed tenant A and tenant B, authenticate as A, then attempt to read, update, and delete B's resources by id — through the API, not the ORM — and assert every attempt comes back 404 with B's data untouched. Run it for every resource type you ship. The test is boring to write and catches exactly the bug that matters; a single-tenant test suite, no matter how thorough, can never catch a cross-tenant leak because there is nothing to leak into.

The Checklist

A shared-schema setup you can trust

1. tenant_id NOT NULL + FK on every tenant-owned table, children included. 2. Unique constraints scoped as (tenant_id, …). 3. Composite indexes leading with tenant_id. 4. Composite FKs so children cannot reference across tenants. 5. All access through a query layer that requires the tenant; tenant resolved from auth, never from request input. 6. RLS if your database offers it. 7. A two-tenant test suite that attacks the wall on every resource. 8. Wrong-tenant responses identical to not-found.

The Bottom Line

Multi-tenancy is not a framework feature you install; it is a small set of structural decisions applied without exception. Pick the isolation model your actual constraints demand — shared schema, for most — then make the safe path the only path: tenant on every row, tenant in every unique constraint and index, tenant applied by machinery rather than memory, and a test suite that actively tries to leak. Do that in week one and multi-tenancy becomes a property of the system you stop thinking about. Skip it, and you will be retrofitting these exact rules someday with production data on the line.

Start From a Tenant-Safe Foundation

This discipline — scoped models, tenant-aware queries, and the isolation patterns above — ships pre-wired in ShipKit, our FastAPI boilerplate. And for a production case study, inside Chiave's multi-tenant architecture shows the same rules running a real licensing platform.

Explore ShipKit
BW

Brandon Wigley

Founder of Wigley Studios. Building developer tools since 2018.

Previous: Inside Brand Token Studio All Articles