Sooner or later every SaaS team has the same argument. Someone draws three boxes on a whiteboard and asks: do all customers share one database, does each get their own schema, or does each get their own database?
It feels like a small decision. It isn't. It shapes your costs, your migrations, your security bugs, and what you can promise a big customer who asks "is my data separate from everyone else's?"
Here is how I think about the three options, what the official docs say about each, and which one I would start with if I were building from zero today. I'm going to use PostgreSQL for the examples, since that's what most of us end up on.
First, the words
Cloud vendors use slightly different words for the same ideas, so let me pin them down.
AWS calls the two ends of the scale silo and pool. In its SaaS Lens, "The silo model refers to an architecture where tenants are provided dedicated resources", and "the pool model of SaaS refers to a scenario where tenants share resources." A third one, the bridge, is a mix of both. More on that near the end.
Map that onto databases and you get:
- Shared database, shared tables. Every tenant's rows live in the same tables, and each row has a tenant_id. This is the pool.
- Schema per tenant. One database, but each tenant gets its own PostgreSQL schema with its own copy of the tables. A middle path. -** Database per tenant.** Each tenant gets a separate database. This is the silo, at the data layer.
There's a fourth option, a whole separate stack per tenant (app servers and all), but that's a deployment decision more than a database one, so I'll leave it at the edges.
One more thing before we start. Microsoft's multitenant storage guide puts it plainly: "There's no single solution that works for every scenario." I agree. What follows is a default and a set of reasons, not a law.
Option 1: shared tables with a tenant_id
This is the cheapest model and the simplest to start. One database, one set of tables, a tenant_id column on everything that belongs to a customer.
On cost, Azure's guide says: "Generally, the higher the density of tenants to your deployment infrastructure, the lower the cost to provision that infrastructure." It also describes the shared multitenant database as the approach that "tends to come at the lowest financial cost of any approach."
Operations are easy too. You run one migration, once. You back up one database. Adding a new customer is an INSERT, not a provisioning job. That matters a lot when you're small.
The price you pay is isolation, and it comes in two forms.
Noisy neighbors. One busy tenant can slow everyone else down. The same Azure guide warns that "Multitenant data and storage services are susceptible to the noisy neighbor problem." You can reduce it with rate limits, query timeouts and per-tenant quotas, but the tenants are still sharing the same tables and the same disks.
Leaks from your own code. This is the one that keeps me up. In the shared model, "tenant A can't see tenant B's data" is only true if every single query remembers to filter by tenant_id. Forget it once, in one report, one export or one admin endpoint, and you've leaked data across customers. OWASP has kept this class of bug, broken access control, at the top of its list. The 2025 page for it says "Maintaining its position at #1 in the Top Ten", and its advice is blunt: "Except for public resources, deny by default."
"Deny by default" is the key idea for the shared model. Don't rely on developers remembering a filter. Make the database refuse to return rows that don't belong to the caller.
Making the shared model safe with row-level security
PostgreSQL has a feature built for exactly this: row-level security, or RLS.
Per the PostgreSQL docs, once you turn it on for a table, "all normal access to the table for selecting rows or modifying rows must be allowed by a row security policy." And if you forget to write a policy: "If no policy exists for the table, a default-deny policy is used, meaning that no rows are visible or can be modified." That's the "deny by default" idea, built in.
The setup has three parts. You turn RLS on for the table. You force it, so the table owner is covered too. And you write one policy that says a row is visible only when its tenant_id matches a setting your app sends with every request.
Then, at the start of every request, inside a transaction, your app tells the database who the tenant is. It does this by calling set_config with the transaction-only flag turned on, so the setting only lasts for this transaction. That matches how SET LOCAL works. The PostgreSQL docs say: "The effects of SET LOCAL last only till the end of the current transaction, whether committed or not." They also say set_config provides equivalent functionality to SET.
There are a few traps here, and every one of them is in the docs.
Your app must not connect as a role that skips RLS. The docs say "Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table." Table owners also skip it by default, which is why the setup above forces it with FORCE ROW LEVEL SECURITY. The docs say an owner can choose to be subject to row security with that command. The simple rule: your app connects as a plain role that owns nothing, and your migrations run as a different role.
Connection pooling can wreck the tenant context. If you use PgBouncer in transaction mode, remember what its docs say: "This mode breaks a few session-based features of PostgreSQL." A plain SET is on the list of things that don't work in that mode. The docs don't mention SET LOCAL at all. My reading is that SET LOCAL is safe because it lives and dies inside one transaction, and in transaction mode a server connection is assigned to a client "only during a transaction." But that's my reasoning, not a quote from the PgBouncer docs, so test it with your own pooler and settings. The mistake to avoid is setting the tenant once per connection and trusting it to stay put.
RLS has quiet leaks of its own. The PostgreSQL docs warn that "Referential integrity checks, such as unique or primary key constraints and foreign key references, always bypass row security", and that you need care to avoid "covert channel" leaks through them. In plain terms, a tenant could learn that a value exists in someone else's data by trying to insert it and seeing a unique-key error. For most apps that's a small risk, but you should know it exists.
And a fair warning from Microsoft, because RLS isn't free: "When you use row-level security, you need to ensure that the user's identity and tenant identity are propagated through the application and into the data store with each query. This approach can be complex to design, implement, test, and maintain. Many multitenant solutions don't use row-level security because of those complexities."
I still think it's worth it. I treat RLS as a seatbelt, not the engine. Your application code should still filter by tenant. Frameworks help with that: the Rails gem acts_as_tenant says that "ActsAsTenant uses row-level multitenancy" and adds the tenant filter to your models. Note its own caveat, though: scoping applies "BUT ONLY if a current tenant has been set." Any app-level filter has that gap, and that's exactly why a database-level backstop is useful.
Option 2: schema per tenant
Here, each tenant gets its own PostgreSQL schema inside one database. Your tables exist once per tenant, and each request points search_path at the right schema.
It looks like a clean middle ground. A query can't accidentally touch another tenant's tables because those tables aren't in the path. Backing up or exporting a single tenant is simpler than extracting rows from shared tables. And you can customize one tenant's tables without touching the others.
It has real fans. The django-tenants project describes itself as one that "enables Django powered websites to have multiple tenants via PostgreSQL schemas".
Here is where I get cautious, and where I'll be honest about what I couldn't find. I did not find an official PostgreSQL number for how many schemas is too many, and I'm not going to invent one. What I can give you is arithmetic: if each tenant's schema has 50 tables, then 1,000 tenants means 50,000 tables in one database. Every one of them has to exist, be migrated, be vacuumed and be tracked by the system. The pain is operational more than theoretical:
- Migrations multiply. A change that was one ALTER TABLE is now a loop over every schema. Some succeed, some fail halfway, and now your tenants are on different versions of your schema. Azure's guidance on this is direct: "If you use a database that enforces a schema, plan how to deploy schema updates across your estate." It also says to avoid having your app depend on one exact version of the schema.
- Onboarding is slower. Creating a customer means creating a schema and running all your migrations into it, not inserting a row. -** Everyone still shares one server.** Schemas isolate names, not CPU, memory or I/O. You get the noisy neighbor problem of the shared model plus the migration pain of the dedicated one.
That last point is why I call schema-per-tenant the awkward middle. It gives you a feeling of separation without giving you the real thing. If I need strong separation, I'd rather go all the way to separate databases. If I don't, I'd rather keep the simplicity of shared tables. Your mileage may differ, and for a small, stable number of tenants with heavy per-tenant customization, schemas can be the right call.
Option 3: database per tenant
Each tenant gets its own database. This is the strongest isolation you can get without giving every customer their own servers.
The upsides are the ones enterprise buyers ask about. A tenant's data is physically separate. You can back up, restore, move or delete one tenant without touching anyone else. You can tune or place a database near that customer. Azure's guide says "each tenant's data is isolated from the others' data, and you might be able to support some degree of customization for each tenant."
The downsides are cost and operations. The same guide notes that "The cost for this approach can be higher than shared hosting models because you provision dedicated data resources for each tenant." And it adds a line I'd print and stick on the wall: "It's important to use automated deployment approaches when you provision databases for each tenant. Otherwise, the complexity of manually deploying and managing the databases becomes overwhelming."
Connections are the other pressure point. Hundreds of databases means hundreds of connection pools, and your app needs to know which database to talk to for each request. That routing layer is code you have to write and keep correct.
I could not find a reliable public benchmark for what each model costs per tenant, so I won't give you a number. It depends on your database size, your cloud and your traffic. Do the math with your own figures before you commit.
The answer most teams end up with: start shared, keep an exit
Here's the part that I think matters most. You don't have to pick one model forever.
AWS describes this as the bridge model. "As you look at real application problems and you decompose your systems into smaller services, you will often discover that your solution will require a mix of the silo and pool models." Azure describes the same idea at the tenant level: "you might use a multitenant database for most of your tenants but deploy single-tenant stamps for tenants who pay more or who have unusual requirements."
So here is what I'd do for a new SaaS:
1. Start with shared tables, tenant_id on every tenant-owned table, and RLS turned on. It's the cheapest, the easiest to run, and the fastest to ship. Your first hundred customers will not care how their rows are stored.
2. Put tenant_id everywhere, even in tables that look like they don't need it. This is the "keep an exit" part. If every row can be tied to a tenant, you can later copy a big customer's rows into their own database with a simple filtered export. If you skipped the column, that move becomes a painful rewrite.
3. Never hard-code the assumption that all tenants share one connection. **Put the "which database does this tenant use?" decision in one place, even if today the answer is always the same.
**4. Move tenants out only when there's a reason. A large customer who needs guaranteed performance, a regulated one who needs separate data, a customer who wants their data in a specific region. Azure notes that "if your tenants need to meet specific compliance or regulatory standards, you might need to apply a higher level of isolation." Check what your specific standard actually requires before you assume it demands separate databases. If you grow past what one Postgres server can handle, the shared model also has a path forward. The Citus extension for Postgres shards by tenant: its docs say to partition tables by a common tenant_id column, so that each tenant's rows land together on one node. That's row-based sharding, and it only works well because every table already carries a tenant ID. Another reason for step two.
Test the thing you're afraid of
Whichever model you choose, the nightmare is the same: tenant A sees tenant B's data. So test for exactly that.
Here's the test I'd write on day one. Create two tenants with data in every tenant-owned table. Then, for each table and each API endpoint, act as tenant A and assert that you get zero of tenant B's rows. Run it in CI. When someone adds a new table, make the test fail until the table is covered. It's boring, and it's the cheapest insurance in this whole article.
For RLS, add one more check: connect as your real app role, with no tenant set, and make sure the query fails or returns nothing. If it returns everything, your app role is probably bypassing the policy.
Where I land
If I had to put it in one paragraph: start with shared tables and RLS, because it's cheap and fast, and put tenant_id on every row so you can leave. Treat schema-per-tenant as a niche tool for a small number of tenants that need lots of customization. Use database-per-tenant for the customers who truly need it, and automate everything about it from day one. And test cross-tenant access like it's a feature, because to your customers, it is.
A quick disclosure, since it's relevant: I work at Entalogics, where we build SaaS products for clients, and multi-tenant architecture is part of what our SaaS development service covers. So I have a bias toward taking this decision seriously early. Everything above is based on the public docs I linked, and you can check each one yourself.
What did you pick for your last SaaS, and would you pick it again?
Top comments (0)