cd /news/developer-tools/rails-postgresql-performance-audit-p… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-61093] src=gist.github.com β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

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.

read16 min views1 publishedJul 15, 2026

| # 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 '%<table_or_keyword>%' | | | ORDER BY total_exec_time DESC LIMIT 10; | | | | | | ## Automate the capture (optional but worth it) | | | Wire your cloud provider's "high DB CPU" alert to a webhook endpoint in your app that | | | enqueues a job running queries A–G read-only, writes them to one text file, uploads it to | | | object storage, and posts the link to Slack. Whoever is on call downloads the file and | | | pastes it plus this playbook into an LLM. Two bonuses: the dump is timestamped evidence from | | | during the incident, and two dumps N minutes apart are a free query-H β€” subtract the | | | total_s columns to get exec-seconds burned per family in the window, no temp table needed. | | | Guard the endpoint with an access key; keep every query read-only. | | | ## Incident-time attribution without APM | | | 1. Two dumps = a delta. As above. Families with huge chronic totals but ~0 Ξ” are | | | historical β€” fixes holding, not today's problem. Don't be fooled by a scary lifetime | | | mean_ms. | | | 2. A request-log table = your poor-man's APM. If you don't have one, build it: one row | | | per request with controller/action, db_duration, query counts, IP, user agent, referer, | | | indexed on time. It answers "which endpoint is burning the DB right now" and | | | "bot or organic?" (dozens of distinct residential IPs β‰ˆ organic; one UA walking your | | | sitemap at dawn β‰ˆ crawler). Query it on the replica so you don't load the pegged primary. | | | 3. Web vs background jobs. application_name distinguishes your web server from your | | | job workers: | | | sql | | | SELECT application_name, | | | count(*) FILTER (WHERE state='active' AND wait_event IS NULL) AS on_cpu | | | FROM pg_stat_activity WHERE backend_type='client backend' | | | GROUP BY 1 ORDER BY on_cpu DESC NULLS LAST; | | | | | | 4. CPU spikes at a fixed minute past the hour = cron. Find the crontab, note the minute | | | it fires and the queue it lands on, and remember your graph's sampling grid can shift the | | | apparent spike a few minutes later. The suspect list for an every-hour spike is closed: | | | only jobs scheduled every hour can be on it. | | | ## Interpretation heuristics | | | - Fingerprint birth-dating: calls Γ· expected-rate estimates when a query shape first | | | appeared. High total_s with few calls relative to the stats window = recent regression β†’ | | | check git log around the inferred birth date. | | | - rows/call ratio: thousands of rows per call = a pluck/materialization that belongs in | | | SQL (MIN/MAX, COUNT FILTER, width_bucket) or a cache. | | | - 0 rows over millions of calls = a guard-clause bug. The recurring Rails pattern: bot | | | traffic yields a nil user/visitor/session, and a find_by(visitor: nil, ...) runs forever | | | matching nothing. Guard before querying. | | | - Mean creep: if a known query's mean rises across audits without a plan change, that's | | | box saturation stretching everything β€” a victim, not a culprit. Attribution rule: a 9Γ— | | | mean rise with a 20Γ— call-rate rise is a new traffic source; a 9Γ— mean rise with flat | | | calls is contention. | | | - t0_r0/ t1_r0 column aliases in a fingerprint = Rails eager_load / | | | includes+ references. Check the call sites for group_by, .sample, .drop, [n] | | | on relations β€” each forces full materialization of the join. | | | - Query β†’ code mapping sequence: grep distinctive SQL fragments, then scopes; check the | | | schema dump for indexes (grep 'ON public.<table>'); fan out one investigation per query | | | family, each returning file:line + mechanism + fix. | | | - Never trust a dev schema dump for extension types. If your dev DBs are built by | | | restoring a prod dump, extension-provided types (e.g. pgvector's vector) can degrade to | | | TEXT and their indexes silently vanish β€” and a schema file regenerated from that DB lies | | | to you. Verify column types and indexes against the production catalog | | | (pg_attribute/ pg_index), never the checked-in structure file. Same for storage | | | parameters (autovacuum flags, fillfactor). | | | ## Recurring root-cause patterns (what we actually keep finding) | | | Every one of these came out of a real audit; check your codebase for each: | | | - Cache keys derived from relation.cache_key (a hash of the full SQL with binds | | | inlined): any per-user/per-filter value in the relation fragments the key space β†’ ~0% hit | | | rate β†’ the cache exists but caches nothing, and the DB eats every render. | | | - Aggressively short cache-store timeouts (e.g. Redis read_timeout: 0.2): latency | | | spikes count as misses, so cache degradation becomes a DB recompute stampede β€” a feedback | | | loop that turns a wobble into an incident. | | | - No-op sync writes: integration jobs writing synced_at/status columns even when | | | nothing changed. Millions of dead tuples, autovacuum churn, WAL volume β€” for zero | | | information. Skip-if-unchanged guards are one line. | | | - counter_cache with an index on the counter column itself: every increment becomes a | | | non-HOT update (new index tuples + heap copy), and concurrent increments serialize on row | | | locks. Usually an EXISTS probe replaces the counter entirely. | | | - after_touch recalculation storms: a parent recalculating aggregates by all | | | children on every touch, amplified by batch jobs touching thousands of parents. Replace | | | with one set-based UPDATE and no_touching around bulk paths. | | | - Materialized view refreshes with no guard rails: refresh lists living in database | | | tables (invisible to code review), refreshed hourly with no statement_timeout, no | | | overlap guard, and non-CONCURRENTLY (ACCESS EXCLUSIVE lock β€” the next run queues behind | | | a stuck one forever). Give every refresh a per-object advisory lock (skip, don't queue), | | | a generous timeout, and version the view definitions in git. And check the refresh order | | | against the dependency chain β€” pipelines refreshed most-derived-first serve data that's | | | hours stale by design. | | | - Catch-all routes running expensive lookups before 404ing: a wildcard route segment | | | (get ":slug") that derives an expensive "is this valid" set on every request pays full | | | price for every crawler probe and dead link. Do the cheap unique-index lookup first; | | | compute the expensive validation only when the record exists. | | | - Unindexed lookups inside cron loops: a scheduled job iterating an external dataset | | | (spreadsheet, API export) doing one find_by per row against an unindexed column. It's | | | invisible at low volume and becomes a nightly CPU plateau as the dataset grows. Batch-load | | | with one where(col: ids) + hash lookup β€” and mind case-insensitive column types | | | (citext): a SQL match is case-insensitive, a Ruby hash key is not. | | | - Bot traffic amplifying all of the above. Check your request logs before blaming code: | | | sanctioned crawlers (search engines, AI bots you allowlisted) walking sitemaps and tag | | | pages at dawn produce load spikes that vanish during human peak hours. Edge-block or | | | challenge what you don't want; cache or cheapen what you do. | | | - The biggest systemic lever is usually replica routing. If your app has a same-sized | | | read replica idling at 15% while the primary serves 100% of reads, Rails' multi-DB | | | connected_to(role: :reading) around stateless public pages is worth more than any single | | | query fix. | | | ## Verifying a fix without breaking production | | | - Restore a recent prod dump locally. For each fix, reconstruct the OLD expression from | | | git show <baseline-sha>:<file> and compare against the NEW code on real data β€” compare | | | ordered id arrays, not booleans ("returns something" hides reordering bugs). | | | - Dev Rails.cache as a NullStore is a feature here: every fetch block executes, so the | | | comparison is cache-free. | | | - Any DB write during verification goes inside | | | transaction { ...; raise ActiveRecord::Rollback }. Never run job workers against a | | | prod-shaped DB β€” integration jobs will make real HTTP calls. | | | - Finish with a server-level HTML diff: run the old and new code against the same local | | | server and data, curl the affected pages, normalize per-request noise (csrf token, nonces, | | | profiler ids), and diff. Byte-identical output is the strongest "same results" proof | | | you can get without deploying. | | | - Watch for the classic behavior-drift traps: eager_load dedup vs array indexing, ORDER BY | | | tie groups (plan-dependent), LIMIT 1 without ORDER BY on duplicated keys (arbitrary row | | | β†’ make it deterministic with .order(:id)), enum String-vs-Symbol comparisons, and | | | .presence/ group_by/ drop/ sample forcing full loads. | | | - Merged β‰  deployed β‰  applied. A merged migration isn't live until deployed; a | | | DB-side change (index, view definition) isn't live until someone runs it. Verify with the | | | catalog (pg_index.indisvalid, pg_matviews.definition), not with git. | | | ## Ops guardrails this method assumes | | | - pg_stat_statements installed and preloaded; reset it after a major traffic change (e.g. | | | blocking a heavy crawler) so the window describes the workload you actually have β€” archive | | | the final B/C first, the cumulative totals are gone forever otherwise. | | | - Statement timeouts on batch/cron connections. One runaway refresh or analytics query | | | should die and alert, not camp on a core for 12 hours. | | | - Every scheduled job's DB work bounded: batch lookups, skip-if-unchanged writes, | | | advisory-lock overlap guards. | | | - Alert β†’ automated diagnostics dump (above), so every incident leaves evidence even if | | | nobody was watching. |

── more in #developer-tools 4 stories Β· sorted by recency
── more on @postgresql 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/rails-postgresql-per…] indexed:0 read:16min 2026-07-15 Β· β€”