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
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.
Index types and where to use them
| Type | Good for | Example |
|---|---|---|
| B-tree (default) | Equality, ranges, sorting, prefix LIKE 'abc%' | WHERE created_at > now() - '1 day' |
| Hash | Equality only | WHERE session_token = $1 |
| Composite | Multi-column filters + order | (user_id, created_at DESC) for a feed |
| Covering (INCLUDE) | Index-only scans, no heap visit | (order_id) INCLUDE (status, total) |
| Partial | Querying a small subset | WHERE status = 'pending' |
| Expression | Filters on a function | lower(email) |
| GIN | Arrays, JSONB, full-text | tags @> '{redis}', to_tsvector(body) |
| GiST / SP-GiST | Geospatial, ranges, nearest-neighbour | PostGIS ST_DWithin, booking overlaps |
| BRIN | Huge append-only, naturally ordered tables | Time-series events by timestamp |
| Unique | Enforcing correctness, not just speed | UNIQUE (idempotency_key) |
Composite indexes and the leftmost-prefix rule
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
| Scenario | Index 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 jobs | Partial: (run_at) WHERE status = 'pending' |
| Foreign key used in joins / cascades | Index on the FK column (Postgres does not add it) |
| Search posts by tag | GIN on tags array |
| Search inside JSON payload | GIN on jsonb (jsonb_path_ops) |
| Nearby drivers within 2 km | GiST on geography point |
| 5 billion log rows by time | BRIN on timestamp + partitioning |
| Dashboard showing only status + total | Covering 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_activethat is 95% true gets ignored. Use a partial index instead. - Functions on the column:
WHERE date(created_at) = …orWHERE 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);
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 signal | Cause | Fix |
|---|---|---|
| Seq Scan on big table | Missing / unusable index | Add the right index; rewrite non-sargable predicates |
| Estimated rows 10, actual 2M | Stale or missing statistics | ANALYZE; raise statistics target; extended stats for correlated columns |
| Sort Method: external merge | Sort spilled to disk | Index that supplies order, or raise work_mem for that query |
| Nested loop with huge outer side | Misestimate | Fix stats; rewrite join; as last resort plan hints |
| OFFSET 100000 | DB still reads 100k rows | Keyset pagination: WHERE id > last_id LIMIT 20 |
| Fast in psql, slow in app | Generic plan for prepared statement | Check 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;
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.
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;
- 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: aboutcores × 2active connections (see Little's Law). - Set
idle_in_transaction_session_timeoutand 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.
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));| Cause | Fix |
|---|---|
| Rows locked in different orders | Lock in a consistent order (sort ids); retry on 40P01 |
| Long transactions holding locks | Keep transactions short; move work outside them |
| ALTER TABLE waiting behind a long query, blocking everything behind it | SET lock_timeout = '3s' for DDL and retry |
| Hot counter row updated by everyone | Shard the counter, batch increments, or use Redis INCR |
| Workers all grabbing the same job | SELECT … FOR UPDATE SKIP LOCKED |
| Foreign key checks without index | Index FK columns |
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".
| Anomaly | Example | Prevented by |
|---|---|---|
| Dirty read | See another txn's uncommitted write | Read Committed+ |
| Non-repeatable read | Same SELECT returns different values in one txn | Repeatable Read+ |
| Lost update | Two users read balance 100, both write 100−10 → 90 instead of 80 | Atomic UPDATE, SELECT FOR UPDATE, or optimistic version check |
| Write skew | Two doctors both go off-call because each saw the other on-call | Serializable, or lock the constraint rows |
| Phantom | New row appears in range query | Serializable / 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;
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
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;
- 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
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.
| Cause | Detect | Fix |
|---|---|---|
| Inactive replication slot retaining WAL | pg_replication_slots where active = false | Drop the slot; set max_slot_wal_keep_size |
| Failing WAL archiving | pg_stat_archiver failed_count | Fix archive target; alert on failures |
| Table / index bloat from dead tuples | pgstattuple, n_dead_tup in pg_stat_user_tables | Tune autovacuum; pg_repack for online rebuild |
| Long transaction blocking vacuum | Oldest xact_start in pg_stat_activity | Kill it; set timeouts |
| Unbounded log / audit tables | Table sizes over time | Partition by time and drop old partitions |
| Temp files from big sorts | temp_bytes in pg_stat_database | Fix the query; limit temp_file_limit |
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;
- Rough Postgres settings:
shared_buffers≈ 25% of RAM,effective_cache_size≈ 50–75%. work_memapplies 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
- 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
| Dangerous | Safe 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 table | Add CHECK (col IS NOT NULL) NOT VALID, validate, then SET NOT NULL |
| Rename / drop column used by running code | Expand–contract across deploys |
| UPDATE all 50M rows in one txn | Batch 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
| Scenario | Protection |
|---|---|
| DELETE / UPDATE without WHERE | PITR from WAL archive; require transactions + review for manual SQL; soft deletes |
| Disk or instance loss | Replicas + off-site backups |
| Region outage | Cross-region replica or backup copies |
| Ransomware / compromised credentials | Immutable backups in a separate account |
| Silent corruption | Data checksums, regular restore tests, amcheck |
| Bad application bug writing garbage | Audit tables / CDC history; PITR to a side instance and copy rows back |
14. Application-side anti-patterns
| Anti-pattern | Symptom | Fix |
|---|---|---|
| N+1 queries | 1 page = 201 queries | JOIN / IN (...) / ORM eager loading; DataLoader |
| SELECT * | Wide rows, no index-only scans | Select needed columns |
| No query timeout | One bad query holds a connection forever | statement_timeout per role |
| Aggressive retries | Retry storm amplifies outage | Exponential backoff + jitter + circuit breaker |
| Counting with COUNT(*) on every page view | Full scans | Cached / approximate counts |
| Using DB as a queue naively | Lock contention | SKIP LOCKED or a real queue (Kafka, SQS) |
| Big transactions around external calls | Locks + idle in transaction | Outbox pattern |
Related: circuit breaker, outbox pattern, SQL injection (security failures are database failures too).
What to monitor
| Metric | Why | Alert when |
|---|---|---|
| Query p95/p99 latency | First sign of plan or lock issues | 2× baseline for 5 min |
| Active / idle-in-txn connections | Pool exhaustion | > 80% of max |
| Lock waits & deadlocks | Contention | Any sustained growth |
| Replication lag | Stale reads, failover RPO | > 10s (tune per app) |
| Disk free & WAL size | Disk-full outage | < 20% free |
| Cache hit ratio | Working set vs RAM | < 99% OLTP |
| Dead tuples / XID age | Bloat, wraparound | age > 1B |
| Backup success & restore test | Recoverability | Any failure |
| CPU, IOPS, memory | Saturation | > 80% sustained |
Cheat sheet
| You see | Most likely | First move |
|---|---|---|
| One endpoint slow | Missing index / bad plan | EXPLAIN ANALYZE |
| Everything slow, CPU 100% | Expensive query or cache down | pg_stat_statements by total time |
| Everything slow, CPU low | Locks or I/O | pg_blocking_pids, wait events |
| Too many clients | Pool sizing / leaks | Pooler + timeouts |
| deadlock detected | Inconsistent lock order | Order locks, retry |
| Saved data disappears | Replica lag | Read-your-writes routing |
| Writes rejected, disk full | WAL slot / bloat | Drop slot, vacuum, expand disk |
| Site down during deploy | Migration lock | lock_timeout, expand–contract |
| Wrong totals under load | Lost update / write skew | Atomic UPDATE, FOR UPDATE, serializable |
| One shard melting | Bad shard key / hot tenant | Rekey, split, isolate tenant |
- Most database incidents are caused by the application or a query, not by hardware.
- Index for your access patterns: equality columns first, then range or sort columns. Remove indexes you do not use.
- Keep transactions short, set timeouts everywhere, and lock rows in a consistent order.
- Replicas give you read scale and availability, but their reads can be stale. Plan for read-your-writes.
- Monitor disk, WAL, vacuum and XID age, because they fail without warning.
- Only backups you have restored can be trusted.