Rails + PostgreSQL Performance Audit Playbook A developer published a field-tested playbook for auditing Rails + PostgreSQL performance without APM tools, relying on pg_stat_statements and pg_stat_activity. The method identifies chronic and live database CPU consumers, exposes fragmentation from query fingerprinting, and maps expensive queries back to application code. The playbook pairs well with an LLM for attribution and includes ordered SQL queries for systematic diagnosis. | Rails + PostgreSQL Performance Audit Playbook | | | A field-tested method for finding what's actually burning your database CPU, built for a | | | Rails monolith on managed PostgreSQL Cloud SQL / RDS / etc. . It assumes no APM — just | | | pg stat statements , pg stat activity , and discipline. It pairs well with an LLM: point | | | one at this file plus your query results and let it do the attribution; the queries below | | | are ordered so a human can run them and paste results back. | | | The core idea | | | Your cloud console's query dashboard will not tell you what's wrong. Ground truth lives in | | | two places: pg stat statements cumulative, per-normalized-query and pg stat activity | | | live, per-backend . The method is: establish the measurement window A , read the chronic | | | top consumers B/C , check what's happening right now D–G , and when "chronic" and "now" | | | disagree, measure a delta window H . Then map each expensive query family back to code and | | | fix the mechanism, not the symptom. | | | Why your cloud console lies | | | - Fingerprint fragmentation. Postgres 15 and earlier fingerprint every IN ... list | | | length separately, and Rails' where id: array produces every length. One logical query | | | shatters into hundreds of rows we measured 1,000+ shapes for a single table's lookups — | | | no single row looks scary while their sum saturates the box. PG16's list-squashing helps | | | but doesn't cover everything. Query C below is the antidote. | | | - Short retention. Console per-query views typically keep ~7 days — you cannot | | | time-slice a regression older than that. Fingerprint "birth-dating" below substitutes. | | | - Utility statements are invisible. REFRESH MATERIALIZED VIEW , CREATE INDEX , | | | VACUUM get one queryid per object name or aren't tracked at all, depending on | | | pg stat statements.track utility and never aggregate into a family. A materialized-view | | | refresh can be your single biggest CPU consumer and appear NOWHERE in the top-N. | | | - Only finished statements are recorded. A query that has been running for 12 hours | | | contributes zero to pg stat statements until it completes. Long-running work is only | | | visible in pg stat activity . | | | - Duration includes lock waits. In pg stat activity , a backend blocked on a lock still | | | shows state = 'active' with a growing duration. Always read wait event type : NULL = | | | genuinely on CPU; Lock = a victim queuing behind someone else. A huge duration can be | | | either the culprit or the queue behind it. | | | The queries run in this order, on the primary | | | A — measurement window always first | | | sql | | | SELECT stats reset, now - stats reset AS window FROM pg stat statements info; | | | | | | If the window is months long, B/C describe chronic load, which may not be today's problem. | | | Compute average busy backends: sum total exec time /1000 / extract epoch from window and | | | compare with current CPU N cores pegged ≈ N busy backends . A large gap means today's mix | | | differs from the chronic top-N → rely on H. If the window is only days old, B/C haven't seen | | | a full weekly cycle yet — same conclusion. | | | B — top consumers with % share | | | sql | | | SELECT | | | round 100 total exec time / sum total exec time OVER ::numeric, 1 AS pct, | | | calls, | | | round mean exec time::numeric, 2 AS mean ms, | | | round total exec time::numeric / 1000, 1 AS total s, | | | rows, | | | left regexp replace query, '\s+', ' ', 'g' , 130 AS query | | | FROM pg stat statements | | | ORDER BY total exec time DESC | | | LIMIT 25; | | | | | | C — fragmentation detector collapse by prefix | | | sql | | | SELECT | | | left regexp replace query, '\s+', ' ', 'g' , 60 AS prefix, | | | count AS shapes, | | | sum calls AS calls, | | | round sum total exec time ::numeric/1000, 1 AS total s | | | FROM pg stat statements | | | GROUP BY 1 | | | ORDER BY total s DESC | | | LIMIT 25; | | | | | | High shapes + high total s = IN-list fragmentation; that family is invisible in your | | | console's per-query view. | | | D — live wait profile run 3–4× over ~30s | | | sql | | | SELECT state, wait event type, wait event, count | | | FROM pg stat activity | | | WHERE backend type = 'client backend' | | | GROUP BY 1,2,3 ORDER BY count DESC; | | | | | | active + NULL wait event = on CPU. Many of those on an N-vCPU box = CPU-bound queries | | | not IO or locks . | | | E — maintenance and materialized views in flight | | | sql | | | SELECT pid, wait event type, wait event, now -query start AS dur, left query,80 AS q | | | FROM pg stat activity | | | WHERE pid < pg backend pid | | | AND query ILIKE 'autovacuum:%' OR query ILIKE '%VACUUM%' OR query ILIKE '%ANALYZE%' | | | OR query ILIKE '%MATERIALIZED VIEW%' ; | | | | | | The MATERIALIZED VIEW match exists because of the utility-statement blind spot above — | | | this is the only place a runaway refresh shows up. | | | F — dead-tuple / vacuum pressure | | | sql | | | SELECT relname, n live tup, n dead tup, | | | round 100 n dead tup::numeric/nullif n live tup,0 ,1 AS dead pct, | | | last autovacuum, autovacuum count | | | FROM pg stat user tables | | | ORDER BY n dead tup DESC LIMIT 15; | | | | | | An outlier autovacuum count on one table = write churn. The classic Rails cause is a sync | | | job rewriting rows to the same values no-op writes still create dead tuples . | | | G — live sampler run 4–5×, ~15s apart | | | sql | | | SELECT now - query start AS dur, wait event type, wait event, | | | left regexp replace query, '\s+', ' ', 'g' , 130 AS q | | | FROM pg stat activity | | | WHERE state = 'active' AND pid < pg backend pid | | | ORDER BY dur DESC; | | | | | | Whatever keeps reappearing is what's on CPU right now . Ignore your replica's | | | START REPLICATION row. Remember: NULL wait columns = working; Lock = queued victim. | | | H — snapshot diff gold standard for "what burns CPU today" | | | Same session/tab, the temp table must survive between steps: | | | sql | | | -- Step 1: run now | | | CREATE TEMP TABLE pss snap AS | | | SELECT userid, dbid, queryid, calls, total exec time | | | FROM pg stat statements; | | | | | | Wait ~10 minutes leave the tab open , then: | | | sql | | | -- Step 2: exec-seconds consumed in the window, per query | | | SELECT round p.total exec time - COALESCE s.total exec time, 0 ::numeric / 1000, 1 AS exec s 10min, | | | p.calls - COALESCE s.calls, 0 AS calls 10min, | | | left regexp replace p.query, '\s+', ' ', 'g' , 120 AS q | | | FROM pg stat statements p | | | LEFT JOIN pss snap s USING userid, dbid, queryid | | | WHERE p.total exec time - COALESCE s.total exec time, 0 1000 | | | ORDER BY 1 DESC | | | LIMIT 30; | | | | | | Sanity check: 10 min × N saturated cores ≈ N×600 exec-seconds total. If the top-30 sums to | | | ~80–90% of that, the list explains the load. Anything new-today shows here even if invisible | | | in the cumulative totals. This diff is also independent of when stats were last reset. | | | I — targeted drill-down template | | | sql | | | SELECT calls, round mean exec time::numeric,1 AS mean ms, | | | round total exec time::numeric/1000,1 AS total s, | | | left regexp replace query,'\s+',' ','g' ,120 AS q | | | FROM pg stat statements | | | WHERE query LIKE '%