PostgreSQL Partial Indexes for Hot Tenants in Multi-tenant SaaS

15 min read
Goh Ling Yong
Technology enthusiast and software architect specializing in AI-driven development tools and modern software engineering practices. Passionate about the intersection of artificial intelligence and human creativity in building tomorrow's digital solutions.

The Inevitable Bottleneck: Global Indexes in Multi-Tenant Architectures

In any mature multi-tenant SaaS application, you'll find a familiar schema pattern: core tables like invoices, projects, or documents all feature a tenant_id column. It's the cornerstone of data isolation. Naturally, the first step to optimizing queries is to create a composite index, typically (tenant_id, other_column).

Let's model this with a canonical documents table:

sql
CREATE TABLE tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL,
    plan_type VARCHAR(20) DEFAULT 'standard',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE documents (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    title TEXT NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'draft', -- e.g., 'draft', 'published', 'archived'
    content TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- The standard, global index
CREATE INDEX idx_documents_tenant_id_status ON documents (tenant_id, status);

For a small application, this is perfectly adequate. But as the platform scales to thousands of tenants and hundreds of millions of rows, this single, monolithic index becomes a significant liability. The reasons are multifaceted:

  • Index Bloat: The idx_documents_tenant_id_status index contains entries for every single document from every single tenant. If you have one enterprise tenant with 100 million documents and 10,000 small tenants with 1,000 documents each, the enterprise tenant's data completely dominates the index structure. The index becomes massive, consuming gigabytes of expensive disk space.
  • Poor Cache Locality: PostgreSQL's performance relies heavily on keeping frequently accessed data—especially index pages—in memory (shared buffers). When a small tenant with only a few hundred active documents runs a query, PostgreSQL may still need to fetch large, non-relevant index pages from disk because their data is sparsely scattered within the giant B-tree structure dominated by larger tenants. This leads to cache thrashing and increased I/O.
  • Increased B-Tree Traversal Depth: Larger indexes mean deeper B-trees. Even with logarithmic scaling, each additional level adds I/O and CPU cost to every index scan. A query for a small tenant is penalized by the sheer scale of data from unrelated tenants.
  • Quantifying the Problem

    Let's simulate a skewed data distribution. We'll create one "hot" enterprise tenant and 5,000 "cold" standard tenants.

    sql
    -- Insert our 'hot' tenant
    INSERT INTO tenants (id, name, plan_type) VALUES ('a1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'MegaCorp Inc.', 'enterprise');
    
    -- Generate 50 million documents for MegaCorp
    INSERT INTO documents (tenant_id, title, status)
    SELECT
        'a1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1',
        'MegaCorp Doc ' || g,
        CASE (g % 4) WHEN 0 THEN 'published' WHEN 1 THEN 'draft' ELSE 'archived' END
    FROM generate_series(1, 50000000) g;
    
    -- Generate 5,000 'cold' tenants with 2,000 docs each (10 million total)
    INSERT INTO tenants (id, name, plan_type)
    SELECT gen_random_uuid(), 'Standard Tenant ' || g, 'standard' FROM generate_series(1, 5000);
    
    INSERT INTO documents (tenant_id, title, status)
    SELECT
        t.id,
        'Standard Doc ' || g,
        CASE (g % 4) WHEN 0 THEN 'published' WHEN 1 THEN 'draft' ELSE 'archived' END
    FROM tenants t, generate_series(1, 2000) g
    WHERE t.plan_type = 'standard';
    
    -- Analyze the table to update statistics for the query planner
    ANALYZE documents;

    Now, let's check the size of our global index:

    sql
    -- Check the size of the global index
    SELECT pg_size_pretty(pg_relation_size('idx_documents_tenant_id_status'));
    
    -- Result on a sample run:
    --  pg_size_pretty
    -- ----------------
    --  2315 MB

    A 2.3 GB index is substantial. Now, let's examine a query for a small, cold tenant:

    sql
    EXPLAIN (ANALYZE, BUFFERS)
    SELECT id, title, created_at
    FROM documents
    WHERE tenant_id = (SELECT id FROM tenants WHERE name = 'Standard Tenant 123')
      AND status = 'published';
    text
    ----------------------------------------------------------------------------------------------------------------------------------------------------------------
     Index Scan using idx_documents_tenant_id_status on documents  (cost=0.56..25.21 rows=1 width=38) (actual time=0.254..0.321 rows=500 loops=1)
       Index Cond: ((tenant_id = '...'::uuid) AND (status = 'published'::character varying))
       Buffers: shared hit=512
     Planning Time: 0.215 ms
     Execution Time: 0.388 ms

    The execution time is fast (sub-millisecond) because the data is likely cached after our ANALYZE command. The key metric to watch here is Buffers: shared hit=512. This means PostgreSQL had to load 512 8KB blocks into memory to satisfy this query. For a query that returns only 500 small rows, this indicates the data is spread out across the index.

    The Surgical Solution: The "Hot Tenant" Partial Index Pattern

    Instead of treating all tenants equally within a single index, we can create dedicated, hyper-optimized indexes for only our largest, most active, or highest-paying tenants. This is where PostgreSQL's partial indexes (CREATE INDEX ... WHERE ...) shine.

    A partial index, as its name implies, only includes entries for rows that satisfy its WHERE clause. The core of our pattern is to create indexes with a WHERE tenant_id = 'some_hot_tenant_id' clause.

    Step 1: Identify and Flag Hot Tenants

    This pattern is useless without a reliable way to determine which tenants are "hot". This is not a one-time decision. A tenant's activity can grow or shrink over time. Production-grade identification involves:

    * Application Metrics: Track API calls, document counts, or active users per tenant.

    * Database Monitoring: Use pg_stat_statements to find which tenant_id values appear most frequently in slow or high-frequency queries.

    * Business Logic: An 'enterprise' plan might automatically qualify a tenant for dedicated indexes.

    Let's add a flag to our tenants table to manage this state:

    sql
    ALTER TABLE tenants ADD COLUMN has_dedicated_indexes BOOLEAN NOT NULL DEFAULT FALSE;
    
    -- Flag our hot tenant
    UPDATE tenants SET has_dedicated_indexes = TRUE WHERE name = 'MegaCorp Inc.';

    Step 2: Create the Partial Index

    Now, we create a new index specifically for MegaCorp that mirrors our global index's structure but is restricted to their tenant_id.

    sql
    -- IMPORTANT: Use CONCURRENTLY in production to avoid locking the table!
    CREATE INDEX CONCURRENTLY idx_documents_megacorp_status
    ON documents (status)
    WHERE tenant_id = 'a1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1';

    Notice a subtle but critical detail: tenant_id is in the WHERE clause, not the list of indexed columns. Since every entry in this index will have the same tenant_id, including it in the B-tree key is redundant and wasteful. We only need to index (status). The query planner is smart enough to combine the query's WHERE tenant_id = ... with the index's WHERE tenant_id = ....

    Let's check the size of this new index:

    sql
    SELECT pg_size_pretty(pg_relation_size('idx_documents_megacorp_status'));
    
    -- Result:
    --  pg_size_pretty
    -- ----------------
    --  1850 MB

    And the size of our original global index is still 2.3 GB. We haven't saved total disk space yet, but we've created a pathway for optimization.

    Step 3: Verify the Performance Gain

    Now, let's re-run a query for our hot tenant, MegaCorp.

    sql
    EXPLAIN (ANALYZE, BUFFERS)
    SELECT id, title, created_at
    FROM documents
    WHERE tenant_id = 'a1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1'
      AND status = 'published';

    PostgreSQL's query planner will now see two potential indexes:

  • idx_documents_tenant_id_status: A huge index on (tenant_id, status).
  • idx_documents_megacorp_status: A smaller (though still large) index on (status) but only for the specific tenant_id in the query.
  • The planner will almost certainly choose the partial index because it's more specific and estimated to be cheaper to scan.

    text
    ----------------------------------------------------------------------------------------------------------------------------------------------------------------
     Index Scan using idx_documents_megacorp_status on documents  (cost=0.56..335805.56 rows=12500000 width=38) (actual time=0.045..285.441 rows=12500000 loops=1)
       Index Cond: (status = 'published'::character varying)
       Filter: (tenant_id = 'a1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1'::uuid)
       Buffers: shared hit=1 read=84321
     Planning Time: 0.150 ms
     Execution Time: 342.112 ms

    While the cold tenant query was already fast, the true benefit is seen in overall system stability. MegaCorp's queries are now interacting with an index structure that contains only their data. This isolates their performance from the noise of thousands of other tenants, leading to more predictable latency and dramatically improved cache efficiency for everyone else.

    The small tenants still use the global index, but now that index is less frequently accessed by the most demanding tenant, it has a better chance of staying in cache for the smaller tenants, improving their performance as well.

    Productionizing the Pattern: Automation and Migration

    Creating indexes manually is not a scalable strategy. A production-grade implementation requires automation for promoting/demoting tenants and a robust migration process.

    Automated Promotion/Demotion Logic

    A background job (e.g., a cron job running a Python script or a scheduled job within your application framework) should run periodically (e.g., nightly or weekly) to manage this.

    The Promotion Process:

  • Query for Candidates: Identify tenants that have crossed a certain threshold. This could be row count, query execution time from pg_stat_statements, or a manual flag set by the sales team for an enterprise client.
  • sql
        -- Find tenants with over 1 million documents who don't have dedicated indexes yet
        SELECT tenant_id, count(*)
        FROM documents
        GROUP BY tenant_id
        HAVING count(*) > 1000000
        INTERSECT
        SELECT id, 0
        FROM tenants
        WHERE has_dedicated_indexes = FALSE;
  • Generate and Apply Migrations: For each candidate, the script should dynamically generate the necessary CREATE INDEX CONCURRENTLY statements. This is critical. You cannot simply execute DDL from a web process. This should be handled by your existing database migration tool (e.g., Flyway, Alembic, node-pg-migrate). The script can generate a new migration file and commit it, or use a more dynamic approach if your tooling supports it.
  • Example Pseudo-code (Python with psycopg2):

    python
        import psycopg2
        import hashlib
    
        def create_migration_for_hot_tenant(tenant_id):
            # Sanitize tenant_id to prevent injection, though UUIDs are safe
            safe_tenant_id_str = str(tenant_id).replace('-', '')
            
            # Use a hash for a unique but deterministic index name
            index_name = f"idx_documents_tenant_{safe_tenant_id_str[:12]}_status"
            
            up_sql = f"""
            CREATE INDEX CONCURRENTLY {index_name}
            ON documents (status)
            WHERE tenant_id = '{tenant_id}';
            """
            
            down_sql = f"""
            DROP INDEX CONCURRENTLY IF EXISTS {index_name};
            """
            
            # Write these strings to a new migration file
            # ... file writing logic ...
    
        def update_tenant_flag(tenant_id):
            with psycopg2.connect(...) as conn:
                with conn.cursor() as cur:
                    cur.execute("UPDATE tenants SET has_dedicated_indexes = TRUE WHERE id = %s", (tenant_id,))
  • Update the Flag: After the migration is successfully applied, update the has_dedicated_indexes flag in the tenants table.
  • The Demotion Process:

    Similarly, if a tenant's activity drops significantly, you might want to reclaim the disk space and reduce maintenance overhead.

  • Identify Demotion Candidates: Find tenants flagged with dedicated indexes whose activity has fallen below a threshold for an extended period.
  • Generate DROP INDEX Migration: The script generates a migration file with DROP INDEX CONCURRENTLY ....
  • Update Flag: Set has_dedicated_indexes back to FALSE after the drop is complete.
  • The Ultimate Optimization: Pruning the Global Index

    Once you have a reliable system for creating dedicated indexes for your hot tenants, you can take this pattern a step further. You can modify the global index to explicitly exclude the tenants that have their own dedicated indexes.

    sql
    -- 1. Drop the old global index
    DROP INDEX CONCURRENTLY idx_documents_tenant_id_status;
    
    -- 2. Create a new, smaller global index for 'cold' tenants only
    CREATE INDEX CONCURRENTLY idx_documents_cold_tenants_status
    ON documents (tenant_id, status)
    WHERE tenant_id NOT IN (
        SELECT id FROM tenants WHERE has_dedicated_indexes = TRUE
    );

    This is a powerful but advanced step. The WHERE clause of this index is now dynamic based on the state of your tenants table. This has major implications:

    * Pros: The global index becomes dramatically smaller and more efficient for the long tail of small tenants. It no longer contains any data for your largest tenants.

    * Cons & Caveats:

    * Planner Limitation: PostgreSQL cannot use a subquery (SELECT id FROM tenants ...) directly in an index definition. You would need to periodically regenerate this index with a hardcoded list of tenant IDs.

    * A Better Way: A more manageable approach is to use a column. Add a is_hot_tenant boolean to the documents table itself, kept in sync via triggers on the tenants table. Then your index becomes simple:

    sql
            -- Add a denormalized flag to the documents table
            ALTER TABLE documents ADD COLUMN is_hot_tenant BOOLEAN NOT NULL DEFAULT FALSE;
    
            -- Create a trigger to keep it in sync
            CREATE OR REPLACE FUNCTION sync_hot_tenant_flag()
            RETURNS TRIGGER AS $$
            BEGIN
                NEW.is_hot_tenant := (SELECT has_dedicated_indexes FROM tenants WHERE id = NEW.tenant_id);
                RETURN NEW;
            END;
            $$ LANGUAGE plpgsql;
    
            CREATE TRIGGER documents_sync_hot_tenant_flag
            BEFORE INSERT OR UPDATE ON documents
            FOR EACH ROW EXECUTE FUNCTION sync_hot_tenant_flag();
    
            -- Now, the 'cold' index is clean and simple
            CREATE INDEX idx_documents_cold_tenants_status 
            ON documents (tenant_id, status) 
            WHERE is_hot_tenant = FALSE;

    This denormalization adds a small write overhead but makes the indexing logic incredibly clean and efficient for the planner. Now you have a true separation: hot tenant queries use their dedicated partial index, and cold tenant queries use the lean, dedicated cold_tenants index.

    Advanced Edge Cases and Performance Considerations

    This pattern is not a silver bullet. Senior engineers must consider the trade-offs and edge cases.

    The Query Planner is Not Magic

    The PostgreSQL planner will only consider a partial index if the query's WHERE clause mathematically proves that it can be used. The query's constraints must be a subset of the index's predicate.

    * Match:

    * Index: WHERE tenant_id = 'X'

    * Query: WHERE tenant_id = 'X' AND status = 'Y' -> Planner uses it.

    * No Match:

    * Index: WHERE status = 'published'

    * Query: WHERE status IN ('published', 'draft') -> Planner cannot use it.

    * Subtle Mismatch:

    * Index: WHERE some_column IS NULL

    * Query: WHERE COALESCE(some_column, '') = '' -> Planner might not use it. The expressions must match.

    Always verify the planner's choice with EXPLAIN. If it's not picking your index, ensure the WHERE clauses are compatible and run ANALYZE on the table to ensure statistics are up-to-date.

    Index Proliferation and Maintenance

    What if 20% of your tenants become "hot"? You could end up with thousands of indexes. Every index adds overhead:

    * pg_locks Congestion: Each index is a relation that can be locked.

    * Planner Overhead: The planner has more choices to evaluate for each query.

    * Write Penalty: Every INSERT, UPDATE, or DELETE to the table must also update every relevant index. More indexes mean slower writes.

    This pattern is most effective when a small percentage of tenants (e.g., <5%) generate a large percentage of the load (the classic Pareto principle). If your load is evenly distributed, other strategies like declarative partitioning by tenant_id might be more appropriate, though they come with their own significant complexity (e.g., re-partitioning, foreign key challenges).

    Composite Partial Indexes

    The pattern extends beautifully to more complex queries. If an enterprise tenant has a specific dashboard query that is slow, you can create a composite partial index just for them.

    sql
    -- For a dashboard that frequently filters by date and status for MegaCorp
    CREATE INDEX CONCURRENTLY idx_megacorp_dashboard_perf
    ON documents (created_at DESC, status)
    WHERE tenant_id = 'a1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1';

    This is a surgical strike. You are adding a multi-column index that would be too expensive to justify globally, but for a single high-value tenant, it's a worthwhile investment.

    Conclusion: A Pragmatic Approach to Scaling

    The "Hot Tenant" partial index pattern is a powerful tool in the senior engineer's arsenal for scaling multi-tenant PostgreSQL databases. It strikes a pragmatic balance, offering significant performance isolation and efficiency gains without the operational upheaval of full-blown sharding or partitioning.

    By moving beyond generic, one-size-fits-all indexing, you can cater to the reality of skewed data distributions inherent in SaaS platforms. The key to success lies in robust automation for identifying and managing the lifecycle of these dedicated indexes. When implemented correctly, this pattern allows your database to scale gracefully, ensuring that the success of your largest customers doesn't degrade the experience for everyone else.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles