← Back to blog
13 May 20267 min readEngineering

Per-tenant databases vs shop_id: why we picked the harder path

There's one architectural question that defines what kind of multi-tenant SaaS you're building, and most companies pick the easy answer.

One database, with every table carrying a shop_id column. Every query filters on it. Every API route asserts it matches the authenticated user. The platform's code becomes a careful dance of "did I remember to add the WHERE shop_id = ?" — usually enforced by a middleware layer, an ORM convention, or sheer discipline.

This is how Shopify is built. It's how most SaaS commerce platforms are built. It's the default in nearly every multi-tenant tutorial. There's a reason: it's operationally simpler. One database to back up, one connection pool, one schema migration to ship.

For LOAM, we picked the other option. Each merchant gets their own Postgres database. Database-per-tenant. The harder path operationally, the dramatically safer path from a data-integrity and customer-trust perspective.

Here's the trade-off honestly.

What "shop_id" gets wrong

The shared-database approach has a single failure mode that subsumes every other failure mode: a missed WHERE shop_id = ? clause can leak data across tenants.

This isn't theoretical. It's the canonical Shopify-class bug. Engineers ship code; one ORM call drops the scoping; data from tenant A is briefly visible to tenant B; depending on what code consumes the result, the breach is anywhere from "embarrassing log entry" to "GDPR-reportable incident."

Every mature multi-tenant SaaS has elaborate defences against this. Static analysis. Pre-commit hooks. Code review checklists. Runtime row-level security. They mostly work. Mostly. The bug class can be reduced in frequency but never eliminated because it depends on humans remembering the right thing every time they write a query.

The architecture itself makes the bug possible. That's the structural issue. Defences are necessary because the architecture is dangerous, not because the engineers are sloppy.

What database-per-tenant gets right

If each merchant's data lives in their own database, with their own connection pool, addressed by their own connection string — the question "did I scope this query correctly" doesn't exist. There is no shop_id column. There is no shared table. The architecture makes cross-tenant queries syntactically impossible, not just operationally rare.

When a request arrives at LOAM, our middleware resolves the tenant by host (or by authenticated session) and obtains a connection to that tenant's database. Every query in that request runs against that database and only that database. There is no way to accidentally query another tenant's data because there is no database connection that points at another tenant's data.

This isn't a code-review burden lifted. It's a class of bug removed from the architecture entirely.

What it costs

We'll be honest. Per-tenant databases cost more, in three ways:

1. Operational overhead. Backups become per-database. Migrations have to run across N databases instead of one. Connection pooling has to be smart about which pool serves which tenant. You can't run a single ALTER TABLE and be done.

2. Resource cost. Postgres has a baseline memory and connection footprint per database. At 10 tenants the overhead is invisible. At 10,000 tenants it requires real engineering — connection pooling at scale, autoscaling Postgres clusters, lazy database provisioning, idle database eviction.

3. Engineering complexity. Every framework convention assumes one database. We patched Medusa v2's MikroORM getFreshManager to route per request. We built a ConnectionPoolManager that materialises per-tenant pools on demand and evicts idle ones. We made RequestContext carry the active tenant through the entire request lifecycle including background workers and Inngest steps. None of this was free.

The operational cost is real. We pay it because the benefit is bigger than the cost — and importantly, the benefit grows as the tenant count grows, while the cost is mostly fixed engineering effort.

What it earns

Three things you can't get from shop_id, in our experience:

1. A defensible privacy story. When a merchant asks "where is my data," the answer is concrete: in a database called omit_tenant_{slug}, in our omit-postgres instance, accessible only via the connection string our request middleware uses for your sessions. Not "in our SaaS database with a shop_id filter." The latter is true of every shop_id-based platform on the internet; the former is true of almost none of them.

For GDPR (and for the next privacy regulation we don't yet have a name for), this is the difference between a credible posture and a hopeful one.

2. Exportability without ambiguity. A merchant who wants to export their data on LOAM gets a pg_dump of their own database. Standard SQL. Schemas, indexes, foreign keys, everything. They can restore it in any Postgres instance they like. Compare to a shop_id-based export, which produces a custom JSON or CSV file the merchant has to interpret — and which will inevitably miss tables the export forgot to include.

The architectural difference becomes a real product difference at the moment of churn (or threatened churn). The merchant believes they own their data, and the export tooling confirms it.

3. Per-tenant operations. Need to apply a one-off SQL fix to a single merchant's data? With shop_id you write a carefully-scoped UPDATE WHERE shop_id = ? and pray. With per-tenant databases you connect to that database directly, run the migration, and you can't touch any other tenant by accident.

Same with monitoring, query analysis, performance tuning. We can profile a slow query for a specific merchant without their data mingling with anyone else's in our APM tooling.

The thing that almost killed us

We didn't pick this approach because it's elegant. We picked it because the alternative made one specific class of bug too easy to ship.

In our early prototype (April 2026), we built on the shop_id pattern. It took six weeks before we shipped a cart workflow that briefly returned line items from a different cart, scoped by user_id only and not by store_id. The bug was caught in code review. It would not have been caught if the reviewer hadn't been looking. The shared cart logic was correct; the missing scope was just an oversight.

We spent a weekend looking at the code and asked: what would prevent this entire bug class? The only honest answer was "different architecture." So we rebuilt around the per-tenant model. The cost was three weeks of refactoring. The benefit is that the bug we shipped that first time cannot, by construction, be shipped now. Even if every engineer forgets to scope every query, the architecture scopes for them.

What you should pick

If you're building a multi-tenant SaaS today, the right answer depends on what you sell. If you sell something where the data is non-sensitive — analytics dashboards on public data, weather APIs, link-shorteners — shop_id is probably fine. The cost saving is real and the risk is bounded.

If you sell something where the data is sensitive — commerce, healthcare, finance, personal communications, anything regulated — we'd push hard for per-tenant. Not because shop_id can't be made safe; it can, with discipline. But the architectural change reduces the attack surface and the audit surface simultaneously, and a year from now when you're explaining your privacy posture to a customer's compliance team, the conversation is much shorter.

LOAM picked per-tenant because we're storing customer PII, payment-adjacent data, brand assets, and conversational logs that include private customer questions. The risk asymmetry didn't allow the easy answer.

We think that's the right call for a few more SaaS categories than the industry's current default suggests. The harder path is harder; it's also the one that scales further before the trust issues catch up.

What you'll see in LOAM's code

If you read the open Medusa v2 fork, the relevant patches are small but load-bearing. A one-line change to MikroOrmBase.getFreshManager that picks up the active tenant from RequestContext. A ConnectionPoolManager keyed by tenant ID with a configurable idle-eviction policy. An Express middleware that resolves the tenant by host and sets RequestContext for the rest of the request.

The wedge is small. The implications are big.

Read more about tenant isolation in practice or why LOAM is built this way. The architecture is the product.