ZephyrCode teardown · databases · No. 02

The Postgres connection pool that isn’t one.

PostgreSQL’s defaults are tuned so a laptop quickstart works on the first try — and the classic production “fix,” raising max_connections, makes the real problem worse. Here are eight defaults, each with the exact setting, the mechanism, and the fix. This is the format a fixed-price ZephyrCode audit produces, run against Postgres’s own reference configuration instead of your system.

What this is (and isn’t)

Every default below is from the current PostgreSQL reference documentation, linked at each finding. The subject is Postgres’s own documented defaults and the folklore fixes teams apply to them — not any team’s private production system, which nobody can observe from outside. Before/after figures are labelled typical: representative of the failure class, not any client’s numbers. Managed platforms (RDS, Cloud SQL, and friends) override some of these — check what your parameter group actually says.

Three pieces of tuning folklore this teardown is careful not to repeat: (1) “tune checkpoint_segments” — removed in 9.5, a decade ago; the dial is max_wal_size (Finding 8). (2) “set fsync=off for speed” — never in production; one crash and the database is corrupt, and no finding here trades durability for speed. (3) “raise effective_cache_size to give Postgres more memory” — it allocates nothing; it’s a planner assumption (Finding 3), and treating it as an allocation is how it ends up both huge and irrelevant.

8 findings · High → Medium
High severityFINDING 01 / 08

The pool that isn’t one

Pages the on-call the night traffic doubles and someone “fixes” it by raising max_connections.

Every Postgres connection is an operating-system process. max_connections=100 isn’t a queue in front of the database — it’s permission for up to 100 backend processes to compete for your cores, your locks, and your buffer pool at once. When latency climbs, the reflex fix is to raise the cap; now 500 backends thrash where a few dozen could work, and throughput falls as p99 rises. The saturation curve looks like a database problem, but it’s arithmetic: a machine does roughly cores-times-a-small-factor of useful database work at once, and every connection past that point pays context-switch and contention tax without adding throughput. The pool your system needs is a real one — in front of Postgres, in transaction mode — not a bigger cap.

max_connections = 100◂ processes, not a queue
# the classic “fix” that makes it worse
max_connections = 500◂ 500 backends thrash, throughput falls
Before · default
Typical: raising the cap under load → p99 climbs while total throughput falls
After · fixed
Typical: same offered load queues at the pooler; active backends stay near cores×k, p99 stabilises
Recommended fix

Put a transaction-mode pooler (PgBouncer or your platform’s equivalent) in front; size its server pool near cores×2–4 for OLTP, and keep max_connections modest — the pooler queues the burst instead of letting it thrash.

Tradeoff — Transaction pooling changes session semantics: session-level state (SET, advisory locks, LISTEN) needs care, and prepared statements need a pooler version that tracks them at the protocol level.

High severityFINDING 02 / 08

The 4 MB that multiplies

Pages whoever owns the box when the OOM killer picks Postgres.

work_mem defaults to 4 MB — and it is not per query. It’s per sort or hash operation, per node of the plan, and a complex query runs several at once. Multiply by hundreds of connections (Finding 1) and the same setting is either far too small — every sort spills to disk as temp files, quietly — or, after someone raises it globally to “fix reports,” far too large, and a busy hour turns into an out-of-memory kill. The default’s crime is that it invites a global answer to a per-workload question.

work_mem = 4MB◂ per sort/hash, PER PLAN NODE
# worst case ≈ work_mem × nodes × active connections
#           ≈ 4MB × 4 × 300 = ~4.8 GB … or 64MB × 4 × 300 = 75 GB
Before · default
Sorts spill to disk unnoticed — or a global raise OOMs the box under concurrency
After · fixed
Deliberate memory budget: spills visible and bounded, no multiplication surprise
Recommended fix

Size work_mem for the workload, not globally: a modest default, raised per role or per session (SET work_mem) for reporting queries; watch temp-file creation (log_temp_files, pg_stat_database.temp_bytes) to see where it’s genuinely too small.

Tradeoff — Per-role/session discipline is more moving parts than one global number; raising it anywhere multiplies against concurrency, so the pooler cap from Finding 1 is what makes larger values safe.

Medium severityFINDING 03 / 08

A cache sized for 2005

Pages nobody — it just quietly makes every read slower than the hardware you paid for.

shared_buffers defaults to 128 MB — deliberately tiny so Postgres starts anywhere, including the container you first tried it in. On a production box with tens of gigabytes of RAM, the database’s own cache is a rounding error, so reads bounce between Postgres and the OS page cache with double-copying in between. The companion setting effective_cache_size (default 4 GB) allocates nothing — it only tells the planner how much caching to assume — but left at default on a big box it makes index scans look more expensive than they are.

shared_buffers       = 128MB◂ container-sized, on a 64 GB box
effective_cache_size = 4GB     # planner hint only — allocates nothing
Before · default
DB cache is a rounding error; reads double-copy through the OS cache
After · fixed
Hot set lives in shared_buffers; planner assumptions match the machine
Recommended fix

Start shared_buffers around 25% of RAM on a dedicated box (with huge pages configured where available), and set effective_cache_size to roughly what the OS + Postgres can realistically cache (~50–75% of RAM).

Tradeoff — Changing shared_buffers requires a restart, and pushing it past ~40% of RAM usually buys nothing — the OS cache is doing real work too.

High severityFINDING 04 / 08

The planner still thinks disks spin

Pages the team that “fixed” a slow query by forcing an index, without asking why the planner refused it.

random_page_cost defaults to 4.0 — a ratio written for spinning disks, where a random read genuinely cost ~4× a sequential one. On NVMe and modern cloud volumes the real ratio is close to 1. Left at 4.0, the planner systematically over-prices index scans, so it reaches for sequential scans and bitmap heaps on queries where a straight index scan is the honest winner. Teams then blame the query, add hints via extensions, or duplicate indexes — treating symptoms of a cost model that still lives in 2005.

random_page_cost = 4.0◂ priced for spinning rust
seq_page_cost    = 1.0   # on NVMe the true ratio is ~1.1 : 1
Before · default
Good indexes ignored; seq scans win arguments they should lose
After · fixed
Cost model matches the hardware; the planner picks the index on merit
Recommended fix

Set random_page_cost ≈ 1.1 on SSD/NVMe-backed storage (and check effective_io_concurrency while you’re in the file). Validate with EXPLAIN on your top queries — plans will shift.

Tradeoff — If any tablespace really is on spinning disks or throttled network storage, keep its cost honest — this is per-tablespace-settable for exactly that reason. Plan shifts are broad; test before prod.

High severityFINDING 05 / 08

Vacuum arrives after the bloat

Pages the team whose 200 GB table is somehow 600 GB, and whose queries got slow “for no reason.”

autovacuum is on by default — good — but its trigger is proportional: autovacuum_vacuum_scale_factor = 0.2 means a table earns a vacuum after ~20% of its rows are dead. On a 100-million-row table that’s 20 million dead tuples before cleanup starts, and autovacuum_analyze_scale_factor = 0.1 means the planner’s statistics can be 10 million rows stale on the same table. Proportional triggers are fine for small tables and quietly catastrophic for big ones: bloat accumulates, indexes swell, and the vacuum that finally runs is a monster that fights your peak traffic for I/O.

autovacuum                      = on
autovacuum_vacuum_scale_factor  = 0.2◂ 100M rows → 20M dead before vacuum
autovacuum_analyze_scale_factor = 0.1
Before · default
Typical: big tables bloat 2–3× between vacuums; stats go stale; one monster vacuum at the worst time
After · fixed
Small, frequent, boring vacuums; stable table size and fresh statistics
Recommended fix

For large tables, set per-table storage parameters — a much smaller scale factor (or effectively threshold-based triggering) so vacuum runs early and small; raise autovacuum_vacuum_cost_limit / workers so it can keep up on busy systems.

Tradeoff — More frequent vacuums consume steady background I/O — the honest price of never needing the giant one during peak.

High severityFINDING 06 / 08

The transaction that holds the door open

Pages the DBA hunting cluster-wide bloat that no amount of vacuuming fixes.

idle_in_transaction_session_timeout defaults to 0 — disabled. One connection that ran BEGIN and then went quiet (an ORM that opened a transaction for a read and never closed it, a developer’s psql over lunch, a crashed worker holding its socket) pins the oldest-visible-transaction horizon. From that moment, vacuum — including every well-tuned autovacuum from Finding 5 — cannot reclaim any row version newer than that horizon, anywhere in the database. The bloat is cluster-wide, the cause is one idle socket, and nothing in the default configuration will ever kill it.

idle_in_transaction_session_timeout = 0◂ one idle BEGIN pins vacuum for ALL tables
statement_timeout                   = 0   # see Finding 7
Before · default
One forgotten BEGIN silently blocks vacuum cluster-wide
After · fixed
Idle transactions die on a timer; the vacuum horizon keeps moving
Recommended fix

Set idle_in_transaction_session_timeout (minutes, not hours) as a server default, with per-role overrides for the rare session that legitimately holds a transaction open.

Tradeoff — Long interactive transactions get killed mid-work — which is precisely the point; exempt the specific roles that need them instead of the whole server.

Medium severityFINDING 07 / 08

Nothing ever times out

Pages everyone at once, the day a noon deploy runs ALTER TABLE.

statement_timeout and lock_timeout both default to 0 — no limit. The famous failure isn’t the runaway query itself; it’s the queue behind it. Postgres lock waits are fair: when a migration’s ALTER TABLE waits for an ACCESS EXCLUSIVE lock behind one long-running read, every subsequent query on that table — including plain SELECTs — queues behind the ALTER. One slow report plus one un-timed migration equals a full table outage, at the exact moment the deploy pipeline says everything is fine.

statement_timeout = 0◂ runaway queries run forever
lock_timeout      = 0◂ DDL queues everything behind it, forever
Before · default
One slow read + one migration = table-wide queue, unbounded
After · fixed
Migrations fail fast and retry; runaways die before they page anyone
Recommended fix

Set a sane statement_timeout per application role (and a generous one server-wide); in migrations, always set lock_timeout (seconds) and retry — a migration that can’t get the lock quickly should fail fast, not dam the table.

Tradeoff — Legitimate long queries (analytics, backups) need explicit per-role or per-session exemptions — deliberate, visible ones.

Medium severityFINDING 08 / 08

Checkpoints on a one-gigabyte fuse

Pages the team chasing periodic latency spikes that “must be the disks.”

max_wal_size defaults to 1 GB and checkpoint_timeout to 5 minutes. Under a write burst, 1 GB of WAL arrives fast, so Postgres forces checkpoints early and often — and immediately after each checkpoint, full_page_writes means every touched page is written to WAL in full, amplifying I/O exactly when you’re busiest. The symptom is a latency sawtooth that correlates with nothing in your application and gets blamed on storage. The signal is sitting in pg_stat_checkpointer: requested checkpoints outnumbering timed ones means the fuse is too short.

max_wal_size       = 1GB◂ write bursts force early checkpoints
checkpoint_timeout           = 5min
checkpoint_completion_target = 0.9   # good default since v14 — keep it
Before · default
Typical: forced checkpoints every couple of minutes under load; post-checkpoint I/O spikes read as “slow disks”
After · fixed
Checkpoints on the timer, smoothed by completion_target; the sawtooth flattens
Recommended fix

Size max_wal_size to absorb your real write bursts (several GB is normal on busy systems) so checkpoints are timed, not requested; watch pg_stat_checkpointer to confirm.

Tradeoff — More WAL on disk and longer crash-recovery replay — the standard durability/latency dial, turned consciously.

That’s the reference config. Now picture yours.

This is the exact deliverable format of a ZephyrCode audit — findings with the mechanism, the evidence, and the fix, ranked by what pages you. Run against your Postgres, your Kafka, your inference stack. Fixed scope, fixed price, two weeks. Not sure it’s worth it? Start with the $1,200 48-hour triage.

ZephyrCode · engineering audits · the price is on the door · previously: the Kafka teardown