Choosing a Database for Your SaaS Isn't One Decision. It's 4.

The isolation model, the security layer, the ORM, and the hosting call, 4 choices that actually decide your stack.

10 min read

I spent an evening on "which database for my SaaS" like it was one decision. It isn't. How you split tenant data is one call. What stops your own code's mistakes from leaking one tenant into another is a second. What you use to actually talk to the database in TypeScript is a third. Whether you run any of it yourself is a fourth, and most guides never get past the first.

None of these 4 are hard on their own. The mistake is treating the first one as the whole decision, then getting surprised 6 months in when the second one turns out to be the one that actually breaks in production.

Office worker pointing at one decision tree while superhero studies four complex database architecture diagrams on whiteboard
Picking a database is easy. Everything else? Plot twist.

Pool Wins, Unless You're Regulated

TITLE "The Pool, Bridge, and Silo Blueprint" + subtitle "3 ways to split tenant data, 1 clear winner for most builders". Metaphor: 3 warehouses side by side, 1 giant shared warehouse with labeled shelves, 1 warehouse split into locked inner rooms, and a row of separate small warehouses. Style: cartoon 90s Hanna-Barbera/Nickelodeon, thick black outlines, halftone dots, bouncy rounded shapes. Palette: mustard #F4C430, hot pink #FF3E7F, sky blue #4FC3F7, cream #FFF8E7, black #111111. Content: 3 labeled warehouses, SILO (1 small building per tenant, tiny door, heavy padlock), BRIDGE (1 building, locked inner rooms labeled by tenant name), POOL (1 open warehouse floor, shelves color coded by tenant, 1 shared forklift). Highlight: POOL warehouse glowing with a gold outline and small sparkle stars, forklift labeled TENANT ID. Legend: sticky note bottom left, lock icon means strict isolation, shelf icon means shared and filtered. Footer: © rentierdigital.xyz bottom-right, small, handwritten. NOT flat corporate vector, NOT minimalist tech startup aesthetic.
Three Multi-Tenant Data Architecture Patterns Compared Visually

3 ways to structure a shared database exist, and only one of them makes sense for most solo-built SaaS.

  • Silo: 1 database instance per tenant. Full isolation, zero risk of customer A peeking at customer B's rows, and a linear cost curve that turns ugly around your 50th customer.
  • Bridge: 1 database, 1 schema per tenant. Cheaper than Silo, but every migration now runs across however many schemas you have, and Postgres was not exactly built to enjoy hosting hundreds of copies of the same table structure. Push past a few thousand tenants and the system catalog itself starts groaning under the weight.
  • Pool: 1 set of tables, shared by everyone, split by a tenant_id column.

Pool is the boring answer and it's the right one. Cheapest to run, one schema to migrate, one connection pool to size. The catch is the same catch every shared-table setup has: your code has to remember to filter by tenant_id on every single query, every single time, forever. Forget that filter once, in a background job you didn't test carefully, and customer A gets a look at customer B's invoices. Karen from Accounting notices before you do, and now you're writing an incident report instead of shipping features.

The exception is real, not theoretical: healthcare, finance, anything where an auditor will eventually ask you to prove isolation at the infrastructure level, not just the query level. If that's your context, Silo stops being paranoid and starts being the price of entry. For everyone else, Pool it is.

The Bouncer Postgres Already Built

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

That's Row-Level Security (RLS), Postgres's built-in answer since 2016. 4 lines, and now the database itself refuses to hand back a row that doesn't match the current tenant, no matter what your application code does or forgets to do. Think of it as a bouncer standing at the query layer: doesn't matter how charming your SELECT statement is, if the tenant_id on your wristband doesn't match, you're not getting past the door.

The FORCE keyword matters more than people expect. Without it, the table owner and any superuser role walk straight through the policy, which happens to be the role most local dev setups and admin scripts run as. Testing as the superuser is basically playing in god mode: nothing you break shows up in the run that counts. I found this out spending a good 20 minutes wondering why my supposedly bulletproof policy let every tenant see everyone's data, all because I never left god mode. Always test as the actual application role, not admin, because admin lies to you in exactly the way you don't want during a demo.

Unrelated, but while we're on database mysteries I haven't solved: my staging database runs out of connections every single Sunday, like clockwork, and I have never once been deploying anything that day. C'est la vie.

One more footgun, and this one lives at the connection pooler level. If you're running PgBouncer or anything like it in transaction mode, setting the tenant context has to happen inside the transaction, scoped with SET LOCAL, not a bare SET.

BEGIN;
SET LOCAL app.tenant_id = '3f29e1d2-91aa-4b3a-9d21-7e0dcb9a1234';
SELECT * FROM invoices;
COMMIT;

A bare SET sticks to the connection, and connections get recycled by the pooler and handed to the next request. Do that and tenant B's request can inherit tenant A's session variable, the RLS policy passes clean, and you've built the exact leak RLS was supposed to prevent. SET LOCAL dies at COMMIT or ROLLBACK, no matter who grabs the connection next.

RLS is one layer, not the whole defense. The DevOps basics an AI agent skipped before wiping prod matter just as much as any policy sitting inside Postgres: scoped credentials, actual staging environments, backups that live somewhere other than the same box, since RLS keeps tenants apart but was never built to back anything up.

1 Index, 25 Times Faster

Without an index, every query that hits a table under RLS forces Postgres to check the policy row by row, and if there's nothing to help it narrow that down, it falls back to a sequential scan. That's the database equivalent of grinding through every random encounter instead of taking the warp point straight to the boss. On a table with a few thousand rows that's invisible. On a table with a few million, your dashboard load time turns into a coffee break.

CREATE INDEX idx_invoices_tenant_id ON invoices (tenant_id, created_at);

Add a composite index with tenant_id as the leading column and Postgres switches from scanning the whole table to a bitmap index scan, going straight to the rows that matter. Benchmarks on million-row tables put the difference at 25 times faster, sometimes more, and the p95 latency overhead from RLS itself drops to under 2%, close enough to free that it stops being a talking point.

The policy check itself is cheap: a single equality comparison Postgres already knows how to plan around. The real cost hides somewhere else, in a query planner decision you only notice once you go looking for it. That's how teams end up shipping RLS, watching a slow query show up in production 3 weeks later, and spending an afternoon convinced the security model itself is the problem, when the actual fix was one CREATE INDEX statement sitting there the whole time.

One more detail, small but expensive if you skip it: if your policy calls a custom function instead of a plain column comparison, mark that function STABLE.

CREATE FUNCTION current_tenant() RETURNS uuid
  LANGUAGE sql STABLE
AS $$ SELECT current_setting('app.tenant_id')::uuid $$;

Leave it VOLATILE (the default) and Postgres re-evaluates it for every single row instead of once per query, which quietly defeats the whole point to add the index in the first place.

I think this covers the RLS setup that works for the vast majority of Pool-model SaaS out there, though I'll admit I'm not fully sure it holds once you're running dozens of stacked policies per table. That's a scale of complexity I haven't personally hit yet.

Prisma, Drizzle, or Skip SQL Entirely

TITLE "The Prisma vs Drizzle Race" + subtitle "bundle size and cold start, side by side". Metaphor: 2 racers on a track, 1 hauling a heavy trailer, 1 on a light bike. Style: retro arcade 8-bit pixel art. Palette: mustard #F4C430, hot pink #FF3E7F, sky blue #4FC3F7, cream #FFF8E7, black #111111. Content: left racer labeled PRISMA pulling a trailer marked with a weight icon, right racer labeled DRIZZLE riding a light bike with a lightning bolt sticker, finish line labeled EDGE RUNTIME. Highlight: DRIZZLE racer glowing with speed lines and small lightning bolt sparkles. Legend: sticky note bottom left, trailer icon means compiled query engine, lightning icon means zero dependency runtime. Footer: © rentierdigital.xyz bottom-right, small, handwritten. NOT flat corporate vector, NOT stock infographic style.
Prisma vs Drizzle Performance Comparison Racing Infographic

Prisma and Drizzle are both ORMs, the layer between your TypeScript and the SQL underneath, and Prisma reads like plain English while the autocomplete makes you feel dangerous 🤓. Version 7 finally ditched the old Rust query engine binary for a WebAssembly compiler, dropping the bundle from 14MB down to 1.6MB and fixing the cold start problem that made it painful on serverless for years.

Drizzle skips the abstraction almost entirely. Schemas are plain TypeScript, queries look close to the SQL they generate, and the whole thing weighs somewhere between 12KB and 57KB depending what you import. Cold start lands at 50ms. If you're deploying to the Edge (Cloudflare Workers, that kind of runtime), Drizzle is the pick. You'll need to actually know SQL to use it well, which is either a feature or a filter depending on where you're starting from. Prisma's generated joins occasionally read like a Stack Overflow answer, copied fast and never re-read.

Drizzle also has a real answer for the RLS wiring problem: it lets you declare roles and policies straight in the TypeScript schema, and Node's AsyncLocalStorage carries the tenant_id from your middleware down to the query function, no passing it through every function signature by hand. Prisma extensions can fake the same trick with client wrappers, but it's bolted on. Drizzle was built with it in mind.

Pick Prisma for the smoothest local dev experience if you're not deploying to the Edge, pick Drizzle if you're already comfortable in SQL and cold start genuinely matters for your stack.

Supabase Hands You RLS and the Ops Bill

Supabase is Postgres underneath, wired to their own auth, storage, and an auto-generated API layer (PostgREST) that lets your frontend talk to the database almost directly. That last part is why RLS stops being optional on Supabase: your client app can hit the database without a backend sitting in between, so the policy is the only thing standing between customer A and customer B's rows. Supabase runs on exactly the RLS setup from earlier in this piece, just pre-wired into their dashboard instead of a migration file you write by hand.

Self-hosting Supabase is real and free, they publish the full docker-compose stack. It's also 12 containers: Postgres, GoTrue for auth, PostgREST, Realtime, Storage, Kong as the API gateway, Studio for the dashboard, and a few more depending what you enable. Running the full stack yourself means becoming the sysadmin for a dozen services that used to be someone else's problem, which is its own final boss. RAM climbs fast, and every one of those 12 containers wants its own updates, its own TLS, its own eyes on it when something falls over.

For a solo build, the hosted free tier removes all of that math. Self-hosting only starts paying for itself once there's a real reason behind it: data residency rules, or a hosted bill that's grown past what the ops burden costs you in time. (Supabase is the exact stack I walk through step by step in Vibe Coding, For Real, if you want the guided version of going from that first Next.js demo to something actually shipped.)

Convex Skips SQL and Self-Hosts in 1 Container

My Convex side project has no schema.sql, no CREATE POLICY, no migration folder. Queries are TypeScript functions, multi-tenancy is a filter you write inside that function, and real-time updates ship by default instead of being a feature you bolt on. The trade is real too: no raw SQL access means no dropping into psql when something looks wrong, you're fully inside Convex's query language or you're stuck.

Convex also ships an open source self-hosted backend, and the contrast with Supabase's stack is the whole pitch: 1 container running the sync engine and the database, not 12. Less to update, less to monitor, less to get paged for. The catch is maturity, not complexity: Convex self-hosted is younger, the community tooling around it is thinner, and you're trading Supabase's decade of "someone already hit this bug before you" for a project with a lot less runway behind it.

I went all in on shipping a SaaS backend without touching SQL for that side project. Self-host Supabase and you inherit a dozen containers to get what their free tier already hands you, and self-host Convex and you inherit 1 container backing a project that hasn't been through as many production fires yet. Two different bills for the same instinct to own your stack.

Actually, let me put it differently: none of these 4 decisions are hard on their own. Pool, Bridge, Silo (a motivated dev lands on the right answer in 10 minutes with a napkin sketch). Prisma or Drizzle is a 10-minute call too. Supabase, Convex, or raw Postgres? Pick one and move on. The hard part is the composite index you add or don't, the function you mark STABLE or don't, the SET LOCAL you put inside the transaction or don't. The kind of detail none of the other 3 calls ever warn you about.

Your database never hides your architecture mistakes. It just waits for the volume to show up before it sends you the bill.

Add the index before it does.

Sources

This post may contain affiliate links. If you click them, I might earn a small commission (costs you nothing, and helps me keep shipping quality articles every day for your reading pleasure).


Database choice is really 4 separate decisions stacked on top of each other, and most guides stop after the first one. The Demo vs Product Checklist in the welcome kit walks you through the infrastructure and query performance decisions that actually break in production, not just the architecture pick.

Get the welcome kit