Multi-tenant SaaS data isolation: shared tables, schemas or databases
How to choose between shared tables, schema per tenant and database per tenant for a SaaS, and how to enforce isolation in shared tables with PostgreSQL 18 row-level security and transaction-scoped tenant context in Node.js.
Updated 7 min read

In this article
Every business-to-business SaaS product stores data for many customers, usually called tenants. The most damaging bug such a product can ship is one customer seeing another customer's data. It is also one of the easiest bugs to write: a single query that forgets WHERE tenant_id = ? is enough.
How you isolate tenants is an architecture decision that is expensive to change later. It affects cost, migrations, backups, performance isolation, compliance conversations and how quickly you can onboard a new customer. This article explains the three common models, gives a decision table, and then shows how to enforce isolation in the shared-table model with PostgreSQL row-level security and Node.js, so that a forgotten filter returns nothing instead of someone else's rows.

The three models: silo, pool and bridge
AWS's SaaS guidance names three patterns, and the terms are now widely used:
- Silo: each tenant gets dedicated resources, for example its own database or even its own full stack, while still sharing one onboarding, identity and operations experience.
- Pool: tenants share resources, such as one database where every row carries a tenant identifier. This is the classic idea of multi-tenancy, built for economies of scale.
- Bridge: a mix, where some parts of the system are siloed and others pooled, for example a pooled application database with a dedicated analytics store for regulated customers.
For relational databases, these map to three concrete designs.
| Shared tables (pool) | Schema per tenant | Database per tenant (silo) | |
|---|---|---|---|
| Isolation strength | Logical; depends on queries or row-level policies | Logical; depends on connection search path and grants | Strong; separate database, separate credentials possible |
| Cost per tenant | Lowest | Low to medium | Highest |
| Schema migrations | Run once | Run once per tenant schema | Run once per database; needs orchestration |
| Onboarding a tenant | Insert a row | Create schema and objects | Provision a database |
| Noisy neighbours | Shared; need per-tenant limits | Shared server | Isolated (if on separate servers) |
| Per-tenant backup and restore | Hard; data is interleaved | Possible per schema | Straightforward |
| Cross-tenant reporting | Easy | Needs unions or ETL | Needs ETL |
| Practical tenant count | Very large | Hundreds to low thousands, depending on tooling | Limited by operations budget |
How to choose
Start from your customers, not from the database:
- Many small tenants on self-serve plans: shared tables. Database-per-tenant for thousands of free-trial accounts is mostly an operations bill.
- A few large customers with contractual isolation, data-residency or "bring your own key" requirements: database per tenant for those customers, which makes your system a bridge.
- Customers who regularly ask for a full export or a point-in-time restore of only their data: per-tenant schemas or databases make that far easier.
- Heavy, uneven workloads where one tenant can slow everyone down: plan for moving large tenants to dedicated resources later, even if you start pooled.
Many products start pooled and add a silo tier for enterprise customers. That only works smoothly if every table carries a tenant identifier from day one and every code path resolves the tenant explicitly, so the same code can point at a dedicated database later.
Enforcing isolation in shared tables with PostgreSQL row-level security
In the pooled model, the usual defence is discipline: every query must include a tenant filter. Discipline fails eventually. PostgreSQL's row-level security (RLS) adds a second layer inside the database, so a query that forgets the filter sees no rows instead of all of them. The examples below follow the PostgreSQL 18 documentation.
Step 1: Add the tenant column and enable RLS
CREATE TABLE projects (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id uuid NOT NULL,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX projects_tenant_id_idx ON projects (tenant_id);
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);Key behaviours, from the PostgreSQL manual:
- Once RLS is enabled, a table with no matching policy uses default deny: no rows are visible or can be modified.
- The
USINGexpression controls which existing rows are visible;WITH CHECKcontrols which rows may be inserted or updated. WithoutWITH CHECK, theUSINGexpression is used for both. - Superusers and roles with the
BYPASSRLSattribute always bypass row security. Table owners also bypass it unless you useFORCE ROW LEVEL SECURITY, which is why the example sets it. current_setting(name, true)returnsNULLinstead of raising an error when the setting does not exist. TheNULLIF(..., '')wrapper also handles the case where the setting exists but is empty, which can happen on a pooled connection after an earlier transaction has ended; without it the cast touuidwould fail.
Step 2: Connect as a role that cannot bypass policies
CREATE ROLE app_user LOGIN PASSWORD 'set-from-your-secret-store'
NOSUPERUSER NOBYPASSRLS;
GRANT SELECT, INSERT, UPDATE, DELETE ON projects TO app_user;Run migrations with a separate owner role, and run the application as app_user. If the application connects as the table owner or a superuser, the policies above protect nothing.
Step 3: Set the tenant per transaction in Node.js
PostgreSQL's set_config(setting_name, new_value, is_local) sets a configuration parameter; when is_local is true, the value applies only to the current transaction. That property matters with connection pools: a value scoped to a transaction cannot leak into the next request that borrows the same connection. With the pg package (version 8.23 at the time of writing):
import pg from 'pg';
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
});
export async function withTenant(tenantId, work) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
const result = await work(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
// Usage inside a request handler, after authentication has
// resolved the tenant from the session (never from user input alone):
const projects = await withTenant(req.session.tenantId, async (db) => {
const { rows } = await db.query(
'SELECT id, name, created_at FROM projects ORDER BY created_at DESC LIMIT 50'
);
return rows;
});Notice that the query has no WHERE tenant_id clause; the policy supplies it. Many teams still add the filter explicitly in application code, both for readability and so the query planner sees the condition directly. RLS then acts as the safety net rather than the only control. Because the setting is transaction-scoped, this pattern also works behind a transaction-pooling proxy, as long as every tenant-scoped query runs inside withTenant.

Step 4: Test the isolation, not just the features
Write integration tests against a real PostgreSQL instance that:
- Create two tenants with data in every tenant-scoped table.
- Query each table as tenant A and assert no tenant B rows come back.
- Try to insert a row with tenant B's ID while scoped to tenant A and assert it fails the
WITH CHECKpolicy. - Query without setting a tenant and assert zero rows.
- Check, in CI, that every table with a
tenant_idcolumn has RLS enabled and forced, by querying thepg_classcatalogue columnsrelrowsecurityandrelforcerowsecurity.
If you use MySQL
MySQL does not offer an equivalent of PostgreSQL's CREATE POLICY, so pooled isolation in MySQL lives in the application. The practical substitutes are a data-access layer that refuses to build a query on a tenant-scoped table without a tenant condition, composite indexes that lead with tenant_id, foreign keys that include the tenant column so rows cannot reference another tenant's records, and the same cross-tenant integration tests described above. Database-per-tenant is also more common on MySQL for customers with strict isolation needs.
Things that leak across tenants besides SQL
- Caches: include the tenant ID in every cache key.
- Object storage: prefix keys by tenant and sign download URLs per request.
- Search indexes: filter by tenant on every query, or use one index per large tenant.
- Background jobs: carry the tenant ID in the job payload and set the database context when the job runs.
- Logs and error reports: they often contain customer data; restrict who can read them.
- Rate limits: apply per tenant as well as per user, so one tenant cannot exhaust shared capacity.

Summary
Pick the isolation model from your customer mix: pooled tables for many small tenants, dedicated databases for the few who need them, and a bridge of both as you grow. In the pooled model, do not rely on every developer remembering a WHERE clause. Put a tenant identifier on every row, connect with a role that cannot bypass policies, scope the tenant per transaction, and prove isolation with tests that try to break it.
Frequently asked questions
Is row-level security enough to isolate tenants on its own?
It is a strong safety net, not a complete design. The application must still resolve the tenant from an authenticated session, connect as a role without BYPASSRLS, and isolate caches, files, search indexes and background jobs. Tests should try to read and write across tenants.
Does PostgreSQL RLS work with connection pooling?
Yes, if the tenant is set per transaction.
set_config('app.tenant_id', value, true)applies only to the current transaction, so the value cannot leak to the next request that uses the same pooled connection.Why does the table owner still see every row?
PostgreSQL lets table owners bypass row security unless you run
ALTER TABLE ... FORCE ROW LEVEL SECURITY. Superusers and roles with BYPASSRLS always bypass it, so the application should use a separate, unprivileged role.Can I do the same in MySQL?
MySQL has no equivalent of PostgreSQL's CREATE POLICY, so isolation in shared tables must be enforced in the application's data-access layer, backed by tenant-aware foreign keys, indexes and cross-tenant tests. Database per tenant is a common alternative for customers who need stronger isolation.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Row Security Policies (PostgreSQL 18 documentation) — PostgreSQL Global Development Group
- System Administration Functions: configuration settings (PostgreSQL 18) — PostgreSQL Global Development Group
- Silo, pool, and bridge models (SaaS Lens) — Amazon Web Services
- pg package on npm — npm
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
Related tools
- Directory profile
Cloudflare
Global network service providing DDoS protection, a web application firewall, CDN and DNS, with a free plan for websites.
A tool connected to a service selected for this article. Compare its fit for your project; inclusion is not an endorsement.
- Directory profile
GitHub
Git-based platform for hosting code, reviewing pull requests, tracking issues and running CI/CD workflows, with free and per-user paid plans.
A tool connected to a service selected for this article. Compare its fit for your project; inclusion is not an endorsement.
- Directory profile
GitLab
DevSecOps platform combining Git repositories, merge request reviews, CI/CD pipelines and issue tracking, offered as SaaS, dedicated or self-managed.
A tool connected to a service selected for this article. Compare its fit for your project; inclusion is not an endorsement.
More from Cloudsoft Zone

Industry updateNode.js
Which Node.js version to use in October 2026: LTS lines and the new schedule
Node.js 26 becomes LTS on 28 October 2026, Node.js 24 moves to maintenance and 20.x is already end of life. Here is every supported line, the new yearly release model from Node.js 27, and which version to run.
By Cloudsoft Zone

ComparisonNode.js
Express 5 vs Fastify 5: which Node.js framework to pick in 2026
Express 5 and Fastify 5 compared on support status, validation, error handling, upgrades and performance, with the same API built and tested in both.
By Cloudsoft Zone

Architecture guideAI and Automation
How to build a safe AI content pipeline with human fact review
An architecture for AI-assisted publishing where people stay responsible: untrusted source snapshots, claim checking with citations, a review queue AI cannot skip, labels and rollback.
By Cloudsoft Zone
