incident log / reliability notebook

PostgreSQL Didn't Fail. It Hit Its Limit.

Postgres rarely crashes outright. What it does instead is quietly run into a ceiling — a connection count, a vacuum horizon, a disk that fills with WAL, a replica that can't keep pace — and the failure shows up somewhere else entirely, looking like anything but a database problem.

Four limits, logged the way an incident channel actually reads: symptom, investigation, fix. None of these are exotic. They're the same handful of ceilings that show up again and again in production Postgres, and every one of them is survivable if you know where to look before the pager does.

Database Reliability & Production Incidents 04 entries
SEV-1 INC-3104 orders-db / pgbouncer

New connections start timing out under normal traffic

Nothing about the query load looks unusual. CPU is calm, disk is calm, and yet the application layer is throwing connection timeouts. This is almost never a Postgres problem in the query-tuning sense — it's an arithmetic problem. max_connections was set once, for a service that has since grown five deploys and three new background workers past what that number ever assumed.

FixPgBouncer in transaction-pooling mode in front of the primary, a statement timeout so a stuck transaction can't hold a slot indefinitely, and an alert on pool saturation — not just on connection errors, which only fire after it's already too late.

SEV-2 INC-3117 events-db

Queries that were fast for months get steadily slower

No schema change, no traffic spike, no obvious culprit — just a slow, creeping decline in query latency over weeks. The table on disk has grown far larger than the number of live rows in it would explain. This is bloat, and it's usually autovacuum losing a race it was never tuned to win.

FixPer-table autovacuum tuning for the highest-churn tables, an alert on transaction age so nothing can pin the vacuum horizon unnoticed, and a scheduled pg_repack pass on the worst offenders instead of waiting for a full table rewrite to become unavoidable.

SEV-1 INC-3129 primary-db / disk

Disk usage climbs steadily until the primary refuses writes

Disk utilization on the primary rises for hours with no corresponding growth in table data. Eventually writes fail outright because there's no room left to accept them. The tables aren't the problem — the write-ahead log is, and it's building up because something downstream stopped consuming it.

FixAlerting on replication slot lag measured in bytes, not just replica health checks, plus a hard cap via max_slot_wal_keep_size so an abandoned slot can degrade a downstream consumer instead of taking down the primary.

SEV-2 INC-3142 read-replicas

Replicas fall minutes behind during a write-heavy batch job

A nightly batch job hammers the primary with writes, and read replicas that are normally near real-time start serving data that's several minutes stale. Nothing has crashed — which is exactly why it takes longer than it should to notice.

FixReplica-lag-aware routing that falls back to the primary past a defined threshold, batch jobs scheduled against replica lag rather than a fixed clock time, and matching the replica's I/O provisioning to what write-heavy windows actually demand.

Pattern across all four

Postgres almost never fails loudly at the moment the underlying limit is reached. Connections don't run out — they quietly queue. Vacuum doesn't stop — it just falls further behind. WAL doesn't overflow — it accumulates for hours before disk pressure becomes undeniable. Replication doesn't break — it lags until something downstream trusts data that isn't there yet. The job isn't memorizing these four ceilings specifically. It's building the instinct to ask, for any resource a database quietly consumes, what happens when nobody is watching the rate at which it's being consumed.

Next in this series Schema migrations at scale — running them without locking the table your traffic depends on.