Postgres is fast until it suddenly is not, and the transition is rarely gradual. A query that ran in two milliseconds on ten thousand rows takes four seconds on ten million, and it usually happens the week after a launch. The patterns behind these cliffs are remarkably consistent across teams and codebases, which makes them worth learning once.
01Find the problem before theorising about it
Enable pg_stat_statements and sort by total execution time rather than mean. The query that takes 40 milliseconds and runs two million times a day costs far more than the nightly report that takes 30 seconds, and teams almost always optimise the visible slow query rather than the expensive one.
Then read the actual plan with EXPLAIN (ANALYZE, BUFFERS). The BUFFERS output is the part most people skip and the part that tells you whether you are reading from memory or from disk. A plan that looks reasonable but shows enormous shared read counts is doing physical I/O that a better index or a higher cache ratio would eliminate.
- Sort pg_stat_statements by total_exec_time, not mean_exec_time.
- Always use ANALYZE and BUFFERS - estimated plans mislead.
- Compare estimated vs actual row counts; large divergence means stale statistics.
- Check for sequential scans on large tables inside nested loops.
02The N+1 that ORMs make invisible
The single most common scaling failure is not a slow query but a fast one executed thousands of times. An ORM lazily loads a relation inside a loop, and a page that issued three queries in development issues three thousand in production. Each is sub-millisecond, so nothing looks slow, but the round trips dominate response time.
The fix is eager loading the relations you know you will need, and the discipline is logging query counts per request in development with a threshold that fails loudly. Teams that add this check find existing N+1s immediately and stop introducing new ones, because the feedback arrives before the code merges.
03Indexes: the ones people miss
Single-column indexes on foreign keys are usually present. What is usually missing is the composite index matching the actual query shape, and the column order in a composite index is not arbitrary - it must follow equality conditions first, then range conditions, then the ordering column.
An index on (tenant_id, created_at) serves a query filtering by tenant and sorting by date. The reverse order does not, and the planner will ignore it. This ordering rule explains a large share of 'I added an index and nothing improved' reports.
| Type | Use for | Cost |
|---|---|---|
| B-tree composite | Equality + range + sort in one query shape | Write amplification; column order matters |
| Partial | Queries always filtering on a subset, e.g. active rows | Only used when the predicate matches exactly |
| GIN | JSONB containment, full-text, array membership | Slower writes, larger index |
| Covering (INCLUDE) | Index-only scans avoiding heap fetches | Larger index; needs current visibility map |
| BRIN | Very large naturally-ordered tables, e.g. append-only logs | Useless if physical order is not correlated |
04Connection pooling is not optional
Each Postgres connection is an operating system process with meaningful memory overhead. A few hundred connections will consume resources that should be serving queries, and serverless platforms make this dramatically worse by opening connections per invocation with no reuse.
Run a pooler - PgBouncer or the managed equivalent - in transaction mode for most application workloads. Be aware that transaction mode breaks session-level features: prepared statements, advisory locks held across statements, and session variables. Most application code does not use these, but the ones that do fail in confusing intermittent ways, so audit before switching.
05Vacuum, bloat and the slow decline
Postgres does not overwrite rows on update; it writes a new version and marks the old one dead. Autovacuum reclaims that space, and when it cannot keep up, tables and indexes bloat. Queries slow gradually as they read more pages for the same data, and because the decline is slow nobody notices until it is severe.
High-churn tables frequently need per-table autovacuum tuning more aggressive than the defaults, which were chosen for a much smaller era of hardware. Monitor dead tuple ratios and transaction ID age. The latter matters existentially: if wraparound protection engages, Postgres will stop accepting writes to protect data integrity, and the emergency vacuum that follows is not a fast operation.
Topics
Priya Iyer
Staff Engineer · SyncTrix
Writes about the engineering decisions behind production systems - architecture, delivery and the trade-offs that only show up at scale.
Building something like this?
SyncTrix engineers AI, SaaS, platform and cloud systems for enterprises and high-growth teams. Tell us what you're shipping and we'll scope it with you.