PostgreSQL performance tuning when the database is the bottleneck
PostgreSQL performance tuning starts with measurement, not configuration. Most slow Postgres databases have a handful of queries doing most of the damage, plus a connection, vacuum or indexing problem behind them. I find those, fix them in production safely, and tell you honestly whether you need a bigger instance or a different design.
Postgres sits under several platforms I’ve built: Varsity Vibe with 710,000 users and real-time messaging, the BLU Loyalty platform serving 9 million users, and Indiemaker on Django and Google Cloud.
How I find a Postgres slow query
- pg_stat_statements first. Sort by total execution time, not mean. A 5 ms query called a million times an hour matters more than a 2-second report run twice a day.
- EXPLAIN (ANALYZE, BUFFERS) on the top offenders, using real parameter values. I look for sequential scans on large tables, row estimates far from actual rows, sorts and hashes spilling to disk, and nested loops over large inputs.
- auto_explain with a duration threshold to catch plans that only go bad with certain parameters or data skew.
- pg_stat_activity and lock waits during the slow periods. Sometimes the query is fine and it’s waiting on a lock held by a migration or a long transaction.
- Application traces to tie each query back to the endpoint and the line of ORM code that issues it.
Indexing that actually helps
- Composite indexes in the right column order – equality columns first, then the range or sort column.
- Partial indexes for queries that always filter the same way, such as only rows where deleted_at IS NULL, or only jobs still pending.
- Covering indexes with INCLUDE so hot queries can use index-only scans.
- Dropping unused indexes. pg_stat_user_indexes shows indexes with zero scans. Each one slows every write and wastes cache.
- Building safely. CREATE INDEX CONCURRENTLY on live tables, so you don’t block writes during business hours.
Connections, vacuum and the problems that aren’t queries
Every Postgres connection is a backend process with its own memory. Hundreds of app workers each holding connections will hurt long before CPU does. PgBouncer in transaction pooling mode lets thousands of client connections share a small pool – check how your driver and ORM handle prepared statements and session state before switching modes.
Vacuum is the other quiet killer. Updates and deletes leave dead tuples behind; if autovacuum can’t keep up, tables and indexes bloat and queries slow down. Large, busy tables usually need per-table settings such as a lower autovacuum_vacuum_scale_factor. And watch for transactions left idle in transaction – they hold back cleanup for the whole database and, in the worst case, push you towards transaction ID wraparound.
Then the configuration basics: shared_buffers, effective_cache_size, work_mem sized for your concurrency, and random_page_cost lowered for SSD storage. Useful, but rarely the main fix.
Partitioning, replicas and when to scale up
Declarative partitioning by time pays off for large append-mostly tables like events, logs and audit trails: queries prune to recent partitions, and retention becomes dropping a partition instead of a huge DELETE. It doesn’t help tables you query by something other than the partition key.
Read replicas take reporting and read-heavy endpoints off the primary, as long as the app tolerates a little replication lag.
And sometimes the honest answer is a bigger instance. If the working set no longer fits in memory and the queries are already good, scaling vertically is cheaper than an engineer’s month. Re-architecture – caching, splitting a hot table, moving analytics elsewhere – comes in when vertical scaling stops being linear. See caching strategy and multi-tenant SaaS architecture.
MySQL performance tuning too
The method carries over: slow query log and performance_schema instead of pg_stat_statements, pt-query-digest to rank them, EXPLAIN ANALYZE in MySQL 8, InnoDB buffer pool sizing. If you’re considering moving engines, that’s a separate decision – see MySQL to PostgreSQL migration.
Questions
Why is my database slow?
Usually a few expensive queries, missing or wrong indexes, too many connections, or bloat from vacuum falling behind. Less often the instance is simply too small. Measure with pg_stat_statements and EXPLAIN ANALYZE before changing configuration or buying hardware.
What does a PostgreSQL consultant do?
A PostgreSQL consultant diagnoses performance and reliability problems – slow queries, indexing, connection pooling, vacuum, replication, upgrades – and fixes them. I also work in the application code, because many database problems start in the ORM.
Do I need PgBouncer?
If you run many application processes or serverless functions that each open connections, almost certainly. Postgres handles a modest number of active connections well and degrades as that grows. PgBouncer in transaction mode keeps the server-side count small.
Should we scale up the database or optimize queries?
Optimize first if pg_stat_statements shows a few queries dominating load – that’s cheap and often dramatic. If queries are already efficient and the working set has outgrown memory, scaling up is a perfectly good answer for a while.
Database slowing everything down?
Send me your top queries from pg_stat_statements. In a two-week sprint I’ll find the root causes and ship the first fixes to production.
GMT+2 · Full EU overlap · US East mornings