The mistake we see most often is treating these three patterns as a maturity path: start row-level, "graduate" to schema, "graduate again" to database-per-tenant. They are not a maturity path. They are three different products with different operational shapes, different cost curves, and different failure modes. Migrating between them is brutal.
Row-level: cheap to run, hard to do right.
Single schema, every multi-tenant table carries tenant_id, every query is filtered by it. The pattern is simple in concept and dangerous in practice, because the failure mode (a forgotten filter) is silent and catastrophic.
What we insist on, every time:
- Tenant context is request-scoped. Set once at the edge, available everywhere. Forbid passing it around as a parameter.
- An ORM-level query interceptor adds the tenant filter automatically. Forgetting it should be impossible, not "discouraged."
- Postgres RLS as defense in depth. Even if the ORM fails, Postgres row-level security catches the leak. Belt and suspenders.
- Periodic tenant-isolation tests in CI. Synthetic test cases that explicitly attempt cross-tenant reads and writes. The day they pass is the day you have a bug.
-- row-level enforcement, Postgres RLS
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid);Schema-per-tenant: middle ground, with teeth.
One Postgres database, one schema per tenant. Stronger logical isolation than row-level. Cross-tenant reads require an explicit cross-schema query, but the operational picture changes meaningfully.
- Migrations run N times, where N is your tenant count. Tooling matters. A migration that's instant on 5 schemas can be a maintenance window on 500.
- Connection pooling gets harder. Search_path changes per request invalidate pooled connections in transaction mode.
- Backups get cleaner. A tenant's data is a contiguous logical unit, easy to export and easy to delete.
If you're considering schema-per-tenant because row-level "feels risky," do row-level properly first. Schema-per-tenant doesn't make insecure code secure. It just makes it more expensive.
Database-per-tenant: strongest, most expensive.
One Postgres instance (or database within an instance) per tenant. The strongest isolation available without dedicated infrastructure per customer. Data residency, encryption-at-rest with per-tenant keys, and per-tenant backup policies all become straightforward.
The catch is operational. Each tenant database is a thing you have to:
- Provision when a tenant signs up.
- Migrate when you ship a schema change.
- Back up and validate restores for.
- Monitor for capacity and connection use.
- Destroy correctly when a tenant offboards.
That's not a deal-breaker. It's a known cost. We default to this pattern when customers are enterprise-only with regulatory data-residency requirements, and not before.
Picking the right one.
The decision is rarely about technology. It's about your tenant distribution and your operational tolerance. A few heuristics from our work:
- SMB SaaS with thousands of tenants? Row-level, with RLS as defense in depth. Anything else is operational overhead you don't need.
- Mid-market with dozens to low hundreds of tenants? Either row-level or schema-per-tenant works. Pick based on per-tenant customization needs.
- Enterprise with data residency / regulatory constraints? Database-per-tenant. Anything else is a procurement conversation you don't want to have.
- Mixed customer base? Row-level for SMB tier, database-per-tenant for enterprise tier. Two products in one. Be honest about that complexity.
The pattern doesn't matter half as much as picking it on purpose, writing it down, and not drifting later.
Closing.
The most expensive engineering work we've done (across multiple engagements) has been retrofitting tenant isolation onto a system that didn't start with it. Do it on purpose, do it early, and accept that the pattern you pick is roughly permanent.