PostgreSQL has been the engineers' database of choice for decades, and for good reason: its query planner is sophisticated, its feature set is unmatched among open-source relational databases, and its correctness guarantees are rock-solid. Yet teams still regularly hit walls where queries that should run in milliseconds take seconds, or where a schema that worked fine at 100K rows grinds at 10M. The root causes are almost always the same handful of problems — and fixing them is more systematic than it looks.
Recent research underscores the stakes. A 2026 paper accepted at VLDB showed that by restructuring how PostgreSQL handles batches of counting queries — using factorized representations and domain quantization — speedups of 2× to 178× are achievable without modifying the database internals at all. The insight translates beyond batch evaluation: the way queries are structured matters as much as how the database is configured.
Image: Logo PostgreSQL — Daniel Lundin (BSD License), via Wikimedia Commons
Start With EXPLAIN ANALYZE — Every Time
The single most important habit for PostgreSQL performance work is running EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) on any query you care about, rather than guessing at the problem. The output tells you:
- Estimated vs. actual row counts: Large gaps between these numbers signal planner statistics problems
- Which nodes dominate cost: The node with the highest actual time is where optimization effort pays off
- Whether indexes are being used: Seq scans on large tables are a red flag; Index Scans or Index-Only Scans are what you want
- Buffer hits vs. reads: High
Buffers: shared readnumbers mean disk I/O is a bottleneck
A 2026 study from ICDE analyzing GROUP-BY cardinality estimation found that PostgreSQL's planner makes poor plan choices when its cardinality estimates are off — sometimes by an order of magnitude on multi-attribute GROUP-BY queries. The fix is more frequent ANALYZE runs and, in some cases, manual statistics targets.
EXPLAIN (ANALYZE, BUFFERS) on your slow queries. Planner misjudgments from stale or low-resolution statistics are responsible for a larger share of production slowdowns than misconfigured shared_buffers or work_mem.Index Strategy: Beyond the Basics
Everyone knows to add indexes, but effective PostgreSQL indexing is more nuanced than adding a B-tree on every column in a WHERE clause.
Composite indexes and column order: PostgreSQL can use a composite index on (a, b) for queries filtering on a alone, or on both a and b — but not for queries filtering on b alone. Put the highest-selectivity column (the one that eliminates the most rows) first, unless your queries consistently filter on the second column independently, in which case that column needs its own index.
Partial indexes: For tables where the overwhelming majority of queries target a specific subset — active users, open orders, recent events — a partial index with a WHERE clause is dramatically smaller and faster than a full-table index. CREATE INDEX ON orders (customer_id) WHERE status = 'pending' might be 1/50th the size of a full index on customer_id while serving 90% of your application's queries.
Covering indexes (INCLUDE): If a query fetches only a few columns and you need an Index-Only Scan, use INCLUDE to store additional columns in the index leaf pages: CREATE INDEX ON users (email) INCLUDE (name, created_at). This eliminates the heap fetch entirely for eligible queries.
GIN indexes for JSONB and arrays: Full-table scans on JSONB columns are a common PostgreSQL performance trap. A GIN index enables efficient containment and existence operators (@>, ?) on JSONB and array columns. The index is larger and slower to write, but read performance improves by orders of magnitude.
The MVCC Factor: Bloat and Vacuuming
PostgreSQL's Multi-Version Concurrency Control (MVCC) architecture — which allows readers and writers to never block each other — comes with a maintenance cost. Dead tuples from UPDATE and DELETE operations accumulate in heap pages until VACUUM reclaims them. A 2024 analysis comparing PostgreSQL and MariaDB on NVMe storage at different I/O bandwidths found that PostgreSQL's version storage strategy (keeping dead tuples in the main heap) becomes a significant factor at scale, particularly for write-heavy workloads.
Table bloat from insufficient vacuuming causes performance degradation in multiple ways:
- Sequential scans read more pages than necessary, including those containing only dead tuples
- Index bloat causes index scans to traverse more levels and read more pages
- PostgreSQL's visibility map becomes stale, preventing Index-Only Scans from skipping heap fetches
Practical steps to keep bloat under control:
- Set
autovacuum_vacuum_scale_factor = 0.01(default 0.2) for large, high-churn tables — this triggers vacuum after 1% of rows change rather than 20% - Set per-table storage parameters with
ALTER TABLE ... SET (autovacuum_vacuum_scale_factor = 0.01) - Monitor bloat regularly with the
pgstattupleextension - For extreme bloat on critical tables, use
pg_repack(an extension) for live space reclamation without an exclusive lock
Configuration: The Parameters That Actually Move the Needle
| Parameter | Default | Recommended Starting Point | Impact |
|---|---|---|---|
shared_buffers |
128MB | 25% of RAM | Primary PostgreSQL page cache; most impactful single parameter |
work_mem |
4MB | 16–64MB (careful with connections × parallel workers) | Eliminates sort/hash spills to disk; transforms sort plans |
effective_cache_size |
4GB | 50–75% of RAM | Planner hint; higher value favors index scans over seq scans |
random_page_cost |
4.0 | 1.1–1.5 for SSD/NVMe | Planner cost model; wrong value causes seq scan preference |
max_parallel_workers_per_gather |
2 | 4–8 on multi-core servers | Enables parallel seq scans and parallel hash joins |
default_statistics_target |
100 | 200–500 for skewed data distributions | Improves cardinality estimates; fixes bad plans from correlated data |
Query Structure: The Layer Most Developers Skip
Database configuration and indexing are frequently optimized; query structure rarely is. The 2026 BaCon research from VLDB demonstrated that restructuring the logical form of counting queries — without any schema or configuration changes — produced speedups of up to 178× by allowing the database to share intermediate results across related queries. The underlying principle applies broadly:
Avoid correlated subqueries where a join will do. A subquery in a SELECT list that references the outer query row is re-executed for every outer row. Moving it to a JOIN or a lateral join often converts an O(n²) plan to O(n log n).
Use CTEs thoughtfully. In older PostgreSQL versions, CTEs were optimization fences — the planner could not push conditions through them. Since PostgreSQL 12, non-recursive CTEs without side effects are inlined by default, but this behavior can still be controlled with MATERIALIZED or NOT MATERIALIZED hints.
Batch related queries. N+1 query patterns — fetching a list of users and then querying orders for each user individually — are one of the most common application-layer performance antipatterns. Replace them with a single query using IN, ANY, or a join. Connection overhead alone makes 100 queries 100× slower than 1 equivalent query even if each individual query is fast.
Use RETURNING to avoid a second round-trip. If you INSERT or UPDATE a row and then immediately SELECT it, merge the operations: INSERT INTO ... VALUES ... RETURNING id, created_at.
Connection Pooling: The Missing Layer
PostgreSQL spawns a new OS process for each connection — each consuming 5–10 MB of RAM and adding overhead to every query. Applications with short-lived requests (HTTP APIs, serverless functions) that open new connections per request pay a high per-connection setup cost and can exhaust PostgreSQL's max_connections under load.
PgBouncer is the industry-standard solution: a lightweight connection pooler that maintains a pool of persistent backend connections and multiplexes application connections through them. In transaction-mode pooling (the most aggressive and most effective mode), a single backend connection services dozens of application connections. Most production systems with more than a handful of concurrent users should run PgBouncer between the application tier and PostgreSQL.
Frequently Asked Questions
How do I find the slowest queries in production without running EXPLAIN manually?
Enable the pg_stat_statements extension, which is included with PostgreSQL and simply needs to be loaded. It automatically tracks execution counts, total time, rows returned, and buffer usage for every distinct query pattern (normalized). Query pg_stat_statements ordered by total_exec_time DESC or mean_exec_time DESC to find the highest-impact targets. Many managed services (RDS, Cloud SQL, Supabase) expose this data in their dashboards.
When should I use a BRIN index instead of a B-tree?
BRIN (Block Range INdex) is appropriate for naturally ordered, very large tables where data is physically stored in the order you query it — timestamp columns in append-only event tables are the textbook case. A BRIN index is tiny (often a few hundred KB for tables with millions of rows) but provides much less precise filtering than a B-tree. If your timestamp column is inserted in roughly chronological order and you always query recent ranges, a BRIN index can be faster and far cheaper to maintain than a B-tree index on the same column.
Should I use VACUUM FULL to reclaim space from a bloated table?
Only as a last resort. VACUUM FULL acquires an exclusive lock on the table for its entire duration, blocking all reads and writes. For production systems, use pg_repack instead — it performs an equivalent space reclamation online, with only a brief lock at the final swap step. Run it during off-peak hours and monitor replication lag if you have replicas, as it generates significant WAL volume.
Bottom Line
PostgreSQL performance optimization follows a consistent priority: first understand what the planner is actually doing with EXPLAIN ANALYZE, then fix stale statistics and missing indexes before touching configuration. Configuration tuning — especially shared_buffers, work_mem, and random_page_cost — provides meaningful baseline improvements but cannot compensate for structural query problems or table bloat. We recommend instrumenting production with pg_stat_statements before the first optimization sprint, keeping autovacuum aggressive on high-churn tables, and treating connection pooling via PgBouncer as infrastructure rather than an optimization — it should be in place before you see connection exhaustion, not after.
Sources & References:
Liu Y et al. (2026). BaCon: Efficient Batch Processing of Counting Queries. VLDB 2026. arXiv:2607.05832.
Zhang Y et al. (2026). From Single to Multiple Attributes: Experimental Insights on Sampling-Based Distinct Combination Estimation in GROUP-BY Queries. ICDE 2026. arXiv:2607.00868.
Han J & Choi Y (2024). Analyzing Performance Characteristics of PostgreSQL and MariaDB on NVMeVirt. arXiv:2411.10005.
Disclaimer: This article is for informational purposes only. Technology landscapes change rapidly; verify information with official sources before making technical decisions.