loke.dev
Header image for 5 Performance Killers in Multi-Tenant Postgres (And How to Audit Them)

5 Performance Killers in Multi-Tenant Postgres (And How to Audit Them)

Uncover the hidden costs of schema-based isolation and row-level security by auditing catalog bloat and connection pool saturation.

· 8 min read

The clock hit 2:15 AM when the monitoring dashboard for our multi-tenant SaaS platform turned into a sea of red. We had just onboarded our 5,000th tenant, a milestone we celebrated with champagne at 5:00 PM, only to realize by midnight that Postgres was effectively gasping for air. The CPU wasn't spiking because of heavy queries; it was spiking because the database was struggling to even decide *how* to run the queries.

Multi-tenancy in Postgres is a seductive architectural pattern. Whether you choose the "Schema-per-Tenant" approach for isolation or the "Shared Table with Row-Level Security (RLS)" approach for simplicity, you are making a trade-off. Most developers assume the trade-off is just about "logical organization." In reality, the trade-off is often paid in hardware cycles, memory latency, and maintenance overhead.

Here are the five biggest performance killers in multi-tenant Postgres environments and how you can audit your system before it hits the wall.

1. The "Schema-per-Tenant" Catalog Bloat

In the early days of a project, creating a new schema for every customer feels like the ultimate win for data isolation. It’s clean, search_path handles the routing, and migrations feel safe. However, Postgres wasn't designed to handle 10,000 schemas with 50 tables each.

Postgres stores metadata about every table, column, and index in its internal system catalogs—primarily pg_class, pg_attribute, and pg_index. When you have 500,000 tables (10,000 tenants × 50 tables), these system catalogs grow massive. Every time you run a query, Postgres has to look up the table in these catalogs. Even with caching, the sheer volume leads to "catalog bloat."

The Performance Hit

Large catalogs cause cache misses in the relcache (Relation Cache). Maintenance tasks like VACUUM and ANALYZE also become excruciatingly slow because they have to iterate over an enormous number of objects. I've seen pg_dump operations that take 12 hours just to extract the schema definition because the metadata lookups were so inefficient.

How to Audit It

You need to check the size and row count of your system catalogs. If pg_attribute starts hitting several million rows, you’re in the danger zone.

SELECT 
    relname AS catalog_table, 
    n_live_tup AS row_count,
    pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_stat_user_tables 
WHERE schemaname = 'pg_catalog'
ORDER BY n_live_tup DESC;

If you see pg_attribute or pg_class taking up hundreds of megabytes or even gigabytes, your "clean" schema isolation is likely the reason your query planning time is creeping up.

2. RLS Policy Planning Overhead

If you went the other route—putting everyone in one giant table and using Row-Level Security (RLS)—you aren't safe either. RLS is essentially a transparent WHERE clause that Postgres injects into every query.

The killer here isn't the execution; it's the planner. If your RLS policy is complex—perhaps it joins to a memberships table or calls a custom function—Postgres has to evaluate that policy for every single query.

The Problem with Functions in RLS

A common mistake is using a non-LEAKPROOF function in an RLS policy. If Postgres can't guarantee the function won't leak data through error messages, it might choose a much less efficient execution plan to ensure security. Furthermore, if your RLS policy looks like this:

CREATE POLICY tenant_isolation ON orders
    USING (tenant_id = current_setting('app.current_tenant')::uuid);

Postgres has to parse that current_setting call every single time. While fast, it adds up across thousands of concurrent queries.

How to Audit It

Use EXPLAIN (ANALYZE, SETTINGS) on a query that triggers an RLS policy. Look specifically for the "Planning Time."

-- Switch to a tenant role to trigger RLS
SET ROLE tenant_user;
SET app.current_tenant = 'd290f1ee-6c54-4b01-90e6-d701748f0851';

EXPLAIN ANALYZE 
SELECT * FROM orders WHERE status = 'shipped';

If your planning time is consistently higher than your execution time (e.g., 5ms planning vs 0.5ms execution), your RLS policies are too "heavy." Consider simplifying the policy or using a simpler session variable check that doesn't involve subqueries.

3. Connection Pool Saturation and the search_path Trap

In a schema-per-tenant model, the application usually switches contexts by running SET search_path TO tenant_abc. If you are using a connection pooler like PgBouncer (and you should be), this creates a massive headache.

Why it kills performance

Most people run PgBouncer in Transaction Mode to maximize throughput. But SET search_path modifies the session state. In Transaction Mode, PgBouncer doesn't guarantee that the next transaction in your code will get the same database connection. If Transaction A sets the path to tenant_1 and Transaction B (for tenant_2) gets that same connection, Transaction B might accidentally query tenant_1's data if the path isn't reset.

To fix this, developers often use DISCARD ALL or reset the search_path every time. This adds an extra round-trip to the database for *every single transaction*.

The Audit

Check your pg_stat_statements for the frequency and impact of SET commands.

SELECT 
    query, 
    calls, 
    total_exec_time / calls AS avg_time_ms, 
    total_exec_time 
FROM pg_stat_statements 
WHERE query LIKE 'SET search_path%'
ORDER BY total_exec_time DESC;

If SET search_path is in your top 5 most called queries, you are wasting a significant portion of your database's capacity on administrative overhead.

Pro Tip: Instead of SET search_path, try to qualify your table names in the query (e.g., SELECT * FROM tenant_123.orders). It’s harder to implement in some ORMs, but it eliminates the session state problem and allows for better connection reuse in PgBouncer.

4. Autovacuum Starvation

Postgres relies on the autovacuum daemon to clean up "dead tuples" (leftovers from UPDATES and DELETES). In a multi-tenant setup with thousands of schemas, the default autovacuum settings are almost always insufficient.

By default, Postgres only runs 3 autovacuum workers (autovacuum_max_workers = 3). If you have 10,000 tenants and all of them are doing small updates, the autovacuum workers will spend all their time jumping between schemas. While they are busy cleaning up tenant_1, tenant_500 is bloating.

The "Noisy Neighbor" Vacuum

If one large tenant performs a bulk import or a massive update, a single autovacuum worker might get "stuck" on that large table for an hour. With only two workers left for the other 9,999 tenants, the rest of your database starts to bloat. Bloat leads to slower sequential scans and increased IOPS.

How to Audit It

Check which tables have the most dead tuples and when they were last vacuumed.

SELECT 
    schemaname, 
    relname, 
    n_dead_tup, 
    last_autovacuum, 
    last_autoanalyze
FROM pg_stat_all_tables
WHERE n_dead_tup > 0
ORDER BY n_dead_tup DESC
LIMIT 20;

If last_autovacuum is days ago for active tables, your workers are starved. You likely need to increase autovacuum_max_workers and potentially decrease autovacuum_vacuum_scale_factor to make the workers trigger more frequently but finish faster.

5. Shared Buffer Contention (The "Working Set" Problem)

Postgres caches data in shared_buffers. In a typical single-tenant database, the "working set" (the indexes and rows used most frequently) usually fits into RAM.

In multi-tenant systems, the working set is unpredictable. If Tenant A runs a massive report, they can flush the useful data of Tenant B and Tenant C out of the cache. This is known as the "Noisy Neighbor" effect at the hardware level.

The Cost of Cold Caches

When Tenant B returns to their dashboard, Postgres finds the cache "cold" and has to go to the disk (or EBS) to fetch the data. This causes latency spikes that seem random but are actually caused by the activity of other tenants.

How to Audit It

You can use the pg_buffercache extension to see exactly what is occupying your memory. This is a life-saver for identifying which tenant is hogging the cache.

-- You may need to run: CREATE EXTENSION pg_buffercache;
SELECT 
    c.relname, 
    count(*) AS buffers,
    usagecount,
    d.datname
FROM pg_buffercache b
JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
JOIN pg_database d ON b.reldatabase = d.oid
GROUP BY c.relname, usagecount, d.datname
ORDER BY buffers DESC
LIMIT 20;

If you see a single tenant's table taking up 80% of your shared_buffers, and that tenant isn't your most important customer, you have a resource contention issue.

Architecting for the Long Haul

If you’re hitting these limits, don't panic. Postgres is incredibly resilient, but it requires more manual guidance as you scale the tenant count.

Mitigation Strategies

1. Consolidate Schemas: If you have 10,000 schemas, consider moving to a hybrid model. Group "Small" tenants into a shared schema using RLS and give "Enterprise" tenants their own dedicated schema (or even their own dedicated database instance).
2. Partitioning is your friend: If using the shared-table approach, use Declarative Partitioning by tenant_id. This allows Postgres to "prune" partitions during query planning, effectively ignoring the data of other tenants at the file level.
3. Prepare your Statements: Use prepared statements to reduce planning time, but be careful with RLS. Ensure that the generic plan generated by Postgres is actually efficient for all tenants.
4. Dedicated Connection Pools: If you have a few massive tenants, give them their own PgBouncer pool. This prevents a single customer from exhausting all available connections to the backend.

The "right" way to do multi-tenancy in Postgres is rarely the one that is easiest to code on day one. It’s the one that balances isolation with the physical realities of how Postgres manages its memory and metadata. Keep an eye on your system catalogs and your planning times—your future self, waking up at 2 AM, will thank you.