Engineering decisions

One database or one per customer: choosing a multi-tenant model

EdgeBrain Studios12 min read

Start with one database, a tenant id column on every table, and row-level security enforcing it. Move a tenant into its own database only when a named requirement forces it: a contractual guarantee of physical separation, data residency in a specific region, a per-tenant point-in-time restore, or a tenant whose schema genuinely diverges. Absent one of those, shared is cheaper to build, far cheaper to operate, and the only model where a migration is still one command.

The reason isn’t elegance. Two costs scale linearly with tenant count in the isolated model and stay flat in the shared one: database connections and migration runs. Everything else in this decision is downstream of those two.

The three models, and what each one actually is

Shared schema with a tenant id column

Every table carries tenant_id. Every composite index leads with it. Every query filters on it, and row-level security enforces the filter inside the database, so a forgotten WHERE clause returns nothing rather than everything. One database, one pool, one migration.

Clora, our own multi-company HR and payroll portal, runs this way on Neon Postgres: one database, a company id on every row, and the tenant boundary held by the database rather than trusted to application code. The Clora AI case study covers what it does.

Schema per tenant

One database, one Postgres schema per tenant, resolved by setting search_pathper connection. It looks like a compromise and behaves like the worst of both. Each schema duplicates every table’s catalogue entries, so 40 tables across 500 tenants is 20,000 tables plus indexes, which autovacuum scheduling, planner lookups and pg_dumpall feel. Fan-out without isolation. We wouldn’t choose it.

Database per tenant

Its own database, often its own instance, branch or project. The boundary is real and holds without application discipline. So does the per-tenant restore, which is usually the real reason anyone ends up here.

 Shared schemaSchema per tenantDatabase per tenant
Boundary enforced byRLS policiessearch_pathThe database itself
Migrations at 500 tenantsOne run500 runs500 runs
ConnectionsFlat in tenant countFlat, sessions pinnedLinear in tenant count
Restore one tenantBespoke extract and reinsertBespoke, slightly easierOne restore command
Noisy neighbourShared cache, locks, workersSame as sharedContained
Ops surfaceOne targetOne target, huge catalogueN targets to watch and back up

Connection count is what kills database-per-tenant

Not storage. Storage is what everyone worries about, and it is almost never the constraint.

A Postgres connection is an operating system process. A working figure for an idle backend is 5 to 10 MB resident, more once it sorts or hashes with a generous work_mem. Stock max_connections is 100, and managed providers derive it from instance memory, so a small instance caps out lower than you assume.

Shared, the arithmetic doesn’t involve tenants at all. Six application instances holding a pool of ten is 60 connections, whether you have five customers or five thousand.

Isolated, it does, because a pooler keys its pools on the database and user pair. PgBouncer will not hand a server connection opened against tenant A’s database to a client asking for tenant B’s. So 500 tenants across six instances, one warm connection each, is 3,000 connections that exist only to be idle. Set the minimum to zero and you have traded memory for a handshake on the first request after any quiet period: single-digit milliseconds locally, materially more across TLS to a provider in another region.

Every escape costs something. Serverless drivers that speak HTTP, which is why Neon’s HTTP driver and Supabase’s transaction-mode pooler exist, drop the persistent connection and with it session state: no session-level prepared statements, no advisory locks, no LISTEN and NOTIFY.

Our own rule of thumb, sensitive to how bursty your traffic is: database-per-tenant is comfortable to about 50 tenants, operationally annoying between 50 and 200, and needs a dedicated pooling tier past that.

Migration fan-out: one deploy becomes 500 sequential runs

A 20-second migration takes 20 seconds. The same migration against 500 tenant databases is close to three hours run serially, or roughly 17 minutes at ten workers in parallel.

Wall clock isn’t the real problem. The run is no longer atomic. Nine minutes in, tenant 312 fails on a unique constraint because it has duplicate rows nobody else has. Now 311 tenants sit on schema version 48, 189 sit on 47, and your code has to work against both.

What that forces you to build, before your first isolated tenant:

  • Expand and contract on every migration. Nullable column, deploy the code that writes it, backfill, NOT NULL in a later release. Good practice in the shared model. The only thing that works in the isolated one.
  • A per-tenant schema version record, because “did the deploy finish” stops being a yes-or-no question.
  • An idempotent, resumable runner with per-tenant retry that picks up from the last successful tenant. Not your ORM’s migrate command in a bash loop.
  • A quarantine list, so one broken tenant doesn’t hold the fleet at the old version while you debug it.

Our own estimate: a week of platform engineering up front and a permanent operational surface after. It is never in the original quote.

Noisy neighbours, and how big the blast radius really is

Four mechanisms, roughly in the order they show up.

  1. Shared buffer cache.One tenant’s unbounded analytical scan evicts everyone’s working set, and the cache refills from disk under load.
  2. Connection exhaustion.One runaway loop opens connections until everybody else gets “too many clients already”. This one becomes an outage, not a slowdown.
  3. Lock contention.A long ALTER TABLE, or an unindexed DELETE, queues every other tenant’s writes behind it.
  4. Autovacuum starvation.One high-churn tenant keeps the workers busy, another tenant’s table bloats quietly, and its plans degrade over weeks.

Mitigations that work in a shared database, cheapest first:

  • statement_timeout per role, not globally: ten seconds for the web role, five minutes for a reporting role. One line of DDL that removes most of mechanism one.
  • A separate small pool for anything user-triggered and expensive, plus a per-role CONNECTION LIMIT. That caps mechanism two.
  • pg_stat_statements with the tenant id in a query comment, so a slow query names its tenant instead of making you guess.
  • Declarative partitioning by tenant id past roughly 50 to 100 million rows, so pruning keeps one tenant’s scan inside one partition.
  • A read replica, with reporting pointed at it.

With those in place, one, three and four become slow-query tickets rather than outages. Two is where isolation genuinely wins. Which makes noisy neighbours the weakest of the four arguments here, despite being the one every comparison post leads with.

Per-tenant restore is the requirement that quietly forces isolation

Point-in-time recovery restores a cluster, not a customer. This is the part the pros-and-cons posts never reach, and it is usually what an enterprise security questionnaire is really asking underneath the wording.

One tenant deletes three months of records at 14:20 on a Tuesday. Restoring the shared database to 14:19 rolls back every other tenant’s afternoon, so nobody does that. What you actually do is:

  1. Restore a full copy of the cluster as of 14:19 into a scratch instance. Minutes with provider-level branching, frequently an hour or more from a conventional backup of a few hundred gigabytes.
  2. Extract that tenant’s rows, in foreign-key order, across every table carrying a tenant id.
  3. Reinsert into production without colliding with anything written since 14:19.

Steps two and three are bespoke to your schema, need re-verifying whenever it changes, and never get written until the first time they’re needed, which is the worst available moment. Database-per-tenant turns all three into one restore command. That is the strongest argument here, and it is about recovery, not security.

The middle path we’d build first: nightly per-tenant logical exports into object storage, plus soft deletes with a retention window so the common case never needs a restore at all. Restore one export into a scratch database on a schedule, because an untested backup is a belief rather than a control.

Row-level security is a real boundary, if you close three holes

The open web already has good RLS walkthroughs, so this isn’t another one. These are the three things that turn a policy set into decoration.

The table owner bypasses its own policies.ENABLE ROW LEVEL SECURITY doesn’t apply to the owner, and plenty of applications connect as exactly that. Use a role that owns nothing and has no BYPASSRLS attribute, or add FORCE ROW LEVEL SECURITY.

Connection reuse leaks the tenant setting. SET rather than SET LOCAL leaves the value on the pooled connection for whoever gets it next. Under transaction-mode pooling, anything set outside a transaction is a leak waiting for traffic.

A policy without WITH CHECK filters reads and permits writes. USING decides which rows are visible, WITH CHECK decides what a row is allowed to become. Omit it and a tenant can update a row into another tenant’s id, the exact failure the mechanism exists to prevent.

RLS converts a class of application bug into an empty result set. It does nothing at all if your application connects as a superuser, in which case you have policies that never run and a compliance answer that isn’t true.

What each model costs, in money

Shared is one managed Postgres. Sized for a real platform, say 4 to 8 vCPU and 16 to 32 GB, it lands in the low hundreds of dollars a month plus backup storage, and that number does not move when you go from 40 tenants to 400. Storage grows. Compute mostly doesn’t.

Database-per-tenant is dominated by one input: the floor cost of an idle database with your provider. Look it up before the architecture debate, because it swings the answer by an order of magnitude. A dedicated instance at 50 dollars a month makes 200 tenants 10,000 a month before storage and backups. A provider billing per database on shared compute with scale-to-zero might floor at a couple of dollars, and the objection evaporates.

Observability is a per-target cost too: two hundred databases is two hundred alert routes, two hundred restore tests you should be running and aren’t, and an on-call rotation that has to know which is which at 3am. Our own estimate: below roughly 80 to 100 dollars per tenant per month in contract value, per-tenant infrastructure is hard to justify on cost alone. You are buying the restore guarantee.

The hybrid, which is where most platforms land

Shared by default, isolated by exception. Less work than it sounds, and the answer nobody on this SERP names.

  • A tenant catalogue. One small table mapping tenant id to a connection string and a schema version. The application resolves its connection per request instead of reading a singleton out of an environment variable.
  • One migration runner that takes a list of targets. The shared database is target zero. Same runner, same ordering, same version table.
  • The same application code on both paths, because the shared path already filters by tenant and the isolated path simply has one tenant in the database.

Put the connection resolution in early, while every tenant still lives in the shared database. Two days on day one, three weeks once forty modules import a singleton client. That one decision is the difference between the migration below being routine and being a project. It is platform work rather than a feature, which is why it sits in our platform band from $28,000 and why most of it is Postgres and Supabase platform engineering rather than product code. The figures we publish, that one included, are estimates in US dollars, not quotations; the quote that follows scoping is fixed in writing before kickoff.

How to move one tenant from shared to isolated

Five steps, no cutover weekend. Assumes connection resolution exists, which is why it is the first thing to build.

1. Provision and migrate the empty target

New database, full migration history, schema version confirmed identical. Days before the move, not on the day.

2. Copy the data, then keep it in sync

An initial COPY per table in foreign-key order, or logical replication with a row filter, which Postgres 15 and later support through a WHERE clause on the publication. Verify with a row count and checksum per table, not by looking at the application.

3. Freeze writes for that one tenant, briefly

A per-tenant read-only flag the application checks, returning a clear message rather than a 500. Ninety seconds, for one customer, at a time they agreed to, isn’t a maintenance window. That is the whole reason to move tenants one at a time.

4. Flip the catalogue entry and verify

Point the tenant at the new connection string, drop the flag, watch their traffic for an hour. The old rows stay in the shared database, untouched and unreachable. That is your rollback.

5. Delete the shared rows after a retention window

Two clean weeks, one final export, then delete in batches predicated on the tenant id so you aren’t holding locks on tables other tenants are using. Teams skip this and end up paying for two copies while answering a questionnaire about where the data actually lives.

Where this costs us, and when to build none of it

The honest limit of the hybrid we just recommended is that it makes you the operator of a small control plane. The tenant catalogue becomes a dependency of every request, so it needs caching, an invalidation story, and a failure mode that isn’t “the platform is down because one table is unreachable”. The runner needs testing against a fleet, which means keeping dummy tenants alive purely to be migrated. None of it is hard, all of it is ongoing, and none of it ships anything a customer can see.

Now the case for building none of it. If you serve under about ten organisations, all internal or all on one contract, and nobody has raised residency or restores, a tenant id column and RLS policies is the complete answer. No catalogue, no runner, no routing layer. Adding them today costs a month and buys an option you may never exercise. Price the boring purchase first, the way we’d argue before an identity build in auth: build, buy, or self-host.

One thing is worth doing on day one regardless, and it costs an afternoon: put the tenant id on every table and lead every composite index with it. Retrofitting that into a schema already holding production data is the expensive version of this article. If you inherited a platform with none of it, judging whether to rewrite or refactor an inherited codebase is the exercise we’d run before this one.

The compressed decision

Shared schema with a tenant id and enforced RLS is the default, and it stays the default longer than most teams expect. Schema per tenant is the trap in the middle. Database per tenant is paid for in connection management and migration fan-out, so buy it against a named requirement, and per-tenant restore is the most common honest one.

Build the connection resolution before you need it, keep the migration runner fleet-aware, and move tenants one at a time with the old rows still sitting there as a rollback. If you want a second opinion on which side of that line your platform sits, tell us what you’re building.

Got a version of this problem?

Send a paragraph describing what you’re trying to build and what’s currently in the way. We reply inside two working days with a scope, a price and a start date. If we’re not the right people for it, we say that instead.