Business Platforms

Multi-Tenancy in SaaS: Shared Database, Schemas or Separate?

The three tenancy models compared on isolation, migrations, restores, noisy neighbors and cost, with the code-level safeguards that keep one customer from ever seeing the data of another.

Multi-Tenancy in SaaS: Shared Database, Schemas or Separate?

Most new products should start with one database and a tenant ID on every row. That is the short answer to the SaaS multi-tenancy question, and it holds until a customer contract, a regulator or one very large account says otherwise.

The longer answer matters because the model you choose decides how you run migrations, how you restore one customer's data, and how you stop one heavy customer from slowing down the rest. Below are the three models, what each costs to operate, and how to start simple without closing off the others.

The three models side by side

Shared tables. All customers live in the same tables, and every table carries a tenant_id column. Schema per tenant. One database server, with a separate set of tables for each customer. In PostgreSQL a schema is a namespace inside a database. In MySQL the word is a synonym for database, so there this model and the next are the same thing. Database per tenant. Each customer has their own database, possibly on their own server.

Shared tablesSchema per tenantDatabase per tenant
Isolation comes fromApplication code, plus row-level security where the database has itThe database namespaceSeparate databases and credentials
A migration isOne runOne run per tenantOne run per tenant, across servers
Restoring one customerHard: rows must be picked out of a full backupModerateSimple: restore that database
A heavy customerShares everything with the othersShares the serverCan be moved to its own hardware
Thousands of small tenantsCheapest to runThe sheer number of tables becomes a burdenMost expensive
Reporting across tenantsOne queryAwkwardNeeds a separate reporting store

How isolation is enforced in shared tables

The whole risk of the shared model is one query that forgot its WHERE tenant_id = ?. You do not manage that risk by asking developers to remember. You stack safeguards so that forgetting is harmless.

Resolve the tenant once

Work out the current tenant in one place, at the start of the request, from the subdomain or from the signed-in user's membership. Never take it from a form field or a query parameter the user can edit.

Let the ORM add the filter

In Laravel a global scope applies the condition to every query on a model, and a model event fills the column on insert. With the current tenant bound in the container by middleware, a trait does both:

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope('tenant', function (Builder $query) {
            $query->where($query->qualifyColumn('tenant_id'), app('tenant')->id);
        });

        static::creating(function (Model $model) {
            $model->tenant_id = app('tenant')->id;
        });
    }
}

Raw queries and bulk updates written with the query builder bypass model scopes, which is why this layer is not enough alone.

Add a backstop in the database

PostgreSQL can refuse rows itself through row security policies. The application sets the tenant for the current transaction and the policy does the filtering, whatever the query says:

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')::bigint);

-- at the start of each transaction
SELECT set_config('app.tenant_id', '42', true);

If the setting is missing the query fails, which is the behavior you want. Superusers bypass row security, and so do table owners unless FORCE is set, so the application should connect as an ordinary role. MySQL has no equivalent feature. There the application layer and the tests carry the full weight.

Cover everything that is not a table

  • Unique indexes include the tenant: UNIQUE (tenant_id, email), not UNIQUE (email).
  • Indexes on large tables lead with tenant_id.
  • Cache keys, uploaded file paths and search indexes carry a tenant prefix.
  • Queued jobs store the tenant ID in their payload and restore the context in the worker before doing anything.
  • Scheduled commands and exports loop over tenants explicitly.

Finally, write one automated test that signs in as tenant A and requests tenant B's records through every endpoint. It is the cheapest insurance in the codebase.

Migrations and schema drift

With shared tables a schema change is one migration. The difficulty moves to table size: on a large table, add the column as nullable, backfill in batches, then add the constraint, as described in database migration with minimal downtime.

With a schema or database per tenant, every migration is a loop, and loops fail in the middle. You need a record of which version each tenant is on, a way to resume, and application code that tolerates the old and the new shape while the rollout is in progress. Provisioning a new customer means running the full migration history for them. Packages such as Tenancy for Laravel automate the switching of connections, cache and storage per tenant, and they support both single-database and multi-database setups. They do not remove the operational cost of several hundred small migrations in a row.

Noisy neighbors

In a shared setup, one customer's bulk import or year-end report competes with everyone else's page loads. The controls are unglamorous:

  • rate limits per tenant on the API and on expensive actions,
  • heavy work pushed to queues, with a cap on how many workers one tenant may occupy at a time,
  • a statement timeout on the database connection used by web requests,
  • hard limits on page size and export size,
  • the tenant ID attached to every log line, slow query entry and job, so that load can be attributed.

When one account outgrows these, the remedy is to move that tenant to its own database. That is only cheap if the application already looks up "which connection does this tenant use" in one place, even while every tenant gets the same answer.

Customization without forks

Requests to "make it work differently for us" start with the second customer. Say yes through data and no through code:

  • settings and feature flags stored per tenant,
  • custom fields held in a JSON column or in a definitions table with a values table,
  • branding, email templates and labels per tenant,
  • webhooks and an API for anything that needs customer-specific logic outside your product.

Refuse per-tenant code branches and per-tenant schema changes. The separate-database models make them technically possible, and every one of them turns each later migration into a special case.

Choosing a SaaS multi-tenancy model and keeping the door open

Start with shared tables unless one of these is true on day one: a contract requires that a customer's data sit in its own database or region, you will have a small number of very large customers, or restoring a single customer to a point in time is something you sell. In those cases go to a database per tenant and accept the migration tooling as part of the product.

Schema per tenant is the model we recommend least for new builds. It brings most of the per-tenant migration work without the clean separation of servers and credentials. It can suit a moderate number of tenants on PostgreSQL where the team already knows it well.

Whatever you pick, four habits keep a later change affordable: resolve the tenant in one place, keep a tenant ID on every row and in every log, avoid product features that join across tenants, and build a script that exports and imports one tenant. You will want that script anyway for offboarding and data requests.

Tenancy is one of the few early decisions that is expensive to reverse, so it is worth an hour with someone who has run each model in production. It is a standard part of the design phase on the SaaS products we build, most of them as Laravel SaaS applications. The other early choices are covered in choosing a tech stack for a SaaS MVP.

Frequently asked questions

Is a shared database safe enough for business customers?

It is a very common model, and it is as safe as its enforcement. With a single point of tenant resolution, ORM scopes, a database-level backstop where available and cross-tenant tests, it holds up well. Some enterprise contracts ask for physical separation regardless, so read the security requirements of your target customers before you decide.

Can we change the tenancy model later?

Moving one tenant from shared tables to its own database is manageable if every row has a tenant ID. Going the other way, merging separate databases into shared tables, is harder because primary keys collide. Using UUIDs or another globally unique key from the start removes that problem.

Does each tenant need its own subdomain?

No. A subdomain is one way to identify the tenant, alongside a path segment or simply the account the user signed in to. Subdomains help with single sign-on setup and cookie separation, and custom domains can be added on top later.

How do backups work when all tenants share tables?

You back up the whole database. Restoring a single customer means restoring a copy to a scratch server and copying that tenant's rows back, table by table, in the right order. Write and rehearse that procedure before the first customer asks for it.

Add WorldWin Coder to your preferred sources

Add worldwincoder.com as a preferred source on Google, or open this article in your AI assistant to use it as a source.

Swati Priyadrshni
· Founder & CEO at WorldWin Coder

I'm Swati Priyadarshani, Founder & CEO of LMS Advisor and Co-Founder of WorldWin Coder Pvt. Ltd. (est. 2019). Over the last 10+ years, I've helped enterprises build learning platforms that actually get used - not just deployed.

Keep reading

Have something to build, fix or move?

Tell us where you are. A developer reads every inquiry, and you get next steps and a free quote within one business day.