Every SaaS product eventually has The Conversation. Someone on the sales team just promised a big customer "your data will never touch anyone else's, obviously," and now the engineering team has to figure out what that actually means in code. Welcome to multi-tenancy โ the art of running one application for many customers ("tenants") while pretending, convincingly, that each of them is the only one in the building.
There's no single right answer here. There's a spectrum, and where you land depends on how paranoid your customers are, how many of them you have, and how much ops pain you're willing to absorb. Let's walk it.
Option 1: Shared Schema, Shared Table, tenant_id Everywhere
This is the default everyone reaches for first, and for good reason โ it's cheap to build and cheap to run.
CREATE TABLE invoices (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
amount_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_invoices_tenant ON invoices (tenant_id);
One database, one set of tables, every row tagged with a tenant_id. Migrations run once. Connection pooling is trivial. You can have 50,000 tenants and your ops dashboard stays boring, which is the highest compliment infrastructure can receive.
The catch is that the only thing standing between Tenant A and Tenant B's invoices is a WHERE tenant_id = ? clause that some engineer has to remember to write, correctly, in every single query, forever. Forget it once in a report-generation script and you've got a data leak with a customer's name on it. This is exactly the kind of thing that looks fine in code review and then shows up in a security audit eighteen months later.
The Fix Nobody Uses Enough: Row-Level Security
If you're on Postgres, you don't have to trust every developer's memory โ you can make the database enforce isolation for you:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant')::uuid);
Then at the start of every request, before any query runs:
// middleware, runs once per request
await db.query('SET app.current_tenant = $1', [req.tenantId]);
Now even a lazily-written SELECT * FROM invoices โ no WHERE clause at all โ silently only returns the current tenant's rows. It's a seatbelt for the query you will eventually forget to write correctly. I've seen this catch a bug in a background job that reused a pooled connection across tenants without resetting context โ the RLS policy just returned zero rows instead of someone else's invoices, which is the difference between "weird bug ticket" and "breach disclosure."
Option 2: Schema-per-Tenant
One database, but each tenant gets their own Postgres schema (tenant_acme.invoices, tenant_globex.invoices). It's a decent middle ground: stronger isolation than shared tables, still cheap enough to run hundreds of tenants on one instance. The tradeoff shows up at migration time โ "run this ALTER TABLE" turns into "run this ALTER TABLE, in a loop, across 400 schemas, and pray none of them drift out of sync." I've watched a migration job silently skip three schemas because a previous manual hotfix had already half-applied itself there, and nobody noticed until a customer hit a missing column in production.
Option 3: Database-per-Tenant
The nuclear option, and the one enterprise contracts love. Total physical isolation โ separate database, separate backups, separate blast radius. If Tenant A's runaway query melts their database, Tenant B never notices. This is genuinely the right call for regulated industries (healthcare, finance) where "your data was in the same table as another customer's" is a compliance failure regardless of how good your WHERE clause is.
The cost is operational, not architectural: connection pool exhaustion becomes a real risk once you're managing hundreds of live database connections, migrations need real orchestration tooling instead of a single migrate up, and your "spin up a new tenant" flow now provisions actual infrastructure instead of inserting a row.
Picking One Without a Whiteboard Fight
A rough heuristic that's served me well, including a project at Cubet where we started shared-schema and had to defend that choice to a security-conscious enterprise buyer:
- Shared schema + RLS for anything self-serve, high tenant count, or early-stage. Add RLS from day one โ retrofitting it onto a table with a year of "forgot the WHERE clause" tech debt is miserable.
- Schema-per-tenant if you have dozens to low-hundreds of tenants and want stronger isolation without full infra duplication โ but budget real engineering time for migration tooling.
- Database-per-tenant when a contract, a regulator, or a security questionnaire demands it โ not because it's more "proper" architecture. It isn't. It's a cost you pay for a specific guarantee.
The mistake I see most often isn't picking the wrong tier โ it's picking shared-schema and then never adding the enforcement layer, betting the company's data isolation guarantees on every future engineer reading the same query-review checklist you did. Don't make isolation a matter of discipline. Make it a matter of physics โ or at least of a database policy that doesn't care how tired the on-call engineer is at 2am.
What's your multi-tenancy war story โ the migration that got missed, or the tenant_id that got dropped? I'd genuinely like to hear it.