/KEY TAKEAWAYS
The short version
- 01Database-per-tenant supports dozens to hundreds of tenants on one instance before connection limits become the bottleneck, not disk space.
- 02Schema-per-tenant looks like a compromise but has its own ceiling. Somewhere between ten and fifty thousand schemas, pg_catalog bloat starts slowing every query planner decision on the cluster.
- 03Shared-database with row-level security scales to hundreds of thousands of tenants on a single Postgres cluster, but it is only as secure as the one line of application code that sets the session variable.
- 04A data sovereignty requirement from an enterprise buyer is usually a single-tenant requirement wearing a compliance label. Read the actual clause before arguing about architecture.
- 05The migration path between these models gets harder, not easier, the longer you wait. Decide the isolation boundary before your first enterprise contract, not after.
This decision gets made once, early, usually by whoever is in the room when the schema first goes up on a whiteboard. It is one of the few architectural calls that is genuinely expensive to reverse.
When should a B2B startup choose single-tenant architecture over multi-tenant?
When a customer's contract, not their preference, requires it. Healthcare, government, and large financial institutions frequently write data residency and physical isolation into the procurement document itself, and no amount of row-level security satisfies a clause that specifies a dedicated database instance. If your early customers are in those categories, single-tenant is not a technical decision you get to make freely. It is a sales requirement with a schema attached.
Outside of that, default to shared infrastructure. Single-tenant multiplies your operational surface by the number of customers you have, and most startups do not yet have the platform team to carry that multiplication.
What is the real difference between database-per-tenant and schema-per-tenant?
Database-per-tenant gives each customer a genuinely separate Postgres database. Total isolation, the cleanest story for an auditor, and a hard ceiling of dozens to hundreds of tenants per instance before connection overhead becomes the limiting factor rather than anything about your data.
Schema-per-tenant looks like a clever middle ground. Same database, separate schemas, less connection pressure. It buys you room up to somewhere in the low tens of thousands of tenants, and then a different bottleneck appears. Every table, index and sequence across every schema lives in the same shared system catalogs, and those catalogs grow into millions of rows, which slows the query planner and turns routine migrations into long, careful operations.
Neither ceiling is a bug. Both are the honest cost of physical or near-physical isolation, paid at different points on the growth curve.
FIG/02How do you scale a Postgres database to handle millions of isolated tenant records?
You stop isolating at the database or schema level and isolate at the row instead. A shared database with row-level security appends a filter to every query based on a session variable, and it scales to hundreds of thousands of tenants on a single cluster, because you are no longer paying the connection or catalog overhead that limits the other two models.
The trade is honest and worth stating plainly. RLS is incredibly secure when configured correctly. It relies on your application setting the correct tenant identifier on every single query, and a bug in that one place can leak data across tenants in a way a physically separate database structurally cannot.
For most B2B SaaS products, that trade is the right one. For anything handling financial or medical records where a single application bug leaking one row is unacceptable regardless of probability, physical separation via database-per-tenant remains the safer default.
-- Every table that holds tenant data gets this pair.
-- The policy is the wall. The session variable is the key.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Set once per connection, at the very start of the request,
-- before any other query touches this connection.
-- SET LOCAL scopes it to the current transaction only.
SET LOCAL app.current_tenant = '3f29a7e1-...';
-- If this line is ever skipped, current_setting() throws
-- rather than silently returning every tenant's rows.
ALTER DATABASE app_db SET app.current_tenant = '';The default at the bottom is the load-bearing line. An empty default means a forgotten SET LOCAL fails loudly instead of quietly returning the entire table.
What does the migration path between tenancy models actually look like?
Harder than the decision to pick one in the first place, and it only gets harder with time. Moving from shared RLS to database-per-tenant for one enterprise customer means standing up a new instance, backfilling their data, and rewriting the connection logic to route that one tenant differently from everyone else, all without downtime on a live product.
The teams that handle this well do not treat it as a rewrite. They design the data access layer once, early, so that tenant_id is threaded through every query from day one regardless of which physical model is behind it. Moving a tenant later becomes an infrastructure change beneath an interface that never has to change, instead of a rewrite of every query in the codebase.
That discipline costs a few days up front. Retrofitting it after your first enterprise contract demands it costs considerably more, on a deadline you do not control.
FIG/03/TRADE-OFFS
Database-per-tenant vs shared database with row-level security
Schema-per-tenant sits between these two, borrowing the ceiling problems of both without fully escaping either.
| Criterion | Database-per-tenant | Shared DB + row-level security |
|---|---|---|
| Isolation model | Physical. A separate Postgres database per tenant. | Logical. A policy filters every query by tenant ID. |
| Practical tenant ceiling | Dozens to hundreds per instance. | Hundreds of thousands on a single cluster. |
| Primary bottleneck | Connection overhead as instance count grows. | Query planner performance on very large shared tables, addressed with indexing and partitioning. |
| Risk of a single application bug | Structurally contained to one tenant's database. | Can potentially expose rows across tenants if a session variable is missed. |
| Data sovereignty and compliance story | Simplest to explain to an auditor or a procurement team. | Requires explaining a policy-based control rather than a physical one. |
| Operational cost per tenant | High. Each tenant is its own instance to patch, back up, and monitor. | Low. Infrastructure is shared across the entire tenant base. |
| Cross-tenant analytics | Requires a separate aggregation pipeline across instances. | A single query with the policy temporarily lifted for an internal role. |
| Best fit | Regulated enterprise customers whose contracts specify physical isolation. | Most B2B SaaS products scaling to a large number of small and mid-size accounts. |
/FAQ
Questions founders actually ask about multi-tenant architecture
- When should a B2B startup choose single-tenant architecture over multi-tenant?
When a customer's contract requires physical data isolation, not merely logical separation. This is common in healthcare, government and large financial institutions. Outside of a contractual requirement, shared infrastructure is the better default for most early-stage B2B products.
- How do you scale a Postgres database to handle millions of isolated tenant records?
Move isolation from the database or schema level to the row level using row-level security policies filtered by a tenant ID. This approach scales to hundreds of thousands of tenants on a single cluster, at the cost of depending on application code correctly setting a session variable on every query.
- What is the practical tenant limit for schema-per-tenant in Postgres?
There is no hard limit, but a practical ceiling forms somewhere between ten and fifty thousand schemas. Beyond that, shared system catalog bloat slows the query planner and makes routine migrations noticeably slower across the entire cluster.
- Is row-level security actually safe for multi-tenant data?
Yes, when configured and used correctly, but it is a logical control, not a physical one. It relies on the application setting the correct tenant identifier on every query, and a bug in that one place can leak data across tenants in a way a physically separate database cannot.
- Can you migrate a tenant from shared infrastructure to a dedicated database later?
Yes, but it gets harder over time, not easier. It is far cheaper if the data access layer was designed from the start with tenant_id threaded through every query, so the physical model behind it can change without rewriting the application's query logic.
- What is the main bottleneck for database-per-tenant architectures?
Connection overhead, not disk space or compute. Postgres has a practical limit on concurrent connections, and each additional tenant database adds to that ceiling, which is why this model tends to top out at dozens to hundreds of tenants per instance.

