Tenant isolation is the architectural decision that is most expensive to reverse. Shared schema with row-level security, schema-per-tenant, and database-per-tenant are not three rungs on a safety ladder. They are three different operating burdens. Choose based on how you will run migrations and prove isolation, not on which one sounds safest. Postgres RLS is enforcement, not a guarantee.

Which tenant isolation model should a SaaS platform actually choose?

The framing you meet most often reduces the choice to a price-versus-safety ladder: database-per-tenant is the secure one, schema-per-tenant is the balanced one, shared schema with row-level security is the cheap one where a forgotten WHERE clause leaks a customer's data. It is a memorable framing and it is a standing fixture of the discussion. It is also the wrong axis.

Every one of these models can be made safe, and every one of them can leak. What actually separates them is the work you inherit on the days that are not launch day: adding a column, restoring one customer, proving to an auditor that tenant A cannot read tenant B, and absorbing the tenant who runs a report across four years of history at 9am.

Sort the decision by that burden and it becomes tractable:

  • Shared schema with a tenant column and RLS. One set of tables, one migration, one backup. Isolation is a property of your policies and your connection role, which means it is code, which means it can be tested. The cost is that isolation is never structural. It is always asserted.
  • Schema-per-tenant. Isolation is structural and per-tenant restore is a pg_dump -n. The cost is that every schema change is now a fleet operation.
  • Database-per-tenant. The strongest boundary and the only one that survives a mistake in your own application code. The cost is the highest per-tenant fixed overhead, and it still does not buy you the performance isolation people assume it does. Notice that none of those costs are about security. They are about operations. That is the actual decision.

Why does row-level security leak even when it is turned on?

Because RLS has documented bypasses, and the most common one is the default. The Postgres 17 documentation is direct about it: "Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table. Table owners normally bypass row security as well, though a table owner can choose to be subject to row security with ALTER TABLE ... FORCE ROW LEVEL SECURITY."

Read that second sentence against how most applications are actually deployed. Your migration tool creates the tables. The role it used owns them. Your application connects with that same role. You enable RLS, you write correct policies, you test nothing, and RLS does exactly nothing, because the role you are connecting as owns the tables. The feature is on. The switch is not wired to anything.

Two fixes, both cheap and both easy to skip: FORCE ROW LEVEL SECURITY on every tenant table, and an application role that is not the owner and does not have BYPASSRLS.

There is a subtler bypass that survives even a correct setup, and it is the one worth understanding before you promise an auditor anything. Again from the Postgres 17 docs: "Referential integrity checks, such as unique or primary key constraints and foreign key references, always bypass row security to ensure that data integrity is maintained." The docs then warn plainly that schemas and policies must be designed "to avoid 'covert channel' leaks of information through such referential integrity checks."

That is not theoretical. Put a global unique constraint on an email column in a shared table. Tenant A inserts an address that tenant B already uses. The insert fails on a uniqueness violation. Tenant A cannot see tenant B's row, and now knows it exists. No policy was violated. The constraint answered a question RLS was supposed to refuse. The mitigation is to make such constraints tenant-scoped, (tenant_id, email) rather than (email), so the check itself is inside the boundary.

The planner has a related opening. The docs note that policy expressions are evaluated before conditions from the user's query, with one exception: "leakproof functions, which are guaranteed to not leak information; the optimizer may choose to apply such functions ahead of the row-security check."

The one default that works in your favour: enable RLS without writing a policy and Postgres denies everything. "If no policy exists for the table, a default-deny policy is used, meaning that no rows are visible or can be modified." A forgotten policy fails closed and loudly. A forgotten FORCE fails open and silently. Only one of those is a good failure.

The tenant context is really a connection pooling decision

Most shared-schema designs put the tenant in a session variable and read it back in the policy with current_setting(). The whole model then rests on one distinction that is easy to get wrong.

From the Postgres 17 documentation: "The effects of SET LOCAL last only till the end of the current transaction, whether committed or not." A plain SET, by contrast, persists "until the end of the session, unless overridden by another SET."

Now add a transaction-mode connection pooler, which is how almost every serious deployment runs. Use a plain SET to establish tenant context and the connection returns to the pool still carrying it. The next request that borrows that connection inherits a tenant identity nobody assigned to it. RLS then works perfectly and enforces the wrong tenant.

Sit with the failure mode, because it is the reason this is the section that matters. It does not raise an error. It does not log anything unusual. It returns a clean, well-formed, authoritative page of somebody else's data. The fix is one keyword, SET LOCAL inside an explicit transaction, and the bug is invisible until it is a phone call. This pattern and its footguns are covered well in a July 2026 writeup by Viktar Patotski, which names the same three traps independently: owner bypass, session-versus-transaction scope, and indexes that do not lead with tenant_id.

That last one is a performance trap rather than a safety one, and it is worth stating because RLS hides it. Your policy silently adds tenant_id to every query's predicate. If your indexes do not lead with tenant_id, Postgres cannot use them for the predicate you did not write and cannot see.

What actually solves the noisy neighbour problem?

Not the isolation model. This is where the ladder framing does the most damage, because people buy database-per-tenant expecting performance isolation and do not get it.

Separate databases on one Postgres instance still share CPU, memory, disk throughput, and the connection limit. One tenant's unbounded analytical scan evicts everyone's working set from shared buffers regardless of which schema or database it lives in. Isolation of names is not isolation of resources.

Real answers are scheduling answers, and they are largely independent of the tenant model: statement timeouts, per-tenant rate limits and work queues, a read replica for reporting so long scans never touch the transactional path, and connection limits per tenant so nobody can exhaust the pool. Only genuinely separate instances give hard resource isolation, and that is a per-tenant cost decision, not an architecture insight.

What breaks first with per-tenant migrations?

Atomicity. With one shared schema, adding a column is one ALTER TABLE that either succeeds or does not. With schema-per-tenant, it is N statements, each taking its own lock, and a failure at tenant 400 of 1,000 leaves you with a fleet in two different shapes and an application that must tolerate both. That state, not the migration itself, is the expensive part.

The scaling ceiling is real and documented by people who build for it. Writing about Citus 12's schema-based sharding in July 2023, Marco Slot of Citus Data was candid about the tradeoff: "A downside of schema-based sharding is the need to manage many tables and performance overhead," and "having a very large number of schemas (or rather, tables) can create certain performance issues in PostgreSQL." He points at a specific mechanism worth knowing: "each process keeps a separate catalog cache, which can cause high memory consumption when there are many tables."

That is the honest shape of it. Schema-per-tenant trades a cheap change and an expensive boundary for an expensive change and a cheap boundary. If you expect hundreds of tenants and heavy compliance pressure, that is a good trade. If you expect tens of thousands, you are buying a migration problem that grows linearly with sales.

The decisions, in the order they are hard to reverse

  1. The tenant boundary. Where tenant_id lives and which tables carry it. Changing this later is a rewrite, not a refactor.
  2. The connection role. Non-owner, no BYPASSRLS, with FORCE ROW LEVEL SECURITY on every tenant table. Cheap on day one and archaeological on day 500.
  3. Context propagation. SET LOCAL inside a transaction, decided together with the pooler, not after it.
  4. Constraint scope. Every unique constraint tenant-scoped, so integrity checks cannot answer questions your policies refuse.
  5. Index order. tenant_id leads, because the policy predicate is real even though it is invisible.
  6. The resource plan. Timeouts, replicas, and per-tenant limits. Not the isolation model. Items 2 through 5 are hours of work at the start of a project. Every one of them is a migration, an audit, and an incident review later.

How we build it

Beacon is multi-tenant, and it takes the shared-schema-with-RLS path deliberately: projects, memberships, permissions, integrations, and metrics are all scoped to customer organizations, with isolation enforced in Postgres rather than trusted to every query an application developer will ever write. That choice was made for the reason above, not for cost. A shared schema means one migration, which means the boundary is one thing to get right and test, instead of a fleet operation that can half-succeed.

The tradeoff is that isolation is asserted rather than structural, so it has to be proved. That means a test that connects as the application role and confirms that tenant A's query returns zero of tenant B's rows, running in CI, on the same footing as any other test. Isolation you have not tested is isolation you are hoping for.

This is the same reasoning behind Commerce Beacon's SaaS platform development work generally. The tenant model, permission boundary, and migration story get decided together and early, because they are the parts of a platform that cannot be renegotiated once customers are on it. Most of what a SaaS platform will regret is chosen in the first month, and almost none of it is chosen for the reasons people think.