Multi-Tenant vs Single-Tenant Architecture

The essential playbook for implementing multi-tenant vs single-tenant architecture in your SaaS.

This page helps you choose between multi-tenant and single-tenant architecture for a small SaaS. The goal is not theory. It is to pick an isolation model you can ship now without blocking billing, auth, deployment, and future scaling.

For most MVPs, the practical default is shared app + shared database with a tenant_id or account_id column and strict tenant scoping in queries. Move to stronger isolation only when customer requirements, compliance, noisy-neighbor issues, or operational constraints justify it.

decision tree comparing shared tables, schema-per-tenant, and database-per-tenant.

Which multi-tenancy isolation model fits your requirements?
Shared tables
Simplest multi-tenancy: add tenant_id FK to every table, filter in every query
Schema-per-tenant
Stronger isolation: each tenant gets its own Postgres schema, same DB
And database-per-tenant
Diagnose: and database-per-tenant

Quick Fix / Quick Setup

Recommended MVP default:

sql
-- Shared app, shared database, tenant_id/account_id on every tenant-owned table

CREATE TABLE accounts (
  id BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE users (
  id BIGSERIAL PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  password_hash TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE memberships (
  user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
  role TEXT NOT NULL,
  PRIMARY KEY (user_id, account_id)
);

CREATE TABLE projects (
  id BIGSERIAL PRIMARY KEY,
  account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_projects_account_id ON projects(account_id);

-- Every request resolves current_account_id
-- Every tenant-owned query filters by account_id
SELECT * FROM projects WHERE account_id = $1 ORDER BY id DESC;

Optional Postgres defense-in-depth with row-level security:

sql
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_projects ON projects
USING (account_id = current_setting('app.current_account_id')::BIGINT);

Practical rule:

  • MVP: shared app + shared DB + shared tables
  • Add account_id everywhere tenant-owned data exists
  • Resolve current account on every request
  • Scope every query, cache key, file path, and worker job by tenant
  • Move selected tenants to dedicated infrastructure only when required

Related setup:

What’s happening

  • Multi-tenant means multiple customers share some part of the system: app servers, database, tables, queues, storage, or all of them.
  • Single-tenant means each customer gets isolated infrastructure, usually separate databases, app environments, or both.
  • The real decision is not binary. Common patterns are shared tables with tenant_id, schema-per-tenant, and database-per-tenant.
  • Your choice affects auth design, billing model, migrations, support operations, backup scope, query performance, and incident blast radius.
  • Most small SaaS products start multi-tenant because it is faster and cheaper to operate.

Quick setup recommendation by stage

  • MVP: shared app + shared database + shared tables with account_id or tenant_id columns.
  • Early production: keep shared tables, add stricter query scoping, indexes, audit logs, and optional Postgres RLS.
  • B2B with stricter customer requirements: move some large tenants to dedicated databases while keeping most on shared infrastructure.
  • Enterprise/compliance-heavy: use database-per-tenant or isolated deployments only when required by contracts or regulation.
  • Do not start with full single-tenant infrastructure unless you already know it is a sales requirement.

Decision criteria

Choose multi-tenant if you want:

  • lowest ops overhead
  • fast feature rollout
  • simpler deployment
  • lower hosting cost

Choose single-tenant if you need:

  • stronger isolation
  • customer-specific data residency
  • custom infrastructure
  • tenant-level maintenance windows

Practical constraints:

  • If your team is solo or very small, prefer fewer moving parts over perfect isolation.
  • If your app has heavy per-customer workloads, estimate noisy-neighbor risk before committing to shared resources.
  • If analytics, search, or background jobs are tenant-heavy, plan quota and rate limiting early.

Step-by-step implementation

1. Define the tenant model

Use one term consistently:

  • account
  • workspace
  • organization
  • team

For most small SaaS products, account is a good default.

2. Create global users and memberships

Do not make users tenant-owned unless each user belongs to exactly one tenant.

sql
CREATE TABLE accounts (
  id BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE users (
  id BIGSERIAL PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  password_hash TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE memberships (
  user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
  role TEXT NOT NULL CHECK (role IN ('owner','admin','member')),
  PRIMARY KEY (user_id, account_id)
);

3. Add account_id to every tenant-owned table

Examples:

  • projects
  • invoices
  • API keys
  • uploads
  • audit logs
  • background jobs
  • app settings
sql
CREATE TABLE projects (
  id BIGSERIAL PRIMARY KEY,
  account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
  slug TEXT NOT NULL,
  name TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'active',
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (account_id, slug)
);

CREATE INDEX idx_projects_account_created_at ON projects(account_id, created_at DESC);
CREATE INDEX idx_projects_account_status ON projects(account_id, status);

4. Resolve current account on every request

Common approaches:

  • subdomain: acme.example.com
  • URL path: /accounts/acme/projects
  • selected workspace stored in session
  • account chosen after login

Example request middleware pseudocode:

ts
async function resolveTenantContext(req) {
  const userId = req.session.userId
  const accountId = req.session.currentAccountId

  if (!userId || !accountId) throw new Error("Missing auth or account context")

  const membership = await db.query(
    `SELECT role
     FROM memberships
     WHERE user_id = $1 AND account_id = $2`,
    [userId, accountId]
  )

  if (membership.rowCount === 0) throw new Error("No access to account")

  req.auth = {
    userId,
    accountId,
    role: membership.rows[0].role
  }
}

5. Centralize scoped queries

Avoid raw queries scattered across handlers.

Bad:

sql
SELECT * FROM projects WHERE id = $1;

Good:

sql
SELECT * FROM projects WHERE id = $1 AND account_id = $2;

Repository example:

ts
export async function listProjects(accountId: number) {
  return db.query(
    `SELECT id, slug, name, status, created_at
     FROM projects
     WHERE account_id = $1
     ORDER BY id DESC`,
    [accountId]
  )
}

export async function getProjectById(accountId: number, projectId: number) {
  return db.query(
    `SELECT id, slug, name, status, created_at
     FROM projects
     WHERE id = $1 AND account_id = $2`,
    [projectId, accountId]
  )
}

6. Add RLS only as defense in depth

RLS helps reduce blast radius if app logic misses a filter. It does not replace app-layer checks.

sql
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_select_projects
ON projects
FOR SELECT
USING (account_id = current_setting('app.current_account_id')::BIGINT);

CREATE POLICY tenant_modify_projects
ON projects
FOR ALL
USING (account_id = current_setting('app.current_account_id')::BIGINT)
WITH CHECK (account_id = current_setting('app.current_account_id')::BIGINT);

Per-request connection setup example:

sql
SET app.current_account_id = '123';

7. Scope caches, files, and jobs

Cache keys:

txt
account:123:projects:list
account:123:project:55

Storage keys:

txt
accounts/123/uploads/logo.png
accounts/123/exports/export-2026-04-20.csv

Worker payload:

json
{
  "account_id": 123,
  "job_type": "project_export",
  "project_id": 55
}

8. Design for promotion to dedicated infrastructure later

Do not hardcode storage layout assumptions into business logic.

Use service boundaries:

  • ProjectRepository
  • FileStorageService
  • BillingAccountService
  • TenantProvisioningService

Hybrid target model:

  • default tenants on shared tables
  • heavy or regulated tenants on dedicated DBs
  • control plane tracks which tenant lives where

Example control-plane table:

sql
CREATE TABLE tenant_infra (
  account_id BIGINT PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
  isolation_mode TEXT NOT NULL CHECK (isolation_mode IN ('shared', 'dedicated_db', 'dedicated_stack')),
  database_url TEXT,
  storage_prefix TEXT,
  deployment_version TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

9. Add tenant-isolation tests

Minimum test cases:

  • user from account A cannot read account B resources
  • user from account A cannot update account B resources
  • account switch updates visible resources
  • cache keys differ per account
  • jobs fail if tenant context is missing

Example test pseudocode:

py
def test_user_cannot_access_other_account_project(client, account_a_user, project_b):
    client.login(account_a_user)
    client.select_account(account_a_user.account_id)

    response = client.get(f"/projects/{project_b.id}")
    assert response.status_code in (403, 404)

Common causes

  • Using user_id instead of account_id as the main ownership boundary, which breaks team-based SaaS models.
  • Forgetting tenant filters on one or more read/write queries, leading to cross-tenant data leaks.
  • Making globally unique constraints where uniqueness only needs to exist within a tenant.
  • Not indexing account_id, causing slow queries as data volume grows.
  • Background workers processing jobs without tenant context.
  • Caching responses or objects without tenant-specific keys.
  • Attaching subscriptions to individual users instead of the tenant account.
  • Using schema-per-tenant or database-per-tenant too early without automation for migrations and provisioning.
  • Mixing tenant-specific files in storage paths without account-level prefixes.
  • Failing to test account switching and multi-membership behavior.

Debugging tips

Check which tables are tenant-scoped:

bash
psql "$DATABASE_URL" -c "SELECT table_name, column_name FROM information_schema.columns WHERE column_name IN ('account_id','tenant_id') ORDER BY table_name;"

Check indexes containing account_id:

bash
psql "$DATABASE_URL" -c "SELECT indexname, indexdef FROM pg_indexes WHERE schemaname='public' AND indexdef ILIKE '%account_id%';"

Inspect tenant query plan:

bash
psql "$DATABASE_URL" -c "EXPLAIN ANALYZE SELECT * FROM projects WHERE account_id = 123 ORDER BY id DESC LIMIT 20;"

Inspect memberships for a user:

bash
psql "$DATABASE_URL" -c "SELECT * FROM memberships WHERE user_id = 1;"

Search app code for unscoped project queries:

bash
grep -R "SELECT .*FROM .*projects" -n .

Search code for tenant references:

bash
grep -R "account_id" -n app/ src/

Run tenant test subset:

bash
pytest -k tenant

Inspect Redis cache keys:

bash
redis-cli KEYS '*account*'

Inspect active Celery jobs:

bash
celery -A app inspect active

Inspect RQ queues:

bash
rq info

Additional checks:

  • verify request context always has accountId
  • verify background jobs include tenant fields
  • verify logs include account_id
  • verify billing records map to account_id

Checklist

  • Every tenant-owned table has account_id.
  • Every list and detail query is scoped by account_id.
  • Membership checks happen before write operations.
  • Unique constraints are designed per tenant where appropriate.
  • Indexes include account_id for high-cardinality tenant queries.
  • Logs, jobs, caches, and storage keys include tenant context.
  • Tests cover cross-tenant access denial.
  • A documented path exists for moving a tenant to dedicated infrastructure.
  • Billing can map subscription state to an account, not just a user.

Cross-check with:

Related guides

FAQ

Which model should an MVP use?

Use multi-tenant with shared tables and an account_id column on tenant-owned data. It keeps cost and operational complexity low while still supporting teams and billing.

Is single-tenant more secure?

It provides stronger isolation by default, but it is not automatically secure. You still need proper auth, secrets management, patching, backups, and monitoring.

Can I support both models?

Yes. Many SaaS products use a hybrid model: most tenants on shared infrastructure, selected tenants on dedicated databases or environments.

What is the biggest risk in multi-tenant design?

Cross-tenant data exposure caused by missing query filters, incorrect joins, unscoped caches, or workers running without tenant context.

How should billing map to tenants?

Attach subscription records to the account or organization, not just the user. Access control and feature gating should resolve from the tenant subscription state. See Stripe Subscription Setup (Step-by-Step).

Is schema-per-tenant a good middle ground?

Sometimes, but migrations, connection management, and tooling become more complex than shared tables. For most small SaaS apps, shared tables are simpler and safer to operate.

Should I put tenant_id on the users table?

Only if users belong to exactly one tenant. Most SaaS apps should keep users global and connect them to accounts through a memberships table.

Can row-level security replace application checks?

No. Use RLS as defense in depth, not as the primary design.

When should I move a tenant to a dedicated database?

When compliance, performance isolation, data residency, or customer-specific operational requirements justify the added ops cost.

Final takeaway

For indie builders and small SaaS teams, shared app + shared database + account_id scoping is the safest default to ship quickly.

Design your schema, auth, billing, and logs around account boundaries from day one. Add stronger isolation only when you can prove the business or technical need.