13 KiB
Debugging — Symptom to Cause
Work symptom-first. Each chain is ordered by probability, and every step is a check you can run, not a guess. Wrong results outrank slow results: a fast query returning the wrong rows never raises an error.
Contents: First Three · Wrong Row Count · Wrong Totals · Query Slow · Was Fast Yesterday · Index Ignored · Hangs · Deadlock · Connection Errors · Constraint Violations · Disk Full · Encoding · Works In One Client · Migration Failed · Replica Disagrees
The Universal First Three
- Read the actual statement, not the intent. For ORM-generated SQL, turn on query logging and copy the emitted text; for dynamic SQL, log the final string with parameters bound.
- Count before you optimize.
SELECT COUNT(*)on each base table with only its own filters. If a base count already surprises you, the bug is upstream of the join. EXPLAIN (ANALYZE, BUFFERS)the real statement with real parameters. A plan foruser_id = 1can differ fromuser_id = 99when one value is a most-common-value in the stats.
Wrong Row Count (too many, too few, duplicates)
- Strip the query to one table plus its
WHERE. Add joins back one at a time and re-count after each — the join that changes the count names the bug. - Count grew → 1:N fan-out. Check the join key's uniqueness:
SELECT key, COUNT(*) FROM t GROUP BY key HAVING COUNT(*) > 1. Fix by pre-aggregating, not byDISTINCT(SKILL.md Traps). - Count shrank → a
LEFT JOINwith a predicate inWHERE(now an inner join, with no error), or aNOT INagainst a subquery containing NULL (SKILL.md rule 6). - Count differs between two "equivalent" queries → collation or case sensitivity: MySQL's
_cicollations match'A' = 'a', PostgreSQL does not. - Trailing-space equality:
CHAR(n)pads, and some engines ignore trailing spaces in comparison.'a ' = 'a'is true on MySQLCHAR, false on PostgreSQLTEXT. - Count changes between runs of the same query → concurrent writes, or a non-deterministic
LIMITwithoutORDER BY.
Wrong Totals (sums, averages, percentages)
- Sum too high → fan-out (above). Verify by comparing
SUM(x)againstSUM(x) / COUNT(DISTINCT join_key)-shaped intuition, then rewrite with a pre-aggregated CTE. - Average is off →
AVGskips NULLs, so the denominator is not the row count.SUM(x) / COUNT(*)andAVG(x)differ wheneverxis nullable. - Money off by cents →
FLOAT/REALstorage.0.1 + 0.2 != 0.3accumulates over aggregation; the schema needsNUMERIC/DECIMAL(SKILL.md rule 7). - Integer division:
SELECT 1/2is0in PostgreSQL, MySQL, and SQL Server for integer operands. Cast one side:x * 100.0 / y. - Percentages don't sum to 100 → rounding each row instead of the total; round once at the end.
- Totals differ from the dashboard → different timezone boundary for "today" or a soft-delete filter one query applies and the other doesn't.
Query Slow
- Is this query even the problem? Rank by total cost first (SKILL.md rule 9).
EXPLAIN (ANALYZE, BUFFERS): find the node with the largest actual time, not the largest estimate.- Estimated vs actual rows off by more than 10× → stale statistics;
ANALYZEthe table and re-plan. - Sequential scan with a selective filter → the index is missing or disabled (→ Index Ignored).
- Correct index, still slow → the query returns too many rows to matter. A million rows through the network is slow no matter the plan; paginate or aggregate server-side.
- Fast standalone, slow in the app → parameter sniffing, a different search_path/session setting, or lock waiting rather than work (→ Hangs).
- Everything is slow, not one query → server-level: cache hit ratio, connection saturation, or another workload.
"It Was Fast Yesterday"
| Cause | Check |
|---|---|
| Data crossed a size threshold and the plan flipped | Compare current plan against the shape you remember; look for a switch from index scan to seq scan |
| Statistics went stale after a bulk load | ANALYZE, re-plan; PostgreSQL: last_analyze in pg_stat_user_tables |
| An index was dropped or left INVALID by a failed concurrent build | PostgreSQL: SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid |
| Bloat from a long-running transaction | Oldest xact_start in pg_stat_activity |
| A new index made writes slow, not reads | Compare index count against write latency; every index is written on every insert |
| Cache no longer holds the working set | Cache hit ratio dropped below the 99% OLTP threshold |
Someone added a LEFT JOIN for one column |
Read the diff of the query, not just the plan |
| Version upgrade changed the planner | Compare plans across the versions before blaming data |
Index Exists But Is Not Used
Check in this order; the first four cover most cases.
- Function or cast on the column.
WHERE lower(email) = ?,WHERE created_at::date = ?, or an implicit cast from a type mismatch all hide the column. Fix the predicate or add an expression index that matches the query text exactly. - Wrong column order. A composite
(a, b)cannot serveWHERE b = ?(SKILL.md rule 3). - Low selectivity. Matching more than roughly 5-10% of rows, a scan is genuinely cheaper. Confirm with
SELECT COUNT(*) FILTER (WHERE <predicate>) * 100.0 / COUNT(*) FROM t. - Stale stats making the planner think the predicate is unselective.
ANALYZE, retry. ORacross columns, leading wildcardLIKE '%x', or a non-C locale withLIKE 'x%'in PostgreSQL (SKILL.md Index Strategy).- Different collation between the index and the query's comparison (MySQL joins across
utf8mb4_general_ciandutf8mb4_0900_ai_cicannot use the index). - The index is invalid or being built. PostgreSQL
indisvalid = false; MySQLSHOW INDEXCommentcolumn. - Prove it before rewriting: force the choice temporarily (
SET enable_seqscan = offin PostgreSQL,FORCE INDEXin MySQL) and compare actual times. If forcing the index is slower, the planner was right — never ship the hint as the fix.
Query Hangs (no result, no error)
- Is it running or waiting? PostgreSQL
pg_stat_activity.wait_event_type = 'Lock'means waiting; MySQLSHOW PROCESSLISTstateWaiting for table metadata lock. - Waiting → find the blocker (PostgreSQL
pg_blocking_pids(pid), MySQLsys.innodb_lock_waits) and decide: cancel it, or wait if it is nearly done. - Running with no output → it may be working correctly on too much data;
EXPLAINwithoutANALYZEreturns instantly and shows the plan it chose. - Client shows nothing while the server is idle → the result is buffered by the driver, or the app never fetched; check the driver's cursor/fetch mode.
ALTER TABLEhangs → the lock queue, not the DDL. Every new query is now queued behind it: cancel, setlock_timeout, retry.- An open transaction in a REPL or notebook is the most common self-inflicted hang: an uncommitted
BEGINin another window holds the lock.
Deadlock Detected
- The engine already picked a victim and rolled it back; the error is the report, not the failure. Read the log: PostgreSQL logs both statements, MySQL exposes the last one in
SHOW ENGINE INNODB STATUS. - Root cause is almost always inconsistent lock order across two code paths. Fix by ordering (
ORDER BY id FOR UPDATE), not by retrying blindly. - Deadlocks that appear only under load with no obvious two-row pattern → gap/next-key locks in MySQL REPEATABLE READ, or foreign-key locks taken on the parent row.
- Every application that writes concurrently needs a bounded retry on deadlock and serialization errors regardless.
Connection Errors
| Error | Real cause | Move |
|---|---|---|
| "too many connections" / "remaining slots reserved" | App pools × instances exceed the server limit | Size pools from cores, add PgBouncer instead of raising max_connections |
| "connection refused" | Server not listening on that address, or wrong port | Check bind address and port before credentials |
| "no pg_hba.conf entry" / "Host is not allowed" | Host-based auth rules, not a bad password | Fix the auth rule for the client's network |
| "password authentication failed" for one app only | Different role than you tested with, or a rotated secret | Compare the role, not the password |
| "SSL required" / cert errors | Managed providers force TLS | Set the driver's SSL mode; do not disable verification to move on |
| Connections work then die after minutes | Idle timeout in a proxy/load balancer below the pool's max_lifetime |
Set pool max_lifetime under the infrastructure timeout |
| Intermittent failures under load only | Pool exhaustion — waiters timing out, not the database refusing | Instrument pool wait time before touching the server |
Constraint Violations
- Unique violation on insert that "should not exist" → a soft-deleted row still occupies the value, or the unique index is on a different column set than assumed.
- Unique violation under concurrency despite a check-then-insert → the check-then-insert race; use
INSERT ... ON CONFLICT/ON DUPLICATE KEYor catch the violation. - Foreign key violation on delete → children exist; decide
RESTRICTvsCASCADEdeliberately, and confirm the child FK column is indexed (SKILL.md rule 4). - Foreign key violation on insert with a valid-looking id → different tenant, or the parent row was inserted in another uncommitted transaction.
- NOT NULL violation only in production → a default exists in one environment's schema and not the other; diff the schemas, don't reason about them.
- CHECK violation after a data import → the import brought values the constraint never saw; the constraint is right.
Disk Full / Database Won't Accept Writes
- Locate the consumer before deleting anything: table and index sizes ranked descending.
- Common consumers in order: an unpartitioned events/audit/log table, bloat from dead tuples pinned by a long transaction, WAL retained by an inactive replication slot, an abandoned temp/sort spill.
DELETEdoes not return space to the filesystem on PostgreSQL or MySQL/InnoDB — it creates dead rows the vacuum must clean. Dropping a partition does.- Never
DROPto free space during an incident until the backup is verified restorable. - PostgreSQL specifically: an inactive replication slot retains WAL forever and is the classic unannounced disk killer — check
pg_replication_slotsforactive = false.
Encoding and Character Problems
?or????in place of accents/emoji → MySQLutf8(3-byte) instead ofutf8mb4; the column, the table default, AND the client connection charset must all beutf8mb4.- Mojibake (
éforé) → UTF-8 bytes read as Latin-1 somewhere in the chain; find the single wrong layer rather than re-encoding twice. - Import fails on "invalid byte sequence" → the file is not the encoding you declared; detect and convert before loading.
- Sorting looks wrong → collation, not encoding.
ORDER BYfollows the column's collation; changing it after the fact requires reindexing. - Invisible characters (zero-width space, non-breaking space) pasted into data make equality fail with identical-looking strings; compare
length()against the visible character count.
Works In One Client, Fails In Another
| Difference | Check |
|---|---|
| Autocommit on/off | The GUI committed; your script left an open transaction |
Session settings (search_path, sql_mode, timezone) |
Compare SHOW ALL / SHOW VARIABLES between sessions |
| Different role or database | Same host, different privileges: diff the GRANTs and any RLS policy on the table |
| Statement/lock timeouts set per role | A short statement_timeout on the app role only |
| Prepared vs literal statements | The generic plan for a prepared statement can differ from the plan for literals |
| Driver-side type conversion | The driver casts a parameter to a type the index can't use |
Migration Failed Halfway
- Check the runner's state table first — a "dirty" flag means it knows it stopped mid-way; resolve that before rerunning anything.
- MySQL has no transactional DDL: half the statements are already committed. Determine what actually applied by inspecting the schema, not the migration file.
- Re-running a partially applied migration usually fails on "already exists". Write the repair as a NEW migration; never edit an applied one.
- A migration that timed out may still be running server-side — kill the backend before retrying, or the retry deadlocks against it.
- Prevention: run every migration against a restored copy of production-shaped data before it reaches production.
Replica Returns Different Data
- Read-after-write on an async replica misses the write that just committed; route read-your-own-writes traffic to the primary.
- A long analytics query on a replica gets canceled by WAL replay conflicts — that is not a query bug.
- Replica lag rises with zero write traffic on the primary: the time-based lag metric grows unbounded when there is nothing to replay; alert only while writes are flowing.
- Counts differ permanently → the replica broke and is no longer replaying, or someone wrote to it. Compare replay position, not row counts.
When You Are Truly Stuck
Reduce to the smallest reproduction: one table, minimal columns, the fewest rows that still show the behavior, on a scratch copy. Most "impossible" SQL bugs resolve at the moment the reproduction stops reproducing — the step you removed is the cause.