Skip to main content
BlogBackend

Multi-Tenant SaaS Architecture With Postgres RLS

Why a shared schema with PostgreSQL Row-Level Security is the pragmatic way for a small team to isolate tenants in a multi-tenant SaaS.

Matthews Wong

Software Engineer

April 20268 min read
Multi-Tenant SaaS Architecture With Postgres RLS

Key Takeaway

Multi-tenant SaaS lets one application and one database serve many customers, which keeps hosting cheap for small teams. PostgreSQL Row-Level Security enforces tenant isolation in the database itself: every query is filtered by tenant_id through a policy, so even an application bug cannot leak one customer's rows to another.

If you are building a SaaS product as a one- to three-person team in Indonesia, your database design decides how much you pay per customer and how badly a single bug can hurt you. Multi-tenancy is the pattern that lets one running instance of your app serve every customer at once, instead of standing up a separate deployment for each.

The hard question is not whether to be multi-tenant, but how strictly to isolate each tenant's data. This post walks through the three classic isolation models, explains why a shared schema with a tenant_id column plus PostgreSQL Row-Level Security is the pragmatic default for a small team, and shows how to set it up without leaking data between customers.

What Multi-Tenancy Means for Cost

A tenant is one customer organization and all of its users. In a multi-tenant SaaS, a single application instance and, usually, a single database serve many tenants at the same time. The alternative, giving every customer their own copy of the app and database, is called single-tenant, and it multiplies your hosting bill and your maintenance work by the number of customers you have.

For a small team, density is everything. The more tenants you pack onto shared infrastructure, the lower your cost per customer, which is exactly what lets a lean SaaS stay profitable at a low price point. The catch is that shared infrastructure moves the burden of keeping tenants apart onto your design, so isolation has to be deliberate rather than accidental.

  • One deployment, one database, and one schema to migrate keeps operational overhead flat as you add customers.
  • Cost per tenant falls as density rises, which matters most when you charge a small monthly fee.
  • Shared infrastructure makes the noisy-neighbor problem and cross-tenant data leaks real risks you must design against.

The Three Isolation Models

There are three well-known ways to store many tenants' data, and they trade isolation against cost and operational effort. A separate database per tenant gives the strongest isolation and the simplest per-customer backup and restore, but it is the most expensive and the hardest to migrate, because every schema change must be applied across every database. A schema per tenant, one set of tables per customer inside a shared database, sits in the middle: decent isolation, but the number of tables grows without bound and querying across tenants becomes awkward. PostgreSQL's own documented limits bound two of those three. A single relation caps at 32 TB, which is the ceiling a shared table with row-level security eventually meets, and one database holds at most 1.431.650.303 relations, which is what schema-per-tenant spends every time a new customer multiplies the table count. Neither limit is close on a small SaaS, but both decide which model survives growth.

The third model is a shared schema: all tenants live in the same tables, and every row carries a tenant_id column that says which customer owns it. This gives the highest density and the lowest cost, and only one schema to migrate, but it puts the responsibility for isolation on correct filtering. Miss a tenant_id filter on one query and you leak another customer's data. That single weakness is exactly what Row-Level Security exists to remove.

For a one- to three-person team charging a modest subscription, start with a shared schema and a tenant_id column. It is the cheapest to run and the easiest to migrate, and RLS closes its one real gap. Reserve database-per-tenant for the rare enterprise customer with a compliance requirement.

How Postgres RLS Enforces Isolation

Row-Level Security is a PostgreSQL feature that filters which rows a query can see or change, evaluated inside the database on every statement. Once you enable it on a table and write a policy, the database itself refuses to return rows that do not belong to the current tenant, no matter what the application code asks for. That is the key shift: isolation stops depending on your application remembering to add a filter.

  • A policy is a rule attached to a table, using an expression that must be true for a row to be visible or writable; for tenancy the rule compares the row's tenant_id to the current tenant.
  • The current tenant is supplied per request as a session setting, for example by running SET app.current_tenant to the tenant id and reading it back inside the policy with current_setting.
  • Enabling row security is not enough on its own; the table owner and any role that created the table bypass policies until you also run ALTER TABLE with FORCE ROW LEVEL SECURITY.
  • With a connection pool, a pooled connection can carry a leftover tenant setting from a previous request, so the tenant context must be set at the start of every transaction and reset when the connection returns to the pool.

Practical Setup and Indexing

The setup is small. Add a tenant_id column to every tenant-owned table, enable row security on each, and add a policy that only allows rows whose tenant_id equals the value read from the session setting. Because your application connects as a normal role rather than a superuser, the policy applies to every query it runs. At the start of each request, after checking who the user is, set the session variable to their tenant id inside the same transaction that does the work.

Indexing matters because RLS effectively adds a tenant_id equality condition to every query. Put tenant_id first in the indexes that back your common lookups, so a composite index on tenant_id together with the column you filter or sort by keeps queries fast. Without it, the database may scan across all tenants' rows before discarding the ones the policy hides, which gets slower as your busiest tenants grow.

Setting the tenant context on a pooled connection and forgetting to reset it is the classic RLS leak. Bind the setting to the transaction so it clears automatically at commit or rollback, rather than to the whole session that outlives the request.

Pitfalls That Cause Leaks

RLS is only as strong as the way you deploy it. Most cross-tenant leaks come from a handful of predictable mistakes, and every one of them is easy to check for before you ship.

  • Connecting as a superuser or a role with the BYPASSRLS attribute skips policies entirely, so your application must run as an ordinary, non-bypassing role.
  • Forgetting FORCE ROW LEVEL SECURITY means the table owner still sees every row, which silently defeats isolation if your app connects as that owner.
  • Leaving a tenant setting on a pooled connection between requests can serve one customer another customer's data, so always scope the tenant context to a single transaction.

For a small Indonesian SaaS team, a shared schema with a tenant_id column and PostgreSQL Row-Level Security gives you enterprise-grade isolation at the lowest running cost. Enable RLS, force it on owners, run as a non-superuser role, and scope the tenant setting to each transaction. Do that, and no single application bug can leak one customer's data into another's.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is multi-tenant SaaS architecture?

Multi-tenant SaaS architecture is a design where a single running instance of an application, and usually a single database, serves many customer organizations at once. Each customer, called a tenant, sees only its own data. This raises density and lowers hosting cost compared with giving every customer a separate deployment.

Which tenant isolation model should a small team use?

For a one- to three-person team, a shared schema with a tenant_id column is usually the best default. It is the cheapest to run and has only one schema to migrate. PostgreSQL Row-Level Security then enforces isolation in the database, so it is nearly as safe as heavier models at a fraction of the cost.

How does PostgreSQL Row-Level Security prevent cross-tenant leaks?

Once RLS is enabled on a table with a policy, PostgreSQL filters every query inside the database so it only returns rows matching the current tenant. The tenant id is supplied per request through a session setting the policy reads. Because the filter lives in the database, an application that forgets a tenant_id condition still cannot see other tenants' rows.

Why is connection pooling a problem for RLS?

A connection pool reuses database connections across requests. If you set the tenant context on a connection and do not reset it, the next request that reuses that connection can inherit the wrong tenant and read the wrong customer's data. The fix is to scope the tenant setting to each transaction so it clears automatically.

Do I need to index tenant_id?

Yes. RLS effectively adds a tenant_id equality check to every query, so tenant_id should be the first column in the composite indexes that back your common lookups. Without it, the database may scan across all tenants' rows before discarding the ones the policy hides, which slows down as your busiest tenants grow.