Blog
Explainer

Database-per-tenant vs shared schema: the decision is about migrations, not isolation

Shared schema, schema-per-tenant or database-per-tenant. Everyone frames it as isolation versus cost. The real fork is how many times you run every migration.

Dharmendra Jagodana5 min read

In short

Shared schema puts every tenant in the same tables behind a tenant_id column. Schema-per-tenant gives each its own namespace in one database. Database-per-tenant gives each its own instance. The usual framing is isolation versus cost. What actually decides it is how many times you will run every schema change.

Shared schema puts every tenant in the same tables behind a tenant_id column. Schema-per-tenant gives each tenant its own namespace inside one database. Database-per-tenant gives each its own instance.

Every post on this topic then draws the same table: isolation on one axis, cost on the other, pick your point on the line. That table is not wrong. It is just not the thing that decides the choice, because isolation is mostly solvable in software and cost is mostly the database line item, which is the smallest bill involved. The thing that decides it is a schema change.

Why the isolation framing misleads

The fear behind database-per-tenant is a query that forgets its WHERE tenant_id = ? and hands one customer another customer's rows. Real fear, real incidents. But it is a fear about one bug class, and row-level security in Postgres closes most of it at the database rather than in application code. Once every table carries a policy that filters on the session's tenant, a forgotten filter returns nothing rather than everything.

What row-level security does not solve is the tenant whose data is a hundred times bigger than anyone else's, sitting in the same table, slowing every query that touches it. That is the noisy neighbour, and it is a performance problem dressed up as an isolation problem. The shared schema handles it by extracting that one tenant, not by moving everyone.

So the isolation axis collapses to two honest cases. A contract or regulator requires physical separation, in which case the decision was made for you and the cost is simply what the contract costs to deliver. Or it does not, in which case row-level security plus the ability to extract a whale gets you almost all of the way there.

What a migration means in each model

This is the fork.

Shared schema. A migration runs once. It touches one set of tables. If it fails, it fails once, at a known time, with one person watching. Adding a column to projects is a Tuesday.

Database-per-tenant. The same migration runs once per tenant. With 100 tenants that is 100 runs. With 10,000 it is 10,000, and some of them will fail, because some tenant will have a row that violates the new constraint or a database that was mid-backup when the job arrived. Now someone is supervising a rollout, and rollouts across thousands of databases take days. The cost post on this pattern worked that supervision out at roughly $25,050 a month at 10,000 tenants, at a $150 an hour loaded rate. It is the largest line in that post by a wide margin. Larger than the databases.

Schema-per-tenant. The migration runs once per schema, which is once per tenant. So the migration cost is the database-per-tenant cost. And the connection pool, the backups and the noisy neighbour are the shared-schema problems, because it is still one instance. This is why the pattern reads well in a design doc and badly in an incident review.

Key takeaway

Count the migrations, not the databases. A shared schema runs each schema change once. Per-tenant models run it once per tenant, and past a few hundred tenants the supervision of those runs costs more than the instances do.

How to actually choose

Two numbers decide it: how many tenants, and how much each one pays.

TenantsContract sizeModel
A handfulSix figures, with data terms in the contractDatabase-per-tenant
Hundreds to thousandsSelf-serve, per seat or per usageShared schema, row-level security
A shared base plus one or two whalesMixedShared schema, extract the whales
AnyAnyNot schema-per-tenant, unless the count is small and will stay small

Five customers paying six figures each, with a clause about where their data lives, is a per-tenant product whatever the ops cost, because the ops cost is in the price. Five hundred teams paying per seat is a shared schema, because the alternative is a migration rollout that competes with the roadmap every sprint.

Under about 100 tenants, none of this adds up to much either way, which is exactly when it is hardest to migrate away from whatever you picked. Pick for the count you expect at the point the product is working, not the count you have now.

When each one is the wrong choice

Database-per-tenant is wrong for a self-serve product. A signup that provisions a database is a signup that takes seconds instead of milliseconds, costs money whether or not the customer ever returns, and adds one more migration target for every trial that churns. The pattern assumes tenants are few and valuable. Free tiers violate both assumptions at once.

Shared schema is wrong when the contract says otherwise. Not when the customer prefers otherwise, or when a security questionnaire has a checkbox for it. When the signed terms require it. Everything short of that is a conversation about row-level security and a data-residency region.

Schema-per-tenant is wrong more often than it is right. It is the pattern teams reach for to avoid choosing, and it postpones the choice by inheriting both sets of problems. If the count is genuinely small, say a dozen tenants that will stay a dozen, it is fine. If the count is going to grow, it is the one you will regret.

What this looks like from where we sit

BuildBase runs both, at two different levels, and that turns out to be the useful way to think about it. Each customer of ours, an org building a product on the SDK, gets its own database. Inside that, the org's own customers are workspaces, which share the org's schema and are isolated by workspace rather than by instance. A schema change on the workspace model runs once per org database, and the ceiling an org hits is workspace count per plan rather than a connection limit per workspace.

Which is to say: per-tenant where tenants are few and paying, shared where they are many and self-serve, and the boundary drawn at the point where the tenant count changes character. That is the same rule as the table above, applied twice. The workspaces guide is the working-code version of the shared half, and the cost post is the arithmetic behind the per-tenant half.

tenancy
database
multi-tenant
architecture

Frequently Asked Questions

Is database-per-tenant more secure than a shared schema?

It removes one class of bug: a query that forgets the tenant filter cannot leak across tenants because there is nothing else in the database. Row-level security in a shared schema closes most of the same gap at the database layer. Per-tenant databases are the stronger guarantee, and the difference matters most when a contract or regulator requires physical separation rather than when you are weighing it yourself.

How many tenants can a shared schema handle?

Far more than most products will ever have, because the tenant count adds rows rather than instances. The ceiling is the largest tenant, not the number of tenants: one customer whose data dwarfs everyone else is the noisy neighbour a shared schema cannot wall off, and that is the point at which teams start peeling big tenants out into their own database.

Can I start with a shared schema and move to database-per-tenant later?

Yes, and it is the common path. Moving one large tenant out is a bounded job: copy their rows, point their connection at the new instance. Moving every tenant out is a migration of the whole product. Start shared, and design so that the tenant_id is the first column in every key, which is what makes the later extraction bounded.

Is schema-per-tenant a good middle ground?

It is a middle ground that inherits the worst half of each side. You still run every migration once per tenant, like database-per-tenant, and you still share one instance and one connection pool, like shared schema. It works at dozens of tenants. It is the pattern most often regretted at a few hundred.

Put this into practice

The modules in this post are one console away. 7-day free trial, no credit card.

7-day free trialNo credit card requiredCancel anytime