Archtin
All articles
DatabasesSystem DesignPerformanceInterview28 min read

The Database Handbook: Every Failure Scenario, How to Detect It, How to Fix It

One reference for production databases: indexing (when and which kind), slow queries, locks and deadlocks, connection exhaustion, replication lag, transactions and isolation anomalies, disk, vacuum, hot partitions, migrations, backups and disaster recovery.

Databases fail in a small number of recurring ways. The same twenty incidents show up at every company, from a two-person startup to a bank. This handbook collects them in one place. For each one you get the symptom you will see, how to confirm it, how to stop the bleeding, and how to stop it coming back. Examples use PostgreSQL, but the ideas carry over to MySQL and most other relational databases, and many apply to NoSQL stores too.

The failure map

ApplicationN+1 queriesNo timeoutsRetry stormsPool misconfigQuery layerMissing indexBad planStale statsFull scansConcurrencyLock waitsDeadlocksLost updatesLong txnsStorageDisk fullWAL growthBloatCorruptionTopologyReplica lagFailoverSplit brainHot shard
Database failures cluster into five layers. Diagnose top-down: most incidents start in the application or query layer.

When something is wrong, work through it in this order. What changed? (a deploy, a migration, a traffic spike, a new cron job). Is it every query or a few? (few means a plan or lock problem, all means a resource or topology problem). Reads or writes? Those three answers usually get you to the right section below within minutes.

Indexing: when, which, and when not

An index is a separate, sorted structure that maps column values to row locations. It trades write cost and disk space for fast reads. Most performance incidents are indexing incidents.

Root[ 100 | 500 ]Branch[ 20 | 60 ]Branch[ 200 | 350 ]Branch[ 700 | 900 ]Leaf200…349 → row ptrsLeaf350…499 → row ptrsHeap rowid = 412WHERE id = 412 → 4 page reads instead of scanning 10M rows. Range scans walk linked leaves left→right.
A B-tree index turns a full scan of millions of rows into three or four page reads: root → branch → leaf → heap row.

Index types and where to use them

TypeGood forExample
B-tree (default)Equality, ranges, sorting, prefix LIKE 'abc%'WHERE created_at > now() - '1 day'
HashEquality onlyWHERE session_token = $1
CompositeMulti-column filters + order(user_id, created_at DESC) for a feed
Covering (INCLUDE)Index-only scans, no heap visit(order_id) INCLUDE (status, total)
PartialQuerying a small subsetWHERE status = 'pending'
ExpressionFilters on a functionlower(email)
GINArrays, JSONB, full-texttags @> '{redis}', to_tsvector(body)
GiST / SP-GiSTGeospatial, ranges, nearest-neighbourPostGIS ST_DWithin, booking overlaps
BRINHuge append-only, naturally ordered tablesTime-series events by timestamp
UniqueEnforcing correctness, not just speedUNIQUE (idempotency_key)

Composite indexes and the leftmost-prefix rule

Index entries (sorted)(IN, 2026-01-03)(IN, 2026-02-11)(US, 2026-01-07)…grouped by country, dates ordered insideWHERE country='IN' AND created_at > …Fast: seek + rangeWHERE country='IN'Fast: leftmost prefixWHERE created_at > …Cannot seek — dates scattered across countries
Composite index (country, created_at): sorted by the first column, then the second. Queries must use the leftmost prefix.

Column order matters. Put equality columns first, then range or sort columns. For WHERE tenant_id = ? AND status = ? ORDER BY created_at DESC, the right index is (tenant_id, status, created_at DESC). That one index handles the filter and the sort, so there is no separate sort step.

Scenario guide

ScenarioIndex to create
Login by email (case-insensitive)UNIQUE INDEX ON users (lower(email))
User's latest 20 orders(user_id, created_at DESC)
Job queue: fetch pending jobsPartial: (run_at) WHERE status = 'pending'
Foreign key used in joins / cascadesIndex on the FK column (Postgres does not add it)
Search posts by tagGIN on tags array
Search inside JSON payloadGIN on jsonb (jsonb_path_ops)
Nearby drivers within 2 kmGiST on geography point
5 billion log rows by timeBRIN on timestamp + partitioning
Dashboard showing only status + totalCovering index with INCLUDE

When indexes hurt

  • Write amplification: every INSERT/UPDATE updates every index. Ten indexes means about ten extra writes per row.
  • Low selectivity: an index on a boolean is_active that is 95% true gets ignored. Use a partial index instead.
  • Functions on the column: WHERE date(created_at) = … or WHERE lower(email) = … skip a plain index. Rewrite the query as a range, or add an expression index.
  • Leading wildcards: LIKE '%foo' cannot use a B-tree. Use trigram (pg_trgm) GIN or a search engine. See inverted indexing.
  • Type mismatch: comparing a varchar column to an integer parameter forces a cast and a full scan.
  • Unused and duplicate indexes: they cost writes and RAM and give nothing back.
-- Indexes never scanned since stats reset
SELECT relname AS table, indexrelname AS index, pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes WHERE idx_scan = 0 ORDER BY pg_relation_size(indexrelid) DESC;

-- Tables with many sequential scans (candidate missing indexes)
SELECT relname, seq_scan, seq_tup_read, idx_scan
FROM pg_stat_user_tables ORDER BY seq_tup_read DESC LIMIT 15;

-- Always build in production without blocking writes
CREATE INDEX CONCURRENTLY idx_orders_user_created ON orders (user_id, created_at DESC);
Find unused indexes and tables that keep getting fully scanned

1. Slow queries and bad plans

Symptom: p99 latency climbs on one endpoint. Everything else looks fine.

Detect: use pg_stat_statements sorted by total time, the slow query log (log_min_duration_statement = 500ms), and EXPLAIN (ANALYZE, BUFFERS).

Plan signalCauseFix
Seq Scan on big tableMissing / unusable indexAdd the right index; rewrite non-sargable predicates
Estimated rows 10, actual 2MStale or missing statisticsANALYZE; raise statistics target; extended stats for correlated columns
Sort Method: external mergeSort spilled to diskIndex that supplies order, or raise work_mem for that query
Nested loop with huge outer sideMisestimateFix stats; rewrite join; as last resort plan hints
OFFSET 100000DB still reads 100k rowsKeyset pagination: WHERE id > last_id LIMIT 20
Fast in psql, slow in appGeneric plan for prepared statementCheck plan_cache_mode; parameter-sensitive skew
-- Slow at depth: scans and discards 100,000 rows
SELECT * FROM posts ORDER BY created_at DESC OFFSET 100000 LIMIT 20;

-- Constant time at any depth (needs index on (created_at, id))
SELECT * FROM posts
WHERE (created_at, id) < ($last_created_at, $last_id)
ORDER BY created_at DESC, id DESC LIMIT 20;
Keyset pagination instead of OFFSET

2. CPU pinned at 100%

Usually this is unnecessary work, not missing capacity. A missing index, stale statistics, a broken cache in front of the database, a connection storm, or a stray analytics job. The full triage is in Your database is at 100% CPU. The short version: check whether traffic or work-per-request went up, sort pg_stat_statements by total time, kill the runaway query, and set a statement_timeout.

3. Connection exhaustion

Symptom: FATAL: sorry, too many clients already or remaining connection slots are reserved. App threads hang waiting for a pool connection, and timeouts cascade.

App pod 1pool = 20App pod 2pool = 20App pod 3pool = 20App pod 4pool = 20PgBouncertransaction poolingPostgresmax_connections ≈ 100~30 realThousands of client connections multiplex onto a few dozen server connections.
Pool size multiplies by instance count. 40 pods × 20 connections = 800 backends on a database sized for 200.
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;
-- Many 'idle in transaction' = app opened a txn and did slow work (HTTP call!) inside it
SELECT pid, now() - xact_start AS age, left(query, 80)
FROM pg_stat_activity WHERE state = 'idle in transaction' ORDER BY age DESC;
Detect
  • Put PgBouncer (transaction mode) or RDS Proxy in front of the database.
  • Size pools on purpose. Total = pods × pool size, and it must stay below max_connections. A useful starting point per DB: about cores × 2 active connections (see Little's Law).
  • Set idle_in_transaction_session_timeout and never make network calls inside a transaction.
  • Make sure connections are returned to the pool on error paths (finally / using / defer).
  • Serverless functions: always go through a pooler, because each cold start opens a new connection.

4. Lock contention and deadlocks

Symptom: queries that are normally fast suddenly wait for seconds. Throughput drops while CPU stays low. Logs show deadlock detected.

Txn Aholds row 1Txn Bholds row 2Row 1Row 2waits forFix: always lock rows in the same order (e.g. ascending id)
Deadlock: each transaction holds the lock the other one needs. The database detects the cycle and kills one.
SELECT blocked.pid AS blocked_pid, left(blocked.query, 60) AS blocked_query,
       blocking.pid AS blocking_pid, left(blocking.query, 60) AS blocking_query,
       now() - blocking.xact_start AS blocking_age
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking ON blocking.pid = ANY(pg_blocking_pids(blocked.pid));
Who is blocking whom
CauseFix
Rows locked in different ordersLock in a consistent order (sort ids); retry on 40P01
Long transactions holding locksKeep transactions short; move work outside them
ALTER TABLE waiting behind a long query, blocking everything behind itSET lock_timeout = '3s' for DDL and retry
Hot counter row updated by everyoneShard the counter, batch increments, or use Redis INCR
Workers all grabbing the same jobSELECT … FOR UPDATE SKIP LOCKED
Foreign key checks without indexIndex FK columns
The DDL lock queue trap
ALTER TABLE needs an ACCESS EXCLUSIVE lock. If a 10-minute report is running, the ALTER waits, and every new query on that table queues behind the ALTER. A small change then takes the whole site down. Always set lock_timeout before running DDL.

5. Transactions and isolation anomalies

ACID guarantees only hold for the isolation level you actually run at. Most databases default to Read Committed, and that allows several bugs that look like "the database lost my data".

AnomalyExamplePrevented by
Dirty readSee another txn's uncommitted writeRead Committed+
Non-repeatable readSame SELECT returns different values in one txnRepeatable Read+
Lost updateTwo users read balance 100, both write 100−10 → 90 instead of 80Atomic UPDATE, SELECT FOR UPDATE, or optimistic version check
Write skewTwo doctors both go off-call because each saw the other on-callSerializable, or lock the constraint rows
PhantomNew row appears in range querySerializable / predicate locks
-- 1. Let the database do the arithmetic atomically
UPDATE accounts SET balance = balance - 10 WHERE id = 1 AND balance >= 10;

-- 2. Pessimistic: lock the row
BEGIN; SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; ... UPDATE ...; COMMIT;

-- 3. Optimistic: version column, retry if 0 rows updated
UPDATE accounts SET balance = $new, version = version + 1
WHERE id = 1 AND version = $read_version;
Three correct ways to avoid the lost update

At Serializable, the database aborts conflicting transactions with a serialization failure, so your code must retry. For duplicate writes across retries and networks, add idempotency keys with a unique constraint. See the duplicate payments bug.

6. Replication lag and stale reads

Userupdates profilePrimaryLSN 1050ReplicaLSN 1012 (lag 3s)Stale pageold name shown1. write2. readWAL stream
Read-your-writes failure: the write lands on the primary, the read hits a replica that has not replayed it yet.

Symptom: "I saved it but it's gone". Counts disagree between pages. Replicas fall minutes behind during batch jobs.

-- On primary
SELECT client_addr, state, replay_lag, pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS bytes_behind
FROM pg_stat_replication;
-- On replica
SELECT now() - pg_last_xact_replay_timestamp() AS lag;
Measure lag
  • Read-your-writes: send a user's reads to the primary for a few seconds after they write, or track the commit LSN and only read from replicas that have caught up to it.
  • Remove lagging replicas from the load balancer when lag goes over a threshold.
  • Causes of lag: big batch writes, long queries on the replica that conflict with replay, a slow network or disk on the replica. Break batch jobs into chunks.
  • Never read money or inventory from a replica when making a decision.

7. Primary failure and split brain

Old primarynetwork partitionedReplica → Primarypromoted, term 8Consensus (3 nodes)etcd / PatroniClientsvia VIP / proxyfenced: read-only
Safe failover needs a quorum to elect a leader and fencing so the old primary cannot keep accepting writes.

When the primary dies, a replica has to be promoted. The dangerous case is split brain. The old primary is not dead, only cut off from the others. It keeps accepting writes, and now two nodes disagree.

  • Use a consensus-based manager (Patroni + etcd, managed RDS/Aurora Multi-AZ, Cloud SQL HA). Do not use a hand-written health-check script.
  • Fence the old primary: STONITH, revoke its VIP, or have it demote itself when it loses quorum.
  • Asynchronous replication can lose the last few seconds of commits on failover (RPO > 0). Use synchronous replication to at least one standby for data you cannot lose.
  • Clients should connect through a stable endpoint and reconnect with backoff, not to a hard-coded IP.
  • Test failover regularly. One you have never tested will fail when you need it.

8. Disk full, WAL growth and bloat

When the disk fills up the database stops accepting writes, and Postgres may shut down. This is one of the most common outages and also one of the easiest to prevent.

CauseDetectFix
Inactive replication slot retaining WALpg_replication_slots where active = falseDrop the slot; set max_slot_wal_keep_size
Failing WAL archivingpg_stat_archiver failed_countFix archive target; alert on failures
Table / index bloat from dead tuplespgstattuple, n_dead_tup in pg_stat_user_tablesTune autovacuum; pg_repack for online rebuild
Long transaction blocking vacuumOldest xact_start in pg_stat_activityKill it; set timeouts
Unbounded log / audit tablesTable sizes over timePartition by time and drop old partitions
Temp files from big sortstemp_bytes in pg_stat_databaseFix the query; limit temp_file_limit
Transaction ID wraparound
Postgres transaction IDs are 32-bit. If vacuum cannot freeze old rows (because autovacuum is disabled or a transaction has been open for days), the database eventually refuses writes to protect data. Monitor age(datfrozenxid) and alert well before 1.5 billion.

9. Memory, cache misses and I/O

Databases are fast when the working set fits in RAM. When it stops fitting, latency jumps sharply rather than gradually.

SELECT sum(blks_hit) * 100.0 / nullif(sum(blks_hit) + sum(blks_read), 0) AS hit_pct
FROM pg_stat_database;
Buffer cache hit ratio (aim for > 99% on OLTP)
  • Rough Postgres settings: shared_buffers ≈ 25% of RAM, effective_cache_size ≈ 50–75%.
  • work_mem applies per sort/hash node per connection, so 200 connections × 64MB can trigger an OOM kill.
  • Watch disk IOPS and throughput limits on cloud volumes. Burst credits run out quietly.
  • Shrink the working set: archive cold data, partition, add covering indexes, and drop unused indexes.

10. Hot rows, hot partitions and sharding

Shard 1Shard 2Shard 3Shard 4Shard 5Shard 3 holds user_id = taylor_swift → 4× the load
A celebrity user or a time-based key concentrates traffic on one shard while the others sit idle.
  • Hot row: a like counter or inventory row updated thousands of times a second. Fix it with sharded counters (N rows summed on read), batching writes through a queue, or atomic operations in Redis.
  • Hot partition from the key choice: sharding by date sends all of today's writes to one shard. Shard by a high-cardinality key (user_id, tenant_id) or a hash of it.
  • Celebrity / large tenant: give the biggest tenants their own shards, or split their data further with a salted sub-key.
  • Cross-shard queries: pick the shard key so the hot queries stay on one shard, and keep global views in a separate read model (CQRS).
  • Rebalancing: use consistent hashing or many small virtual shards so you can move data without rehashing everything.

Sharding should be the last step. Before it, try indexes, read replicas, caching, partitioning and a bigger machine.

11. Schema migrations gone wrong

Expandstep 1Dual writestep 2Backfillstep 3Switch readsstep 4Contractstep 5
Expand–contract: every step is backward compatible, so any step can be rolled back without downtime.
DangerousSafe alternative
ALTER TABLE ADD COLUMN … DEFAULT volatile_fn() (table rewrite)Add nullable column, backfill in batches, then set default
CREATE INDEX (blocks writes)CREATE INDEX CONCURRENTLY
ADD FOREIGN KEY (validates immediately with lock)ADD … NOT VALID, then VALIDATE CONSTRAINT
SET NOT NULL on huge tableAdd CHECK (col IS NOT NULL) NOT VALID, validate, then SET NOT NULL
Rename / drop column used by running codeExpand–contract across deploys
UPDATE all 50M rows in one txnBatch 5–10k rows per txn with pauses

Run migrations with lock_timeout, take a backup or snapshot first, and make sure the old version of the application keeps working against the new schema.

12. Cache failures that hit the database

  • Stampede: a hot key expires and 10,000 requests rebuild it at once. Fix with request coalescing (single-flight), locks, or early probabilistic refresh.
  • Synchronised expiry: everything was cached at deploy time with the same TTL, so it all expires together. Add jitter to TTLs.
  • Cache penetration: repeated lookups for keys that do not exist. Cache the negative result, or use a Bloom filter.
  • Cold restart: warm the cache before sending traffic.

More in Your Redis cache suddenly became useless and the cache-aside pattern.

13. Data loss, corruption and backups

Base backup 00:00replay WAL archiveTarget 14:31:59DELETE without WHERE 14:32
Point-in-time recovery: restore the last base backup, then replay WAL up to one second before the bad DELETE.
ScenarioProtection
DELETE / UPDATE without WHEREPITR from WAL archive; require transactions + review for manual SQL; soft deletes
Disk or instance lossReplicas + off-site backups
Region outageCross-region replica or backup copies
Ransomware / compromised credentialsImmutable backups in a separate account
Silent corruptionData checksums, regular restore tests, amcheck
Bad application bug writing garbageAudit tables / CDC history; PITR to a side instance and copy rows back
A backup you have never restored is not a backup
Define RPO (how much data you can lose) and RTO (how long you can be down). Run automated restore drills every week and record how long they take. Most teams find out that their backups are broken during the incident.

14. Application-side anti-patterns

Anti-patternSymptomFix
N+1 queries1 page = 201 queriesJOIN / IN (...) / ORM eager loading; DataLoader
SELECT *Wide rows, no index-only scansSelect needed columns
No query timeoutOne bad query holds a connection foreverstatement_timeout per role
Aggressive retriesRetry storm amplifies outageExponential backoff + jitter + circuit breaker
Counting with COUNT(*) on every page viewFull scansCached / approximate counts
Using DB as a queue naivelyLock contentionSKIP LOCKED or a real queue (Kafka, SQS)
Big transactions around external callsLocks + idle in transactionOutbox pattern

Related: circuit breaker, outbox pattern, SQL injection (security failures are database failures too).

What to monitor

MetricWhyAlert when
Query p95/p99 latencyFirst sign of plan or lock issues2× baseline for 5 min
Active / idle-in-txn connectionsPool exhaustion> 80% of max
Lock waits & deadlocksContentionAny sustained growth
Replication lagStale reads, failover RPO> 10s (tune per app)
Disk free & WAL sizeDisk-full outage< 20% free
Cache hit ratioWorking set vs RAM< 99% OLTP
Dead tuples / XID ageBloat, wraparoundage > 1B
Backup success & restore testRecoverabilityAny failure
CPU, IOPS, memorySaturation> 80% sustained

Cheat sheet

You seeMost likelyFirst move
One endpoint slowMissing index / bad planEXPLAIN ANALYZE
Everything slow, CPU 100%Expensive query or cache downpg_stat_statements by total time
Everything slow, CPU lowLocks or I/Opg_blocking_pids, wait events
Too many clientsPool sizing / leaksPooler + timeouts
deadlock detectedInconsistent lock orderOrder locks, retry
Saved data disappearsReplica lagRead-your-writes routing
Writes rejected, disk fullWAL slot / bloatDrop slot, vacuum, expand disk
Site down during deployMigration locklock_timeout, expand–contract
Wrong totals under loadLost update / write skewAtomic UPDATE, FOR UPDATE, serializable
One shard meltingBad shard key / hot tenantRekey, split, isolate tenant
  1. Most database incidents are caused by the application or a query, not by hardware.
  2. Index for your access patterns: equality columns first, then range or sort columns. Remove indexes you do not use.
  3. Keep transactions short, set timeouts everywhere, and lock rows in a consistent order.
  4. Replicas give you read scale and availability, but their reads can be stale. Plan for read-your-writes.
  5. Monitor disk, WAL, vacuum and XID age, because they fail without warning.
  6. Only backups you have restored can be trusted.

Keep reading

Suggested next articles based on this one.

Design it, don't just read it.

Practise LLD and system design problems with structured rubrics and AI feedback.

Start practising free