The Tenant Isolation Model

Designing a Software-as-a-Service (SaaS) platform almost always leads to the same architectural question:

How should customer data be isolated?

There are several common approaches: separate databases per customer, separate schemas within the same database, or shared tables with tenant-aware row filtering. In Upblit, we chose the third option—shared infrastructure with strict tenant_id scoping across every service and query.

This article explains why we made that decision, what implementation patterns we follow, and the tradeoffs involved.


The Three Common Approaches

1. Database per Tenant

customer_a_db
customer_b_db
customer_c_db

Advantages

  • Strong physical isolation
  • Easy customer-specific backups
  • Simpler compliance boundaries

Drawbacks

  • Operational complexity grows linearly
  • Expensive at scale
  • Harder migrations
  • Connection pool explosion

Managing hundreds or thousands of databases quickly becomes an infrastructure problem instead of an application problem.


2. Schema per Tenant

public.users
tenant_a.users
tenant_b.users
tenant_c.users

Advantages

  • Better isolation than shared tables
  • Shared PostgreSQL instance
  • Easier logical separation

Drawbacks

  • Thousands of schemas become difficult to maintain
  • Migrations must run repeatedly
  • ORMs often struggle with dynamic schemas
  • Cross-tenant analytics become complicated

3. Shared Tables with tenant_id

users
-------------------------------------
id | tenant_id | email
-------------------------------------
1  | acme      | alice@example.com
2  | beta      | bob@example.com
3  | acme      | carol@example.com

Every business table contains a tenant_id column, and every query filters using that identifier.

This is the model implemented in Upblit.


Why We Chose Row-Level Scoping

The biggest advantage is operational simplicity.

Instead of maintaining separate infrastructure for every organization, all tenants share the same tables while remaining logically isolated through application-enforced filtering.

Benefits include:

  • Single migration path
  • Simpler backups
  • Efficient resource utilization
  • Easier monitoring
  • Better cache locality
  • Straightforward analytics across tenants when authorized

Most importantly, it scales operationally without creating thousands of databases or schemas.


Making tenant_id Mandatory

Every domain object includes a tenant identifier.

CREATE TABLE projects (
    id UUID PRIMARY KEY,
    tenant_id TEXT NOT NULL,
    name TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL
);

Likewise:

CREATE TABLE deployments (
    id UUID PRIMARY KEY,
    tenant_id TEXT NOT NULL,
    environment TEXT,
    status TEXT
);

This ensures that ownership information exists for every record from creation through deletion.


Request Context Carries the Tenant

Authentication resolves which tenant the current user belongs to.

A middleware extracts that information and stores it in the request context.

type TenantContext struct {
    ID   string
    Role string
}

Business services never accept arbitrary tenant identifiers from clients. Instead, they obtain the active tenant from authenticated context.

This avoids privilege escalation through manipulated request parameters.


Query Patterns

The simplest rule is also the most important:

Every query includes a tenant_id filter.

Fetching projects:

SELECT *
FROM projects
WHERE tenant_id = $1
ORDER BY created_at DESC;

Fetching a single deployment:

SELECT *
FROM deployments
WHERE tenant_id = $1
  AND id = $2;

Updating data:

UPDATE projects
SET name = $3
WHERE tenant_id = $1
  AND id = $2;

Deleting data:

DELETE FROM deployments
WHERE tenant_id = $1
  AND id = $2;

Notice that object IDs alone are never trusted. Every operation verifies ownership through the tenant scope.


Inserts Always Include Ownership

When creating records, the tenant is written immediately.

INSERT INTO projects (
    id,
    tenant_id,
    name
)
VALUES (
    $1,
    $2,
    $3
);

The application derives $2 from authenticated context rather than user input.


Composite Indexes Matter

Without indexing, filtering every query by tenant_id can become expensive.

Instead of indexing only primary keys, composite indexes improve performance dramatically.

CREATE INDEX idx_projects_tenant_created
ON projects (
    tenant_id,
    created_at DESC
);

Another example:

CREATE INDEX idx_deployments_tenant_status
ON deployments (
    tenant_id,
    status
);

These indexes allow PostgreSQL to narrow searches to a tenant before applying additional predicates.


Service Layer Enforcement

A repository function might look like this:

func GetProjects(ctx context.Context, tenantID string) ([]Project, error) {
    rows, err := db.Query(
        ctx,
        `
        SELECT id, name
        FROM projects
        WHERE tenant_id = $1
        `,
        tenantID,
    )

    // handle rows...
}

Notice that the repository requires a tenant identifier explicitly instead of allowing unrestricted queries.

This pattern makes accidental cross-tenant reads much less likely.


Cross-Tenant Administration

Some administrative users legitimately need visibility across multiple tenants.

Rather than removing tenant filters entirely, privileged endpoints explicitly branch their logic.

if isAdmin {
    query = `
        SELECT *
        FROM projects
    `
} else {
    query = `
        SELECT *
        FROM projects
        WHERE tenant_id = $1
    `
}

Keeping unrestricted access isolated to dedicated administrative paths reduces accidental exposure.


Tradeoffs of the Shared Table Model

No architecture is perfect.

Advantages

  • Lower infrastructure costs
  • Easier migrations
  • Centralized backups
  • Simpler monitoring
  • Better resource utilization
  • Straightforward deployment pipelines

Disadvantages

  • Every query must enforce tenant filtering
  • Bugs can expose cross-tenant data if safeguards fail
  • Large tables may require careful indexing and partitioning
  • Compliance requirements may demand stronger physical isolation

The engineering discipline required is significant, but automation and code review can reduce risk.


Preventing Mistakes

The biggest danger is forgetting the tenant predicate.

For example:

SELECT *
FROM projects;

This query would expose every tenant's data.

A safer version is:

SELECT *
FROM projects
WHERE tenant_id = $1;

Many teams implement linting rules, repository abstractions, or integration tests that verify tenant scoping on every data access path.


Scaling Over Time

As datasets grow, additional techniques can complement tenant_id scoping:

  • Table partitioning
  • Read replicas
  • Materialized views
  • Query caching
  • Connection pooling
  • Background archival of inactive data

These optimizations preserve the same logical isolation model while improving performance.


Final Thoughts

Choosing row-level tenant isolation with tenant_id scoping is ultimately a balance between operational simplicity and application discipline.

For Upblit, this approach provides a scalable foundation that keeps infrastructure manageable while maintaining strong logical separation between customers. The key is consistency: every service, repository, and SQL statement must treat tenant_id as a first-class constraint rather than an optional filter.

When enforced rigorously, shared-table multi-tenancy delivers excellent scalability, efficient resource usage, and a clean developer experience without the operational burden of managing separate databases or schemas for every customer.