Schema-Per-Tenant: The Gold Standard for SaaS Data Isolation
Every advertiser who runs a publisher program on a shared platform is trusting that their data lives in a sealed room. Their commission rules, their publisher payout details, their conversion logs, their fraud signals — none of it should ever be visible to the account next door. Yet in a surprising number of affiliate and marketing platforms, that "sealed room" is a polite fiction. Underneath, everyone's records sit together in the same tables, separated only by a column and a promise that every query remembers to filter on it. It works right up until one query forgets.
That gap between "should never happen" and "cannot happen by design" is the entire subject of this article. At TrackingMD we made a deliberate architectural choice: every advertiser account gets its own dedicated PostgreSQL schema. Not a tenant column. Not a row-level policy. A physically separate namespace of tables, provisioned the moment the account is created. Think of it as the difference between a hospital that keeps every patient's chart in one giant filing cabinet with names written on each folder, and one that gives each patient a locked room with their own cabinet inside it. Both can work. Only one fails safe.
The two ways to isolate tenants
When a SaaS product serves many customers from one codebase and one database cluster, it has to answer a foundational question: how do we keep tenant A's data away from tenant B? There are two dominant answers, and the distance between them is larger than it looks.
Shared table with a tenant column (row-level isolation). Every tenant's rows live in the same physical tables. A clicks table holds every click for every advertiser. A commissions table holds every commission. Each row carries a tenant_id (or organization_id), and the application is responsible for appending "WHERE tenant_id = ?" to every single query. Postgres row-level security policies can move that filter down into the database, which is a real improvement — but the guarantee still rests on the correctness of a filter that must be present, correct, and un-bypassable on every read and every write, forever.
Schema-per-tenant (logical isolation via dedicated schemas). Every tenant gets its own schema — its own private set of tables — inside the shared PostgreSQL cluster. Tenant A's clicks table and tenant B's clicks table are genuinely different objects. A query executed in tenant A's context physically cannot see tenant B's clicks table, because it isn't on the search path. There is no tenant_id column to forget, because there is nothing else in the table to filter out.
To be precise about what schema-per-tenant is and is not: it is logical isolation inside a shared database cluster. We are not claiming separate servers or separate hardware per customer. The tables live in the same Postgres instance. What changes is that the boundary between tenants is a first-class database construct — a namespace — rather than a value in a column that application code has to honor on every access.
Why the shared model keeps people up at night
The shared-table approach is popular because it's easy to start with. One migration, one table, add a column, ship it. The problems don't show up on day one. They show up at scale, under deadline pressure, in the code paths nobody reviewed carefully.
- The forgotten WHERE clause. This is the classic cross-tenant leak. A developer writes a new report, a new export, a new background job, and forgets the tenant filter — or writes a raw query for performance and bypasses the ORM scope that would have added it. In a shared-table model, that single omission returns another advertiser's data. Industry post-mortems of multi-tenant breaches repeatedly trace back to exactly this: a query that ran without its scope.
- Aggregates and joins that quietly widen. A GROUP BY that should have been per-tenant, a join that reintroduces rows the outer filter removed, a subquery that isn't scoped — these don't throw errors. They return numbers. The wrong numbers, mixed across accounts, presented as if they were correct.
- Bulk operations with global blast radius. An UPDATE or DELETE that misses its tenant predicate doesn't leak data — it mutates or destroys everyone's. A single bad migration or cleanup script can touch every customer at once.
- The boundary is invisible to reviewers. When every tenant shares a table, nothing in the schema tells you where one customer ends and another begins. Correctness lives entirely in discipline and code review, and discipline degrades as a team and a codebase grow.
Row-level security genuinely helps with several of these, and mature teams use it well. But it is a control you have to configure correctly on every table, keep aligned with a session variable, and trust to survive every future migration. It narrows the gap. It does not close it. The failure mode is still "one mistake exposes another tenant."
How schema-per-tenant closes the gap
Our platform is built on Laravel 12 with the stancl/tenancy library (v3), configured to use its PostgreSQL schema manager. Here is what that actually means in practice, grounded in how the system is wired.
Every tenant's schema is named with a tenant_ prefix followed by the tenant's unique identifier. When a request arrives, the platform identifies which advertiser it belongs to from the hostname — each advertiser gets a subdomain — and initializes that tenant's context. Initialization switches the active database connection's search path to that tenant's schema. From that point on, a query for the clicks table resolves to that tenant's clicks table and no other. The scoping isn't a clause the developer remembered to add; it's the environment the query runs inside.
This is a categorically different safety property. In the shared model, isolation is something every query must opt into. In the schema-per-tenant model, isolation is the default, and there is no supported way for ordinary application code to reach across it. A missing filter can't leak another tenant's rows, because the other tenant's rows live in a table that isn't even visible on the current search path.
The separation also extends beyond the database. Our tenancy configuration makes the cache, the filesystem, and the queue tenant-aware too — cache keys are tagged per tenant, storage paths are suffixed per tenant, and queued jobs re-enter the correct tenant context when they run. The isolation boundary follows the data through every layer it touches, not just the SQL.
Provisioning: isolation from the first second
A boundary is only as good as the moment it's created. If a new account exists for even a short window before its walls go up, that window is a risk. So provisioning a TrackingMD tenant is a deterministic, ordered pipeline that fires the instant a tenant is created:
- Create the schema. A brand-new, empty PostgreSQL schema is carved out for the tenant.
- Migrate it. The full set of tenant migrations runs inside that schema, building the tenant's private tables — programs, publishers, clicks, conversions, commissions, the ledger, and the rest.
- Bootstrap it. A seeding job runs inside the new schema to install the defaults every advertiser needs from minute one: branding, a default program, approval configuration, and the system general-ledger accounts. It also starts the account's trial.
Because these steps run in order and the schema is guaranteed to exist and be fully migrated before any application data is written, there is no half-provisioned state where a tenant's data could land somewhere shared or ambiguous. The room is built, furnished, and locked before anyone moves in. The same seeding path runs idempotently on every deploy, so the defaults stay consistent over the account's whole lifetime — not just at birth.
There's also a clean separation between the central database — which holds cross-cutting concerns like the plan catalog, tenant registry, and super-admin records — and the per-tenant schemas that hold each advertiser's actual business data. Central and tenant live on different connections. The platform's own control plane never commingles with customer data, and a query written against one is structurally distinct from a query against the other.
What this buys you as an advertiser
The architecture is invisible when you use the product, which is exactly the point. But it translates into properties you can actually rely on:
| Concern | Shared table + row filter | Schema-per-tenant (our model) |
|---|---|---|
| Default when a query is unscoped | Returns other tenants' rows | Returns nothing — other data isn't on the path |
| Where the boundary lives | In application code and policies | In the database structure itself |
| Blast radius of a bad bulk write | Potentially every tenant | Confined to one tenant's schema |
| Reviewer visibility | Boundary is invisible in the schema | Boundary is an explicit, named schema |
| Failure mode | One mistake can leak | One mistake stays contained |
- Your data can't bleed into another account by accident. The most common cross-tenant failure — a query that forgot its scope — doesn't have a path to your neighbor's tables.
- Contained blast radius. Operational mistakes, bad migrations, and runaway cleanup jobs are bounded to a single schema instead of the whole customer base.
- Cleaner audits and cleaner exits. When your records live in a discrete, named schema, "show me exactly what data you hold for this account" and "remove this account's data" are precise operations against a well-defined boundary — not a hunt-and-filter across shared tables.
The honest trade-offs
No architecture is free, and pretending otherwise would undercut the point. Schema-per-tenant asks more of the platform operator. There are many schemas to migrate instead of one, which is why migrations run as a fleet-wide operation rather than a single command. Certain database-level limits — connection pooling, lock budgets, the mechanics of dropping schemas at scale — need deliberate handling that a single shared schema never forces you to think about. These are real engineering costs. We take them on so that the isolation guarantee lands on the side of the customer rather than the convenience of the platform. The complexity lives in our infrastructure; the safety lives in your account.
The diagnosis
Row-level policies and shared tables can be operated safely by careful teams, and many are. But "safe if everyone is careful, on every query, forever" is a promise, and promises are exactly what fails under load. Physical schema separation replaces the promise with a property. Your advertiser data doesn't stay isolated because our code remembered to isolate it on this particular request — it stays isolated because it lives in a room that other accounts have no path into.
For a platform handling the commercial nervous system of your publisher program — who to pay, how much, on which conversions, flagged by which fraud signals — that distinction is not academic. It is the difference between trusting a policy and trusting a wall. As we continue to build out reporting, ledgers, and lead-handling on top of this foundation, every new feature inherits the same boundary for free. The isolation isn't a feature we bolt onto each capability; it's the ground the whole product stands on.
See it in your own program
Start your free 7-day trial and put these ideas to work — no credit card required.
Start free trial