The moment you sell your system by subscription to more than one company, every customer gets a private corner inside a single product. Where you put that corner, whether it is rows in a shared table, a schema of its own, or a whole database, decides what you pay for servers afterwards, how long a single upgrade takes, and whether you can accept a customer who requires their data to stay inside their own country.
That is what a multi-tenant SaaS application means: one running copy of the software serving several companies, each of them a “tenant” that sees only its own data. The single engineering question worth starting from is where you draw the line between one tenant and the next.
Many teams settle it by asking which model is the most secure. That question leads nowhere, because all three models are safe when implemented carefully and all three are breakable when neglected. The questions that actually separate them are operational:
- If one customer asks you to restore their data to two hours ago, how long does that take, and what happens to everyone else while it runs?
- If you add a column to a core table, how many times does the operation run, and for how many minutes is the product frozen?
- What happens when a developer forgets the tenant_id condition in a single query?
- When one tenant saturates the server, who else notices?
This guide answers all four, and grounds the answers in what the PostgreSQL, MySQL and Laravel documentation actually says rather than in general impressions.
The three models, and what each one means on an operating day
| Shared table with a tenant_id column | Schema per tenant | Database per tenant | |
|---|---|---|---|
| Infrastructure cost | Lowest; one server serves everyone | Moderate; one server holding many more objects | Highest; connections, backups and maintenance per database |
| Restoring one tenant | Row-level surgery | Possible by dumping the schema, with an important caveat | Restore a single file |
| Migrations | One operation | Repeated once per tenant | Repeated once per database |
| Reports across all customers | An ordinary query | Need explicit aggregation | Need a separate data pipeline |
| Noisy neighbour | No barrier | A limited barrier | A real barrier |
| Hosting one customer in a specific country | Not possible without separating them | Not possible without separating them | Possible per customer |
The shared table with a tenant_id column
Every company lives in the same tables, and one column tells them apart. This is the cheapest model to run and the easiest to upgrade: you run a migration once and it reaches everybody, and you write a report comparing usage across all customers as a plain query.
The price is that isolation becomes the responsibility of every line of code you write. Worse, restoring a single tenant turns into manual work: there is no file called “this customer’s data”, only rows scattered across twenty tables with foreign keys and a specific order for deleting and inserting. The first time a customer asks for a restore after a bad bulk import, you discover you never built that tool.
Schema per tenant
One database, and inside it a complete set of tables for each customer. The PostgreSQL documentation compares schemas to directories, with the difference that they cannot be nested, and that reaching a schema you do not own requires the USAGE privilege on it. That gives you a barrier the server itself understands: an account bound to one schema sees nothing else, even when the code is wrong.
Backups look solved at first glance, because pg_dump -n exports one schema. But the tool's documentation adds a caveat worth reading before you build a whole plan on it: with -n the tool will not carry along other objects that the chosen schema leans on, and it says outright that a schema-only dump comes with no guarantee of restoring by itself into a clean database. Custom types, shared functions and extensions usually live outside the schema. So if you choose this model, make the first test you write a restore of one schema into a completely empty database, not into a copy of production.
The second problem is migrations. The ALTER TABLE documentation says the command takes an ACCESS EXCLUSIVE lock except where a particular subform is marked otherwise, and the lock table shows that this is the only lock mode that blocks even a SELECT. Altering a table inside a tenant's schema therefore freezes that tenant for the duration. At twenty customers that is an acceptable risk; at a thousand you need a plan that runs migrations in batches and survives some of them failing halfway. Not all changes are equal, fortunately: adding a foreign key, for instance, only needs a SHARE ROW EXCLUSIVE lock according to the same source.
And if you are on MySQL, this middle model does not exist for you at all: the CREATE DATABASE documentation states plainly that CREATE SCHEMA is a synonym for CREATE DATABASE. You move from the shared table straight to a database per tenant.
Database per tenant
The strongest isolation, the clearest backups, and the easiest answer when a customer asks exactly where their data lives. The cost that does not show up in theoretical comparisons is connections.
The default value of max_connections in PostgreSQL, as the documentation notes, is typically one hundred, and it can only be changed at server start. Because a connection pool is usually built per database, a hundred customers with a sensible minimum of connections each will exhaust that number before a single real user logs in. The practical answer is an intermediate pooler that reuses connections across databases, which is one more infrastructure component with its own configuration and its own failures.
Migrations stay repeated, as in the schema model, and on top of that the report that spans all customers becomes a data pipeline into a separate warehouse rather than a query.
The first risk: one query that forgot tenant_id
In the shared model, a cross-tenant leak does not need an attacker. A new report written in raw SQL by a developer in a hurry is enough, or a background job processing “all overdue orders”, or an export page that takes an identifier from the URL without checking who owns it.
Relying on developer attention is not a guarantee, because it assumes that everyone who writes a query in this system over the coming years will remember the same rule. The structural guarantee is that the database or the framework refuses the query instead of letting it succeed incomplete.
What the framework gives you: a global scope in Laravel
The Laravel documentation describes a global scope as a constraint attached to every query a given model issues. It is a clean and quick solution, and its limits are stated on the same page: withoutGlobalScope removes a specific scope and withoutGlobalScopes removes them all. On top of that, the scope belongs to the model, so any query written directly through the query builder or in raw SQL never passes through it.
Put differently, a global scope protects you from forgetting; it does not protect you from bypassing. That is enough for a small product with one team, and not enough for a product touching health or financial records.
What the database gives you: row security in PostgreSQL
Row security policies move the condition into the server. Once you run ALTER TABLE … ENABLE ROW LEVEL SECURITY on a table, every ordinary read or write against it has to be permitted by some policy, and a table with no policy at all denies by default: nothing shows and nothing changes. That is exactly the behaviour you want, because the starting state sees nothing.
The usual pattern is to carry the tenant identity in a two-part parameter such as app.tenant_id. The customized options documentation notes that PostgreSQL accepts a setting for any two-part parameter name and treats it as a placeholder even when no extension has defined it. The decisive part is the is_local argument of the set_config function: when it is true, the new value applies only during the current transaction. With a pooler handing the same connection to consecutive requests, that difference is what stops one tenant's identity from leaking into the next tenant's request.
Three documented traps catch almost everyone doing this for the first time:
- The table owner bypasses the policy. A superuser, or any role carrying the BYPASSRLS attribute, steps past row security every time, and the documentation adds that a table's owner normally does the same unless the owner is made subject to it with ALTER TABLE … FORCE ROW LEVEL SECURITY. Since your application usually connects as the account that created the tables, enabling policies without this step gives you the feeling of safety without the safety.
- A restrictive policy on its own blocks everything. The CREATE POLICY documentation explains that permissive policies are combined with OR and restrictive ones with AND, then states outright that if there are no permissive policies, access is denied. Anyone who writes the tenant policy as RESTRICTIVE alone finds the table empty and cannot see why.
- Unique constraints talk. Integrity checks on unique, primary and foreign keys sit outside row security altogether so that the data stays consistent, and the documentation warns in as many words that this opens a covert channel for leaking information. In practice: a unique constraint on the email column of a shared table makes the error message tell your customer that this address is already in use by another customer. Make uniqueness composite on (tenant_id, email) rather than on email alone.
Snaabble provides a tailored technical assessment to define the right stack & exact budget.
And when you take a backup of a table with policies on it, set row_security = off. The documentation clarifies that this does not itself bypass the policies; it raises an error if a query's results would be filtered, so you find the problem at backup time instead of at restore time.
For MySQL teams: there is no row-level equivalent. The GRANT documentation shows that the finest level at which a data privilege is granted is the column inside a table. The boundary a MySQL server understands is the database, the table or the column, not the row. That is a solid practical reason to choose a database per tenant on MySQL, or to build the product on PostgreSQL from the start if strict isolation is a clause in your contracts. A comparable decision in the application layer is covered in React or Laravel, and when to use each.
When compliance picks the model for you
Sometimes you do not choose. An enterprise customer, or an organisation in a regulated sector, puts a single line in the requirements: our data stays inside the country, or on separate infrastructure.
In Egypt, Article 14 of the Personal Data Protection Law requires a licence from the Centre in order to store personal data in a foreign country, and the word “store” covers putting your database on a server abroad. In Saudi Arabia, Article 29 of the Personal Data Protection Law restricts transfers outside the Kingdom to specified conditions, and the transfer regulation sets out the acceptable safeguards. The detail, the deadlines and the penalties are covered in Customer data protection in Egypt and Saudi Arabia, and their effect on your provider and server location in How to choose a web host.
What matters here is one consequence: a customer who must keep data in their own country cannot live in your shared table hosted in Frankfurt. So even if you start with the shared table, which is the right start for most products, design it so that a single tenant can be lifted out and moved to a dedicated database without rewriting the application. In practice that means routing every access through one layer that knows how to resolve a tenant's connection, and never assuming anywhere that all customers share one database.
This hybrid arrangement is where most mature products end up: the majority of tenants in the shared model, and a few on dedicated databases under specific contracts priced to reflect their cost. Retention, deletion and the legal periods for keeping copies complete the picture, and they are covered in Website backup strategy.
The noisy neighbour
One tenant uploads a file with a million rows, or runs a report scanning their entire history, and the product slows down for everybody. This is not a coding problem but a resource-sharing problem, and it is handled with controls that have specific names.
Measurement comes first. The pg_stat_statements extension keeps planning and execution statistics per statement; turning it on means listing it in shared_preload_libraries and restarting the server, and it tracks 5000 distinct statements by default. Without it you will keep guessing which tenant is to blame.
Then control. The client connection settings provide statement_timeout to abort any statement exceeding a given duration, and idle_in_transaction_session_timeout to terminate a session that left a transaction open and then sat idle holding locks. The documentation discourages putting statement_timeout in postgresql.conf at all, since a value there lands on every session the server has, which is why you attach it to a role or a connection type instead. Add to that a per-role connection cap through CONNECTION LIMIT, whose default is -1 meaning no limit, with two stated caveats: it is only enforced approximately, and it is never enforced for superusers. The same cap can be applied at database level in the database-per-tenant model.
MySQL is broader in this specific area: it caps individual accounts on hourly queries, hourly updates, hourly connections and simultaneous connections, written into a CREATE USER … WITH or ALTER USER … WITH clause, and left at zero by default, which means uncapped. Two details matter when planning around them: the tally is kept against the account rather than against each client, and every tally returns to zero whenever the server restarts.
One item the database does not cover is queues. A heavy job for a large tenant can hold up notifications for every tenant if you seat them all in one queue. Separate long queues from short ones, and cap the number of concurrent jobs per tenant.
Subscriptions and billing: data that is not the tenant's
Plans and limits look like an interface concern; they are really an enforcement concern. Any limit you advertise, whether it is the number of users, file storage or messages per month, has to be checked on the server before the operation runs, not in the interface after it has completed. Upgrades and downgrades need a written decision before coding: what happens to user number 11 when a customer drops from a plan allowing twenty to one allowing ten? Deactivating them and blocking the downgrade are both legitimate answers; taking neither leaves you with inconsistent data.
The detail most teams miss is that billing records, usage counters and the audit log are not the tenant's data but your platform's data about them. If you store them inside the tenant's database or schema, restoring that tenant to two days ago erases your invoices and your audit trail with it, and also hands the tenant authority over evidence you may need in a dispute. Keep them in the platform database and link them by tenant identifier alone.
If you sell subscriptions to customers in Saudi Arabia, you are yourself liable for e-invoicing on those subscriptions. ZATCA's roll-out phases page states that the first phase has been in force since 4 December 2021, and that the integration phase has been in force since 1 January 2023 and applies in waves, with taxpayers notified at least six months in advance (page accessed 22 September 2026). What changes inside your system at integration is covered in Phase two of e-invoicing, and the Egyptian equivalent in Connecting your system to Egyptian e-invoicing.
Per-tenant customisation, and where it ends
Logo, colours and a subdomain are cheap customisation: settings read at boot time that never touch the data structure. A full custom domain (app.client.com) is slightly heavier, because it adds a step the customer performs in their own DNS settings and a certificate issuance step on your side, and both belong to the onboarding path and need testing like any other feature.
Extra fields are harder. Adding a column for one customer in a shared table means an ALTER TABLE on a table everybody uses, with the lock consequences described above. The common alternative is a jsonb column holding that customer's free-form fields, and the JSON types documentation notes that the default GIN operator class for jsonb covers the key-existence, containment and jsonpath match operators, so searches over those fields remain indexed. The price is that the database no longer validates the types of those fields on your behalf.
The limit you should know in advance is business logic. The moment two customers ask for different calculation rules on the same screen, you have started maintaining two products with one team. That returns the question to its origin: are you building a product or a custom system for one company? The difference and its cost are covered in Off-the-shelf or custom software and The cost of a custom ERP.
Settling the decision in one session
Sit with your team and answer six written questions:
- Do your contracts today, or your plan for the next year, include a customer requiring in-country data or separate infrastructure?
- How many tenants do you expect in two years: dozens or thousands?
- How sensitive is the data: restaurant orders, patient records, or accounting entries?
- How often do you intend to change the database structure per month in the first year?
- Do you need reports spanning all tenants in order to run your own business?
- Who performs a single-tenant restore when it is requested, and with what tool?
The patterns that come out of these answers are usually clear. Thousands of small tenants with ordinary data and frequent structural changes: a shared table with row security enabled from day one. Dozens of tenants with sensitive data and an isolation clause in the contract: a database per tenant. In between: start shared, and write from day one both a layer that resolves the tenant's connection and a tool that moves one tenant to a dedicated database. The cheapest part of this decision is keeping the option to change it.
And if you have an existing system you are thinking of turning into a product for several companies, or a SaaS product you want to start from scratch, put the idea in front of the Snaabble team: you get a first outline of the scope, plus an estimate of duration and cost, inside a single day, at no charge and with nothing owed.
Sources
- Row security policies, CREATE POLICY, Schemas, pg_dump, ALTER TABLE, Explicit locking, Connections and authentication, Client connection defaults, CREATE ROLE, ALTER DATABASE, Customized options, System administration functions, pg_stat_statements and JSON types. PostgreSQL documentation, version 18.
- CREATE DATABASE, GRANT and Setting account resource limits. MySQL 8.4 Reference Manual.
- Eloquent: global scopes. Laravel documentation, version 13.
- E-invoicing roll-out phases. Zakat, Tax and Customs Authority, last updated 9 August 2026.
All links accessed on 22 September 2026.
Frequently asked questions
What is the difference between multi-tenancy and running a separate instance per customer?
In a multi-tenant product one copy of the code serves every customer, so a single deployment reaches all of them. With a separate instance per customer (single-tenant) you run a distinct application and database per contract, which buys you complete isolation in exchange for a deployment and maintenance cycle repeated for every customer you have. The difference is mostly operational: the first saves running effort and demands strict isolation inside the data, the second spares you that and charges you an operating cost that grows with every new customer.
How do I confirm that data isolation actually works before launch?
Write tests that deliberately reach for another tenant's data: a request carrying an identifier that belongs to a second tenant, a report run with no tenant identity in the session, an export that takes an identifier from the URL. A passing test is one where the request fails or returns zero rows, not one that returns the right data. If you use PostgreSQL row security, add a test that connects as the application account rather than the administrator account, because the table owner bypasses the policies unless FORCE ROW LEVEL SECURITY is on.
Do I need a subdomain per tenant from the start?
No. Early on, a path inside the application that identifies the tenant after login is enough. A subdomain becomes useful when the address turns into part of the customer's identity, or when you want sessions separated between them, and a full custom domain usually arrives as a request from larger customers. What matters is that tenant identity passes through one place in the code, so that changing how you recognise it later is a change in a single layer.
Do row security policies remove the need for permission checks in the application?
No, because the two answer different questions. Policies answer which rows belong to this tenant; application permissions answer what this user is allowed to do inside their tenant: who sees salaries, who approves a discount, who deletes. You need both, and the policies are the last net that catches mistakes in the first layer.



