Skip to content
All insights

engineering//3 min read

Designing the data model for a multi-tenant SaaS

Every B2B SaaS has to keep one customer's data away from another's. The three common designs, what each costs, and how to make the shared-database approach safe with Postgres row-level security.

Digitat engineeringProduct studio, Mumbai
Relational database schema modeling and row-level security for multi-tenant SaaS

In short

  • A shared database with a tenant id on every row is the right start for most B2B SaaS.
  • Put the tenant id on every tenant-owned table, include it in indexes, and scope every query by it.
  • Postgres row-level security adds a second wall that holds even when application code forgets a filter.
  • Move a large or regulated customer to their own database later, if a contract requires it.

In a multi-tenant product, many customers (tenants) share one application. The most important property of the data model is that no tenant can ever see another's data. There are three common ways to get there.

Design

How it works

Strengths

Costs

Shared tables

One database; every row carries a tenant id

Simple to run, cheap, easy to report across tenants

Isolation depends on every query being scoped

Schema per tenant

One database; a schema per tenant with identical tables

Stronger separation, per-tenant restore

Migrations run once per tenant; many schemas get unwieldy

Database per tenant

A separate database for each tenant

Strongest isolation, per-tenant location and scaling

Most expensive and most work to operate

For most products, start with shared tables. It is the easiest to build and run, and you can move individual customers to dedicated databases later if a contract or a regulator demands it.

Shape the tables around the tenant

  • Put tenant_id (or team_id, org_id) on every table that holds tenant data, even child tables. It makes scoping and deletion simple.
  • Lead composite indexes with the tenant id: (tenant_id, created_at) rather than (created_at).
  • Make uniqueness per tenant: two customers may both have an invoice numbered 1001.
sql
create table invoices (
  id bigserial primary key,
  tenant_id bigint not null references tenants(id) on delete cascade,
  number text not null,
  total_cents bigint not null,
  created_at timestamptz not null default now(),
  unique (tenant_id, number)
);

create index invoices_tenant_created on invoices (tenant_id, created_at desc);

Scope every query, in one place

Never rely on each developer remembering where tenant_id = .... Resolve the tenant once per request from the session, and route all data access through functions that require it.

Add row-level security as a second wall

Postgres row-level security (RLS) lets the database itself refuse rows that belong to another tenant. Even if application code forgets a filter, the query returns nothing it should not.

sql
alter table invoices enable row level security;
alter table invoices force row level security; -- applies to the table owner too

create policy tenant_isolation on invoices
  using (tenant_id = current_setting('app.tenant_id')::bigint)
  with check (tenant_id = current_setting('app.tenant_id')::bigint);

Set the tenant at the start of each transaction, scoped to that transaction so it cannot leak into the next request that reuses the connection:

sql
begin;
select set_config('app.tenant_id', '42', true); -- true: local to this transaction
select * from invoices;                          -- only tenant 42's rows
commit;

Have the application connect as a role that is not a superuser and does not have BYPASSRLS, otherwise the policies are skipped. Keep a separate, tightly controlled role for migrations and cross-tenant admin work.

Plan for the hard parts early

Deleting a tenant

With on delete cascade from the tenant table, removing a customer is one statement. Remember files in object storage and copies in analytics or search indexes.

Noisy neighbours

One large tenant can slow everyone down. Rate-limit per tenant, push heavy work to background jobs, and watch the slowest queries by tenant.

Reporting across tenants

Internal analytics need cross-tenant queries. Run them through the privileged role, ideally against a read replica, never through the application's normal connection.

Isolation that depends on every developer remembering a WHERE clause will fail eventually. Put it in the database too.

Checklist

  • To do: Tenant id on every tenant-owned table
  • To do: Indexes and unique constraints lead with the tenant id
  • To do: Tenant resolved once per request, from the session
  • To do: Row-level security enabled and forced on tenant tables
  • To do: Application role without superuser or BYPASSRLS
  • To do: Tenant deletion covers files, analytics and search

Tags

  • SaaS
  • PostgreSQL
  • Architecture
  • Security
  • Multi-tenancy

Share

LinkedInX
Written byDigitat engineeringProduct studio, Mumbai

Have a product worth writing about?

Tell us where it is today. We reply within 12 hours.