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.
Quick Fix / Quick Setup
Recommended MVP default:
-- 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:
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_ideverywhere 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:
- SaaS Architecture Overview (From MVP to Production)
- Database Design for SaaS Applications
- Implement User Authentication (Login/Register)
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_idortenant_idcolumns. - 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:
accountworkspaceorganizationteam
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.
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
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:
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:
SELECT * FROM projects WHERE id = $1;Good:
SELECT * FROM projects WHERE id = $1 AND account_id = $2;Repository example:
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.
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:
SET app.current_account_id = '123';7. Scope caches, files, and jobs
Cache keys:
account:123:projects:list
account:123:project:55Storage keys:
accounts/123/uploads/logo.png
accounts/123/exports/export-2026-04-20.csvWorker payload:
{
"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:
ProjectRepositoryFileStorageServiceBillingAccountServiceTenantProvisioningService
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:
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:
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_idinstead ofaccount_idas 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:
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:
psql "$DATABASE_URL" -c "SELECT indexname, indexdef FROM pg_indexes WHERE schemaname='public' AND indexdef ILIKE '%account_id%';"Inspect tenant query plan:
psql "$DATABASE_URL" -c "EXPLAIN ANALYZE SELECT * FROM projects WHERE account_id = 123 ORDER BY id DESC LIMIT 20;"Inspect memberships for a user:
psql "$DATABASE_URL" -c "SELECT * FROM memberships WHERE user_id = 1;"Search app code for unscoped project queries:
grep -R "SELECT .*FROM .*projects" -n .Search code for tenant references:
grep -R "account_id" -n app/ src/Run tenant test subset:
pytest -k tenantInspect Redis cache keys:
redis-cli KEYS '*account*'Inspect active Celery jobs:
celery -A app inspect activeInspect RQ queues:
rq infoAdditional 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_idfor 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:
- Database Design for SaaS Applications
- Implement User Authentication (Login/Register)
- Stripe Subscription Setup (Step-by-Step)
- SaaS Production Checklist
Related guides
- SaaS Architecture Overview (From MVP to Production)
- Implement User Authentication (Login/Register)
- Stripe Subscription Setup (Step-by-Step)
- Database Design for SaaS Applications
- SaaS Production Checklist
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.