This repository has no description
66 kB
1746 lines
1create or replace
2function pgfirstaid_pg_stat_statements_checks()
3returns table (
4 severity TEXT,
5 category TEXT,
6 check_name TEXT,
7 object_name TEXT,
8 issue_description TEXT,
9 current_value TEXT,
10 recommended_action TEXT,
11 documentation_link TEXT,
12 severity_order INTEGER
13) as $$
14begin
15 if not exists (
16 select
17 1
18 from
19 pg_extension
20 where
21 extname = 'pg_stat_statements') then
22 return;
23 end if;
24
25 begin
26 return query
27with pss as (
28 select
29 queryid,
30 query,
31 calls,
32 total_exec_time,
33 mean_exec_time,
34 rows
35 from
36 pg_stat_statements
37 where
38 calls > 0
39 order by
40 total_exec_time desc
41 limit 10)
42 select
43 'MEDIUM' as severity,
44 'Query Health' as category,
45 'Top 10 Queries by Total Execution Time' as check_name,
46 'queryid: ' || pss.queryid::text as object_name,
47 'Queries with the highest total execution time are usually the best optimization targets for overall workload improvement' as issue_description,
48 'calls: ' || pss.calls || ', total_exec_time_ms: ' || round(pss.total_exec_time::numeric, 2) ||
49 ', mean_exec_time_ms: ' || round(pss.mean_exec_time::numeric, 2) || ', rows: ' || pss.rows ||
50 ', query: ' || left(regexp_replace(pss.query, E'[\n\r\t]+', ' ', 'g'), 350) as current_value,
51 'Run EXPLAIN (ANALYZE, BUFFERS) and focus on reducing total runtime for these fingerprints first' as recommended_action,
52 'https://www.postgresql.org/docs/current/pgstatstatements.html \
53 https://www.postgresql.org/docs/current/using-explain.html \
54 https://www.tigerdata.com/blog/using-pg-stat-statements-to-optimize-queries' as documentation_link,
55 3 as severity_order
56 from
57 pss;
58
59 return query
60 select
61 'MEDIUM' as severity,
62 'Query Health' as category,
63 'High Mean Execution Time Queries' as check_name,
64 'queryid: ' || pss.queryid::text as object_name,
65 'Queries with high average runtime and enough call volume are underperforming and likely user-visible' as issue_description,
66 'calls: ' || pss.calls || ', mean_exec_time_ms: ' || round(pss.mean_exec_time::numeric, 2) ||
67 ', total_exec_time_ms: ' || round(pss.total_exec_time::numeric, 2) ||
68 ', query: ' || left(regexp_replace(pss.query, E'[\n\r\t]+', ' ', 'g'), 350) as current_value,
69 'Add or improve indexes and rewrite query predicates to reduce per-execution latency' as recommended_action,
70 'https://www.postgresql.org/docs/current/pgstatstatements.html \
71 https://www.postgresql.org/docs/current/using-explain.html \
72 https://www.tigerdata.com/blog/using-pg-stat-statements-to-optimize-queries' as documentation_link,
73 3 as severity_order
74 from
75 pg_stat_statements pss
76 where
77 pss.calls >= 20
78 and pss.mean_exec_time > 100
79 order by
80 pss.mean_exec_time desc
81 limit 10;
82
83 return query
84with pss as (
85 select
86 queryid,
87 query,
88 calls,
89 temp_blks_read,
90 temp_blks_written,
91 total_exec_time
92 from
93 pg_stat_statements
94 where
95 (temp_blks_read + temp_blks_written) > 0
96 order by
97 (temp_blks_read + temp_blks_written) desc
98 limit 10)
99 select
100 'MEDIUM' as severity,
101 'Query Health' as category,
102 'Top 10 Queries by Temp Block Spills' as check_name,
103 'queryid: ' || pss.queryid::text as object_name,
104 'Frequent temp block usage points to sort or hash operations spilling to disk and causing avoidable latency' as issue_description,
105 'calls: ' || pss.calls || ', temp_blks_read: ' || pss.temp_blks_read ||
106 ', temp_blks_written: ' || pss.temp_blks_written || ', total_exec_time_ms: ' ||
107 round(pss.total_exec_time::numeric, 2) || ', query: ' || left(regexp_replace(pss.query, E'[\n\r\t]+', ' ', 'g'), 350) as current_value,
108 'Reduce row width, improve index support for sort or group patterns, and tune work_mem cautiously' as recommended_action,
109 'https://www.postgresql.org/docs/current/pgstatstatements.html \
110 https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM \
111 https://www.tigerdata.com/blog/using-pg-stat-statements-to-optimize-queries' as documentation_link,
112 3 as severity_order
113 from
114 pss;
115
116 return query
117 select
118 'MEDIUM' as severity,
119 'Query Health' as category,
120 'Low Cache Hit Ratio Queries' as check_name,
121 'queryid: ' || pss.queryid::text as object_name,
122 'Low buffer cache hit ratio indicates heavy physical reads and likely missing indexes or poor filtering' as issue_description,
123 'calls: ' || pss.calls || ', cache_hit_pct: ' || round(
124 100.0 * pss.shared_blks_hit / NULLIF(pss.shared_blks_hit + pss.shared_blks_read, 0),
125 2
126 ) || ', shared_blks_read: ' || pss.shared_blks_read || ', shared_blks_hit: ' || pss.shared_blks_hit ||
127 ', query: ' || left(regexp_replace(pss.query, E'[\n\r\t]+', ' ', 'g'), 350) as current_value,
128 'Prioritize index tuning and query filtering to reduce disk reads for these statements' as recommended_action,
129 'https://www.postgresql.org/docs/current/pgstatstatements.html \
130 https://www.postgresql.org/docs/current/using-explain.html \
131 https://www.tigerdata.com/blog/using-pg-stat-statements-to-optimize-queries' as documentation_link,
132 3 as severity_order
133 from
134 pg_stat_statements pss
135 where
136 pss.calls >= 20
137 and (pss.shared_blks_hit + pss.shared_blks_read) > 0
138 and (100.0 * pss.shared_blks_hit / NULLIF(pss.shared_blks_hit + pss.shared_blks_read, 0)) < 90
139 order by
140 (100.0 * pss.shared_blks_hit / NULLIF(pss.shared_blks_hit + pss.shared_blks_read, 0)) asc
141 limit 10;
142
143 return query
144 select
145 'MEDIUM' as severity,
146 'Query Health' as category,
147 'High Runtime Variance Queries' as check_name,
148 'queryid: ' || pss.queryid::text as object_name,
149 'High runtime variance can indicate plan instability, skewed data distribution, or parameter sensitivity' as issue_description,
150 'calls: ' || pss.calls || ', mean_exec_time_ms: ' || round(pss.mean_exec_time::numeric, 2) ||
151 ', stddev_exec_time_ms: ' || round(pss.stddev_exec_time::numeric, 2) ||
152 ', total_exec_time_ms: ' || round(pss.total_exec_time::numeric, 2) || ', query: ' ||
153 left(regexp_replace(pss.query, E'[\n\r\t]+', ' ', 'g'), 350) as current_value,
154 'Check plan stability with EXPLAIN (ANALYZE, BUFFERS), update statistics, and review parameterized execution paths' as recommended_action,
155 'https://www.postgresql.org/docs/current/pgstatstatements.html \
156 https://www.postgresql.org/docs/current/routine-vacuuming.html \
157 https://www.postgresql.org/docs/current/using-explain.html' as documentation_link,
158 3 as severity_order
159 from
160 pg_stat_statements pss
161 where
162 pss.calls >= 20
163 and pss.stddev_exec_time > pss.mean_exec_time
164 order by
165 pss.stddev_exec_time desc
166 limit 10;
167
168 return query
169 select
170 'MEDIUM' as severity,
171 'Query Health' as category,
172 'High Calls Low Value Queries' as check_name,
173 'queryid: ' || pss.queryid::text as object_name,
174 'Very high call volume with low per-call value can create avoidable overhead and crowd out expensive work' as issue_description,
175 'calls: ' || pss.calls || ', mean_exec_time_ms: ' || round(pss.mean_exec_time::numeric, 3) ||
176 ', total_exec_time_ms: ' || round(pss.total_exec_time::numeric, 2) ||
177 ', rows_per_call: ' || round((pss.rows::numeric / NULLIF(pss.calls, 0)), 2) ||
178 ', query: ' || left(regexp_replace(pss.query, E'[\n\r\t]+', ' ', 'g'), 350) as current_value,
179 'Batch repeated requests, cache stable lookups, and reduce N+1 query patterns in the application layer' as recommended_action,
180 'https://www.postgresql.org/docs/current/pgstatstatements.html \
181 https://www.tigerdata.com/blog/using-pg-stat-statements-to-optimize-queries' as documentation_link,
182 3 as severity_order
183 from
184 pg_stat_statements pss
185 where
186 pss.calls >= 5000
187 and pss.mean_exec_time <= 2
188 and (pss.rows::numeric / NULLIF(pss.calls, 0)) <= 2
189 order by
190 pss.calls desc
191 limit 10;
192
193 return query
194 select
195 'MEDIUM' as severity,
196 'Query Health' as category,
197 'High Rows Per Call Queries' as check_name,
198 'queryid: ' || pss.queryid::text as object_name,
199 'High rows returned per execution often indicates over-fetching or missing selective filters' as issue_description,
200 'calls: ' || pss.calls || ', rows_per_call: ' || round((pss.rows::numeric / NULLIF(pss.calls, 0)), 2) ||
201 ', total_rows: ' || pss.rows || ', mean_exec_time_ms: ' || round(pss.mean_exec_time::numeric, 2) ||
202 ', query: ' || left(regexp_replace(pss.query, E'[\n\r\t]+', ' ', 'g'), 350) as current_value,
203 'Add tighter predicates, pagination, and narrower SELECT lists to reduce unnecessary row transfer' as recommended_action,
204 'https://www.postgresql.org/docs/current/pgstatstatements.html \
205 https://www.postgresql.org/docs/current/queries-limit.html \
206 https://www.tigerdata.com/blog/using-pg-stat-statements-to-optimize-queries' as documentation_link,
207 3 as severity_order
208 from
209 pg_stat_statements pss
210 where
211 pss.calls >= 20
212 and (pss.rows::numeric / NULLIF(pss.calls, 0)) > 10000
213 order by
214 (pss.rows::numeric / NULLIF(pss.calls, 0)) desc
215 limit 10;
216
217 return query
218 select
219 'MEDIUM' as severity,
220 'Query Health' as category,
221 'High Shared Block Reads Per Call Queries' as check_name,
222 'queryid: ' || pss.queryid::text as object_name,
223 'High shared block reads per call usually points to heavy table or index scans and poor locality' as issue_description,
224 'calls: ' || pss.calls || ', shared_blks_read_per_call: ' || round((pss.shared_blks_read::numeric / NULLIF(pss.calls, 0)), 2) ||
225 ', shared_blks_read: ' || pss.shared_blks_read || ', mean_exec_time_ms: ' || round(pss.mean_exec_time::numeric, 2) ||
226 ', query: ' || left(regexp_replace(pss.query, E'[\n\r\t]+', ' ', 'g'), 350) as current_value,
227 'Use EXPLAIN (ANALYZE, BUFFERS) to add selective indexes and reduce pages read per execution' as recommended_action,
228 'https://www.postgresql.org/docs/current/pgstatstatements.html \
229 https://www.postgresql.org/docs/current/using-explain.html \
230 https://www.tigerdata.com/blog/using-pg-stat-statements-to-optimize-queries' as documentation_link,
231 3 as severity_order
232 from
233 pg_stat_statements pss
234 where
235 pss.calls >= 20
236 and (pss.shared_blks_read::numeric / NULLIF(pss.calls, 0)) > 1000
237 order by
238 (pss.shared_blks_read::numeric / NULLIF(pss.calls, 0)) desc
239 limit 10;
240
241 return query
242 select
243 'MEDIUM' as severity,
244 'Query Health' as category,
245 'Top Queries by WAL Bytes Per Call' as check_name,
246 'queryid: ' || pss.queryid::text as object_name,
247 'High WAL generation per execution can indicate heavy write amplification and expensive update patterns' as issue_description,
248 'calls: ' || pss.calls || ', wal_bytes_per_call: ' || round(
249 ((to_jsonb(pss)->>'wal_bytes')::numeric / NULLIF(pss.calls, 0)),
250 2
251 ) || ', wal_bytes_total: ' || round((to_jsonb(pss)->>'wal_bytes')::numeric, 2) ||
252 ', mean_exec_time_ms: ' || round(pss.mean_exec_time::numeric, 2) ||
253 ', query: ' || left(regexp_replace(pss.query, E'[\n\r\t]+', ' ', 'g'), 350) as current_value,
254 'Reduce row churn, batch writes where possible, and review index maintenance cost for heavy write queries' as recommended_action,
255 'https://www.postgresql.org/docs/current/pgstatstatements.html \
256 https://www.postgresql.org/docs/current/wal-intro.html \
257 https://www.tigerdata.com/blog/using-pg-stat-statements-to-optimize-queries' as documentation_link,
258 3 as severity_order
259 from
260 pg_stat_statements pss
261 where
262 pss.calls >= 20
263 and coalesce((to_jsonb(pss)->>'wal_bytes')::numeric, 0) > 0
264 and ((to_jsonb(pss)->>'wal_bytes')::numeric / NULLIF(pss.calls, 0)) > 1048576
265 order by
266 ((to_jsonb(pss)->>'wal_bytes')::numeric / NULLIF(pss.calls, 0)) desc
267 limit 10;
268 exception when object_not_in_prerequisite_state then
269 return;
270 end;
271end;
272$$ language plpgsql;
273
274-- Helper: returns formatted checkpoint stats compatible with PG15/16 (pg_stat_bgwriter)
275-- and PG17+ (pg_stat_checkpointer, which replaced the checkpoint columns in pg_stat_bgwriter)
276create or replace
277function _pg_firstaid_checkpoint_stats()
278returns text
279language plpgsql
280stable
281as $$
282declare
283 v_timed bigint;
284 v_forced bigint;
285begin
286 if current_setting('server_version_num')::int >= 170000 then
287 select num_timed, num_requested
288 into v_timed, v_forced
289 from pg_stat_checkpointer;
290 else
291 select checkpoints_timed, checkpoints_req
292 into v_timed, v_forced
293 from pg_stat_bgwriter;
294 end if;
295
296 return 'timed: ' || v_timed::text ||
297 ', forced: ' || v_forced::text ||
298 ', forced ratio: ' ||
299 case
300 when v_timed + v_forced = 0 then '0%'
301 else round(100.0 * v_forced / (v_timed + v_forced), 1)::text || '%'
302 end;
303end;
304$$;
305
306create or replace
307function pg_firstAid()
308returns table (
309 severity TEXT,
310 category TEXT,
311 check_name TEXT,
312 object_name TEXT,
313 issue_description TEXT,
314 current_value TEXT,
315 recommended_action TEXT,
316 documentation_link TEXT
317) as $$
318begin
319-- Create temporary table to collect all health check results
320 create temp table health_results (
321 severity TEXT,
322 category TEXT,
323 check_name TEXT,
324 object_name TEXT,
325 issue_description TEXT,
326 current_value TEXT,
327 recommended_action TEXT,
328 documentation_link TEXT,
329 severity_order INTEGER
330 );
331-- CRITICAL: Tables without primary keys
332 insert
333 into
334 health_results
335 select
336 'CRITICAL' as severity,
337 'Table Health' as category,
338 'Missing Primary Key' as check_name,
339 quote_ident(pt.schemaname) || '.' || quote_ident(tablename) as object_name,
340 'Table missing a primary key, which can cause replication issues and/or poor performance' as issue_description,
341 'No primary key defined' as current_value,
342 'Add a primary key or unique constraint with NOT NULL columns' as recommended_action,
343 'https://www.postgresql.org/docs/current/ddl-constraints.html' as documentation_link,
344 1 as severity_order
345from
346 pg_tables pt
347where
348 pt.schemaname not like all(array['information_schema', 'pg_catalog', 'pg_toast', 'pg_temp%'])
349 and not exists (
350 select
351 1
352 from
353 pg_constraint pc
354 join pg_class c on
355 pc.conrelid = c.oid
356 join pg_namespace n on
357 c.relnamespace = n.oid
358 where
359 pc.contype = 'p'
360 and n.nspname = pt.schemaname
361 and c.relname = pt.tablename
362 );
363-- CRITICAL: Unused indexes consuming significant space
364 insert
365 into
366 health_results
367 select
368 'CRITICAL' as severity,
369 'Table Health' as category,
370 'Unused Large Index' as check_name,
371 quote_ident(psi.schemaname) || '.' || quote_ident(psio.indexrelname) as object_name,
372 'Large unused index consuming disk space and potentially impacting write performance' as issue_description,
373 pg_size_pretty(pg_relation_size(psi.indexrelid)) || ' (0 scans)' as current_value,
374 'Consider dropping this index if truly unused after monitoring usage patterns. Never drop an index without validating usage!' as recommended_action,
375 'https://www.postgresql.org/docs/current/sql-dropindex.html' as documentation_link,
376 1 as severity_order
377from
378 pg_stat_user_indexes psi
379join pg_statio_user_indexes psio on
380 psi.indexrelid = psio.indexrelid
381where
382 idx_scan = 0
383 and pg_relation_size(psi.indexrelid) > 104857600;
384-- 100MB
385-- HIGH: Inactive Replication slots
386 insert
387 into
388 health_results
389with q as (
390 select
391 slot_name,
392 plugin,
393 database,
394 restart_lsn,
395 case
396 when active is true then 'active'
397 else 'inactive'
398 end as "status",
399 pg_size_pretty(
400 pg_wal_lsn_diff(
401 pg_current_wal_lsn(), restart_lsn)) as "retained_wal",
402 pg_size_pretty(safe_wal_size) as "safe_wal_size"
403 from
404 pg_replication_slots
405 where
406 active = false
407 )
408 select
409 'HIGH' as severity,
410 'Replication Health' as category,
411 'Inactive Replication Slots' as check_name,
412 'Slot name:' || slot_name as object_name,
413 'Target replication slot is inactive' as issue_description,
414 'Retained wal:' || retained_wal || ' database:' || database as current_value,
415 'If the replication slot is no longer needed, drop the slot' as recommended_action,
416 'https://www.morling.dev/blog/mastering-postgres-replication-slots' as documentation_link,
417 2 as severity_order
418from
419 q
420order by
421 slot_name;
422-- credit: https://www.morling.dev/blog/mastering-postgres-replication-slots/ -- Thank you Gunnar Morling!
423-- HIGH: Tables with high bloat
424insert
425 into
426 health_results
427with q as (
428 select
429 current_database(),
430 schemaname,
431 tblname,
432 bs * tblpages as real_size,
433 (tblpages-est_tblpages)* bs as extra_size,
434 case
435 when tblpages > 0
436 and tblpages - est_tblpages > 0
437 then 100 * (tblpages - est_tblpages)/ tblpages::float
438 else 0
439 end as extra_pct,
440 fillfactor,
441 case
442 when tblpages - est_tblpages_ff > 0
443 then (tblpages-est_tblpages_ff)* bs
444 else 0
445 end as bloat_size,
446 case
447 when tblpages > 0
448 and tblpages - est_tblpages_ff > 0
449 then 100 * (tblpages - est_tblpages_ff)/ tblpages::float
450 else 0
451 end as bloat_pct,
452 is_na
453 from
454 (
455 select
456 ceil( reltuples / ( (bs-page_hdr)/ tpl_size ) ) + ceil( toasttuples / 4 ) as est_tblpages,
457 ceil( reltuples / ( (bs-page_hdr)* fillfactor /(tpl_size * 100) ) ) + ceil( toasttuples / 4 ) as est_tblpages_ff,
458 tblpages,
459 fillfactor,
460 bs,
461 tblid,
462 schemaname,
463 tblname,
464 heappages,
465 toastpages,
466 is_na
467 from
468 (
469 select
470 ( 4 + tpl_hdr_size + tpl_data_size + (2 * ma)
471 - case
472 when tpl_hdr_size%ma = 0 then ma
473 else tpl_hdr_size%ma
474 end
475 - case
476 when ceil(tpl_data_size)::int%ma = 0 then ma
477 else ceil(tpl_data_size)::int%ma
478 end
479 ) as tpl_size,
480 bs - page_hdr as size_per_block,
481 (heappages + toastpages) as tblpages,
482 heappages,
483 toastpages,
484 reltuples,
485 toasttuples,
486 bs,
487 page_hdr,
488 tblid,
489 schemaname,
490 tblname,
491 fillfactor,
492 is_na
493 from
494 (
495 select
496 tbl.oid as tblid,
497 ns.nspname as schemaname,
498 tbl.relname as tblname,
499 tbl.reltuples,
500 tbl.relpages as heappages,
501 coalesce(toast.relpages, 0) as toastpages,
502 coalesce(toast.reltuples, 0) as toasttuples,
503 coalesce(substring(
504 array_to_string(tbl.reloptions, ' ')
505 from 'fillfactor=([0-9]+)')::smallint, 100) as fillfactor,
506 current_setting('block_size')::numeric as bs,
507 case
508 when version()~ 'mingw32'
509 or version()~ '64-bit|x86_64|ppc64|ia64|amd64' then 8
510 else 4
511 end as ma,
512 24 as page_hdr,
513 23 + case
514 when MAX(coalesce(s.null_frac, 0)) > 0 then ( 7 + count(s.attname) ) / 8
515 else 0::int
516 end
517 + case
518 when bool_or(att.attname = 'oid' and att.attnum < 0) then 4
519 else 0
520 end as tpl_hdr_size,
521 sum( (1-coalesce(s.null_frac, 0)) * coalesce(s.avg_width, 0) ) as tpl_data_size,
522 bool_or(att.atttypid = 'pg_catalog.name'::regtype)
523 or sum(case when att.attnum > 0 then 1 else 0 end) <> count(s.attname) as is_na
524 from
525 pg_attribute as att
526 join pg_class as tbl on
527 att.attrelid = tbl.oid
528 join pg_namespace as ns on
529 ns.oid = tbl.relnamespace
530 left join pg_stats as s on
531 s.schemaname = ns.nspname
532 and s.tablename = tbl.relname
533 and s.inherited = false
534 and s.attname = att.attname
535 left join pg_class as toast on
536 tbl.reltoastrelid = toast.oid
537 where
538 not att.attisdropped
539 and tbl.relkind in ('r', 'm')
540 group by
541 1,
542 2,
543 3,
544 4,
545 5,
546 6,
547 7,
548 8,
549 9,
550 10
551 order by
552 2,
553 3
554 ) as s
555 ) as s2
556) as s3)
557select
558 'HIGH' as severity,
559 'Table Health' as category,
560 'Table Bloat (Detailed)' as check_name,
561 quote_ident(schemaname) || '.' || quote_ident(tblname) as object_name,
562 'Table has significant bloat (>50%) affecting performance and storage' as issue_description,
563 'Real size: ' || pg_size_pretty(real_size::bigint) ||
564 ', Bloat: ' || pg_size_pretty(bloat_size::bigint) ||
565 ' (' || ROUND(bloat_pct::numeric, 2) || '%)' as current_value,
566 'Run VACUUM FULL to reclaim space' as recommended_action,
567 'https://www.postgresql.org/docs/current/sql-vacuum.html,
568 https://github.com/ioguix/pgsql-bloat-estimation/' as documentation_link,
569 2 as severity_order
570from
571 q
572where
573 bloat_pct > 50.0
574 and bloat_size > pg_size_bytes('10 MB')
575 and schemaname not like all(array['information_schema', 'pg_catalog', 'pg_toast', 'pg_temp%'])
576order by
577 quote_ident(schemaname),
578 quote_ident(tblname);
579--Credit: https://github.com/ioguix/pgsql-bloat-estimation -- Jehan-Guillaume (ioguix) de Rorthais!
580-- HIGH: Tables never analyzed
581 insert
582 into
583 health_results
584 select
585 'HIGH' as severity,
586 'Table Health' as category,
587 'Missing Statistics' as check_name,
588 quote_ident(schemaname) || '.' || quote_ident(relname) as object_name,
589 'Table has never been analyzed, query planner missing statistics' as issue_description,
590 'Last analyze: Never' as current_value,
591 'Run ANALYZE on this table or enable auto-analyze' as recommended_action,
592 'https://www.postgresql.org/docs/current/sql-analyze.html' as documentation_link,
593 2 as severity_order
594from
595 pg_stat_user_tables pt
596where
597 last_analyze is null
598 and last_autoanalyze is null
599 and n_tup_ins + n_tup_upd + n_tup_del > 1000;
600-- HIGH: Tables larger than 100GB
601with ts as (
602select
603 table_schema,
604 table_name,
605 pg_relation_size('"' || table_schema || '"."' || table_name || '"') as size_bytes,
606 pg_size_pretty(pg_relation_size('"' || table_schema || '"."' || table_name || '"')) as size_pretty
607from
608 information_schema.tables
609where
610 table_type = 'BASE TABLE'
611 and pg_relation_size('"' || table_schema || '"."' || table_name || '"') > 107374182400
612 -- 100GB in bytes
613order by
614 size_bytes desc)
615 insert
616 into
617 health_results
618 select
619 'HIGH' as severity,
620 'Table Health' as category,
621 'Tables larger than 100GB' as check_name,
622 ts.table_schema || '"."' || ts.table_name as object_name,
623 'The following table' as description,
624 ts.size_pretty as current_value,
625 'I suggest looking into partitioning tables. Do you need all of this data? Can some of it be archived into something like S3?' as recommended_action,
626 'https://www.heroku.com/blog/handling-very-large-tables-in-postgres-using-partitioning/' as documentation_link,
627 2 as severity_order
628from
629 ts;
630-- HIGH: Duplicate or redundant indexes
631-- Compare actual index structure (columns, operator class) not string definitions
632 insert
633 into
634 health_results
635 select
636 'HIGH' as severity,
637 'Table Health' as category,
638 'Duplicate Index' as check_name,
639 quote_ident(n1.nspname) || '.' || c1.relname || ': ' || i1.relname || ' & ' || i2.relname as object_name,
640 'Multiple indexes with identical column sets and operator classes' as issue_description,
641 'Indexes: ' || i1.relname || ', ' || i2.relname as current_value,
642 'Review and consolidate duplicate indexes and focus on keeping the most efficient one' as recommended_action,
643 'https://www.postgresql.org/docs/current/indexes-multicolumn.html' as documentation_link,
644 2 as severity_order
645from
646 pg_index idx1
647join pg_class i1 on idx1.indexrelid = i1.oid
648join pg_class c1 on idx1.indrelid = c1.oid
649join pg_namespace n1 on c1.relnamespace = n1.oid
650join pg_index idx2 on
651 idx1.indrelid = idx2.indrelid -- same table
652 and idx1.indexrelid < idx2.indexrelid -- avoid duplicates
653 and idx1.indkey = idx2.indkey -- same columns
654 and idx1.indclass = idx2.indclass -- same operator classes
655 and idx1.indoption = idx2.indoption -- same options
656join pg_class i2 on idx2.indexrelid = i2.oid
657where
658 n1.nspname not like all(array['information_schema', 'pg_catalog', 'pg_toast', 'pg_temp%']);
659-- HIGH: Table with more than 200 columns
660with cc as (
661select
662 table_schema,
663 table_name,
664 COUNT(*) as column_count
665from
666 information_schema.columns
667where
668 table_schema not in ('pg_catalog', 'information_schema')
669group by
670 table_schema,
671 table_name
672order by
673 column_count desc)
674insert
675 into
676 health_results
677select
678 'HIGH' as severity,
679 'Table Health' as category,
680 'Table with more than 200 columns' as check_name,
681 cc.table_schema || '.' || cc.table_name as object_name,
682 'Postgres has a hard 1600 column limit, but that also includes columns you have dropped. Continuing to widen your table can impact performance.' as issue_description,
683 cc.column_count as current_value,
684 'Yikes-it is about time you put a hard stop on widing your tables and begin breaking this table into several tables. I once worked on a table with over 300 columns before.......' as recommended_action,
685 'https://www.tigerdata.com/learn/designing-your-database-schema-wide-vs-narrow-postgres-tables \
686 https://nerderati.com/postgresql-tables-can-have-at-most-1600-columns \
687 https://www.postgresql.org/docs/current/limits.html' as documentation_link,
688 2 as severity_order
689from
690 cc
691where
692 cc.column_count > 200;
693-- MEDIUM: Blocked and Blocking Queries
694with bq as (
695select
696 blocked.pid as blocked_pid,
697 blocked.query as blocked_query,
698 blocking.pid as blocking_pid,
699 blocking.query as blocking_query,
700 now() - blocked.query_start as blocked_duration
701from
702 pg_locks blocked_locks
703join pg_stat_activity blocked on
704 blocked.pid = blocked_locks.pid
705join pg_locks blocking_locks
706on
707 blocking_locks.transactionid = blocked_locks.transactionid
708 and blocking_locks.pid != blocked_locks.pid
709join pg_stat_activity blocking on
710 blocking.pid = blocking_locks.pid
711where
712 not blocked_locks.granted)
713insert
714 into
715 health_results
716select
717 'MEDIUM' as severity,
718 'Query Health' as category,
719 'Current Blocked/Blocking Queries' as check_name,
720 'Blocked PID: ' || bq.blocked_pid || chr(10) ||
721 'Blocked Query: ' || bq.blocked_query as object_name,
722 'The following query is being blocked by an already running query' as issue_description,
723 'Blocking PID: ' || bq.blocking_pid || chr(10) ||
724 'Blocking Query: ' || bq.blocking_query as current_value,
725 'Blocked queries are part of concurrency behavior. However, it is always recommended to monitor long running blocking queries. The Crunchy Data article recommended has an excellent walk through and suggested steps on how to tackle unnecessary blocking queries' as recommended_action,
726 'https://www.postgresql.org/docs/current/explicit-locking.html' as documentation_link,
727 3 as severity_order
728from
729 bq;
730-- MEDIUM: Tables with outdated statistics
731insert
732 into
733 health_results
734 with s as (
735 select
736 current_setting('autovacuum_analyze_scale_factor')::float8 as analyze_factor,
737 current_setting('autovacuum_analyze_threshold')::float8 as analyze_threshold,
738 current_setting('autovacuum_vacuum_scale_factor')::float8 as vacuum_factor,
739 current_setting('autovacuum_vacuum_threshold')::float8 as vacuum_threshold
740 ),
741 tt as (
742 select
743 n.nspname,
744 c.relname,
745 c.oid as relid,
746 t.n_dead_tup,
747 t.n_mod_since_analyze,
748 c.reltuples * s.vacuum_factor + s.vacuum_threshold as v_threshold,
749 c.reltuples * s.analyze_factor + s.analyze_threshold as a_threshold
750 from
751 s,
752 pg_class c
753 join pg_namespace n on
754 c.relnamespace = n.oid
755 join pg_stat_all_tables t on
756 c.oid = t.relid
757 where
758 c.relkind = 'r'
759 and n.nspname not like all(array['information_schema', 'pg_catalog', 'pg_toast', 'pg_temp%'])
760 )
761 select
762 'MEDIUM' as severity,
763 'Table Health' as category,
764 'Outdated Statistics' as check_name,
765 quote_ident(nspname) || '.' || quote_ident(relname) as object_name,
766 'Table statistics are outdated, which can lead to poor query plans' as issue_description,
767 'Dead tuples: ' || n_dead_tup || ' (threshold: ' || round(v_threshold) || '), ' ||
768 'Modifications since analyze: ' || n_mod_since_analyze || ' (threshold: ' || round(a_threshold) || ')' as current_value,
769 case
770 when n_dead_tup > v_threshold
771 and n_mod_since_analyze > a_threshold then 'Run VACUUM ANALYZE'
772 when n_dead_tup > v_threshold then 'Run VACUUM'
773 when n_mod_since_analyze > a_threshold then 'Run ANALYZE'
774 end as recommended_action,
775 'https://www.postgresql.org/docs/current/routine-vacuuming.html,
776 https://www.depesz.com/2020/01/29/which-tables-should-be-auto-vacuumed-or-auto-analyzed/' as documentation_link,
777 3 as severity_order
778from
779 tt
780where
781 n_dead_tup > v_threshold
782 or n_mod_since_analyze > a_threshold
783order by
784 nspname,
785 relname;
786-- credit: https://www.depesz.com/2020/01/29/which-tables-should-be-auto-vacuumed-or-auto-analyzed -- Thanks depesz!
787-- MEDIUM: Low index usage efficiency
788 insert
789 into
790 health_results
791 select
792 'MEDIUM' as severity,
793 'Table Health' as category,
794 'Low Index Efficiency' as check_name,
795 quote_ident(schemaname) || '.' || quote_ident(indexrelname) as object_name,
796 'Index has low scan to tuple read ratio indicating poor selectivity' as issue_description,
797 'Scans: ' || idx_scan || ', Tuples: ' || idx_tup_read ||
798 ' (Ratio: ' || ROUND(idx_tup_read::numeric / nullif(idx_scan, 0), 2) || ')' as current_value,
799 'Review index definition and query patterns, consider partial indexes' as recommended_action,
800 'https://www.postgresql.org/docs/current/indexes-partial.html' as documentation_link,
801 3 as severity_order
802from
803 pg_stat_user_indexes psi
804where
805 idx_scan > 100
806 and idx_tup_read::numeric / nullif(idx_scan, 0) > 1000;
807-- MEDIUM: Replication slots with high wal retation (90% of max wal)
808insert
809 into
810 health_results
811with q as (
812 select
813 slot_name,
814 plugin,
815 database,
816 restart_lsn,
817 case
818 when active is true then 'active'
819 else 'inactive'
820 end as "status",
821 pg_size_pretty(
822 pg_wal_lsn_diff(
823 pg_current_wal_lsn(), restart_lsn)) as "retained_wal",
824 pg_size_pretty(safe_wal_size) as "safe_wal_size"
825 from
826 pg_replication_slots
827 where
828 pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) >= (safe_wal_size * 0.9)
829)
830select
831 'MEDIUM' as severity,
832 'Replication Health' as category,
833 'Replication Slots Near Max Wal Size' as check_name,
834 'Slot name:' || slot_name as object_name,
835 'Target replication slot has retained close to 90% of the max wal size' as issue_description,
836 'Retained wal:' || retained_wal || ' safe_wal_size:' || safe_wal_size as current_value,
837 'Consider implementing a heartbeat table or using pg_logical_emit_message()' as recommended_action,
838 'https://www.morling.dev/blog/mastering-postgres-replication-slots' as documentation_link,
839 3 as severity_order
840from
841 q
842order by
843 slot_name;
844-- MEDIUM: Large sequential scans
845 insert
846 into
847 health_results
848 select
849 'MEDIUM' as severity,
850 'Query Health' as category,
851 'Excessive Sequential Scans' as check_name,
852 quote_ident(schemaname) || '.' || quote_ident(relname) as object_name,
853 'Table has high sequential scan activity, may benefit from additional indexes' as issue_description,
854 'Sequential scans: ' || seq_scan || ', Tuples read: ' || seq_tup_read as current_value,
855 'Analyze query patterns and consider adding appropriate indexes' as recommended_action,
856 'https://www.postgresql.org/docs/current/using-explain.html' as documentation_link,
857 3 as severity_order
858from
859 pg_stat_user_tables
860where
861 seq_scan > 1000
862 and seq_tup_read > seq_scan * 10000;
863-- MEDIUM: Table with more than 50 columns
864with cc as (
865select
866 table_schema,
867 table_name,
868 COUNT(*) as column_count
869from
870 information_schema.columns tc
871where
872 table_schema not in ('pg_catalog', 'information_schema')
873group by
874 table_schema,
875 table_name
876order by
877 column_count desc)
878insert
879 into
880 health_results
881select
882 'MEDIUM' as severity,
883 'Table Health' as category,
884 'Table with more than 50 columns' as check_name,
885 cc.table_schema || '.' || cc.table_name as object_name,
886 'Postgres has a hard 1600 column limit, but that also includes columns you have dropped. Continuing to widen your table can impact performance.' as issue_description,
887 cc.column_count as current_value,
888 'The most straightforward recommendation is to split your table into more tables connected via foreign keys. However, your situation may very based on the type of data stored. Consider the documentation links to learn more.' as recommended_action,
889 'https://www.tigerdata.com/learn/designing-your-database-schema-wide-vs-narrow-postgres-tables \
890 https://nerderati.com/postgresql-tables-can-have-at-most-1600-columns \
891 https://www.postgresql.org/docs/current/limits.html' as documentation_link,
892 3 as severity_order
893from
894 cc
895where
896 cc.column_count between 50 and 199;
897-- MEDIUM: Connection and lock monitoring
898 insert
899 into
900 health_results
901 select
902 'MEDIUM' as severity,
903 'System Health' as category,
904 'High Connection Count' as check_name,
905 'Database Connections' as object_name,
906 'High number of active connections may impact performance' as issue_description,
907 COUNT(*)::text || ' active connections' as current_value,
908 'Monitor connection pooling and consider adjusting max_connections' as recommended_action,
909 'https://www.postgresql.org/docs/current/runtime-config-connection.html' as documentation_link,
910 3 as severity_order
911from
912 pg_stat_activity
913where
914 state = 'active'
915group by
916 1,
917 2,
918 3,
919 4,
920 5,
921 7,
922 8,
923 9
924having
925 COUNT(*) > 50;
926-- MEDIUM: Tables larger than 50GB
927with ts as (
928select
929 table_schema,
930 table_name,
931 pg_relation_size('"' || table_schema || '"."' || table_name || '"') as size_bytes,
932 pg_size_pretty(pg_relation_size('"' || table_schema || '"."' || table_name || '"')) as size_pretty
933from
934 information_schema.tables
935where
936 table_type = 'BASE TABLE'
937 and pg_relation_size('"' || table_schema || '"."' || table_name || '"') between 53687091200 and 107374182400
938order by
939 size_bytes desc)
940insert
941 into
942 health_results
943 select
944 'MEDIUM' as severity,
945 'Table Health' as category,
946 'Tables larger than 50GB' as check_name,
947 ts.table_schema || '"."' || ts.table_name as object_name,
948 'The following table' as description,
949 ts.size_pretty as current_value,
950 'Tables larger than 50GB should be monitored and reviewed if a data archiving or removal process should be implemented. I suggest looking into partitioning tables, if possible.' as recommended_action,
951 'https://www.heroku.com/blog/handling-very-large-tables-in-postgres-using-partitioning/' as documentation_link,
952 3 as severity_order
953from
954 ts;
955-- MEDIUM: Queries running longer than 5 minutes
956 insert
957 into
958 health_results
959 select
960 'MEDIUM' as severity,
961 'Query Health' as category,
962 'Long Running Queries' as check_name,
963 concat_ws(' | ',
964 'pid: ' || pgs.pid::text,
965 'usename: ' || pgs.usename,
966 'datname: ' || pgs.datname,
967 'client_address: ' || pgs.client_addr::text,
968 'state: ' || pgs.state,
969 'duration: ' || to_char(now() - query_start, 'HH24:MI:SS')
970 ) as object_name,
971 'The following query has been running for more than 5 minutes. Might be helpful to see if this is expected behavior' as issue_description,
972 query as current_value,
973 'Review query using EXPLAIN ANALYZE to identify any bottlenecks, such as full table scans, missing indexes, etc' as recommended_action,
974 'https://www.postgresql.org/docs/current/using-explain.html' as documentation_link,
975 3 as severity_order
976from
977 pg_stat_activity pgs
978where
979 state = 'active'
980 and now() - query_start > interval '5 minutes'
981order by
982 (now() - query_start) desc;
983-- MEDIUM: pg_stat_statements extension missing
984insert
985 into
986 health_results
987select
988 'MEDIUM' as severity,
989 'Query Health' as category,
990 'pg_stat_statements Extension Missing' as check_name,
991 'pg_stat_statements' as object_name,
992 'pg_stat_statements is not installed, so query fingerprint and workload-level performance checks are unavailable' as issue_description,
993 'Extension not found in pg_extension' as current_value,
994 'Self-hosted: add pg_stat_statements to shared_preload_libraries, restart PostgreSQL, then run CREATE EXTENSION pg_stat_statements; AWS RDS: add pg_stat_statements to the parameter group shared_preload_libraries, reboot, then CREATE EXTENSION; GCP Cloud SQL: enable cloudsql.enable_pg_stat_statements, restart if required, then CREATE EXTENSION; Azure Database for PostgreSQL: add pg_stat_statements to shared_preload_libraries, restart, then CREATE EXTENSION' as recommended_action,
995 'https://www.postgresql.org/docs/current/pgstatstatements.html \
996 https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Appendix.PostgreSQL.CommonDBATasks.Extensions.html \
997 https://cloud.google.com/sql/docs/postgres/flags \
998 https://learn.microsoft.com/azure/postgresql/flexible-server/concepts-server-parameters' as documentation_link,
999 3 as severity_order
1000where
1001 not exists (
1002 select
1003 1
1004 from
1005 pg_extension
1006 where
1007 extname = 'pg_stat_statements'
1008 );
1009-- MEDIUM: pg_stat_statements dependent checks
1010insert
1011 into
1012 health_results
1013select
1014 *
1015from
1016 pgfirstaid_pg_stat_statements_checks();
1017-- MEDIUM: Top 10 expensive active queries by runtime
1018with eq as (
1019select
1020 pgs.pid,
1021 pgs.usename,
1022 pgs.datname,
1023 pgs.client_addr,
1024 now() - pgs.query_start as runtime,
1025 pgs.query
1026from
1027 pg_stat_activity pgs
1028where
1029 pgs.state = 'active'
1030 and pgs.query_start is not null
1031 and pgs.pid <> pg_backend_pid()
1032 and now() - pgs.query_start > interval '30 seconds'
1033order by
1034 runtime desc
1035limit 10)
1036insert
1037 into
1038 health_results
1039select
1040 'MEDIUM' as severity,
1041 'Query Health' as category,
1042 'Top 10 Expensive Active Queries' as check_name,
1043 concat_ws(' | ',
1044 'pid: ' || eq.pid::text,
1045 'usename: ' || eq.usename,
1046 'datname: ' || eq.datname,
1047 'client_address: ' || coalesce(eq.client_addr::text, 'local'),
1048 'runtime: ' || to_char(eq.runtime, 'HH24:MI:SS')
1049 ) as object_name,
1050 'Top 10 active queries running longer than 30 seconds, ordered by runtime. Long-running active queries can signal lock waits, missing indexes, or inefficient plans' as issue_description,
1051 left(regexp_replace(eq.query, E'[\n\r\t]+', ' ', 'g'), 500) as current_value,
1052 'Review these queries with EXPLAIN (ANALYZE, BUFFERS) and reduce lock waits or full scans' as recommended_action,
1053 'https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-ACTIVITY-VIEW \
1054 https://www.postgresql.org/docs/current/using-explain.html \
1055 https://www.tigerdata.com/blog/using-pg-stat-statements-to-optimize-queries' as documentation_link,
1056 3 as severity_order
1057from
1058 eq;
1059-- MEDIUM: Lock-wait-heavy active queries
1060with lw as (
1061select
1062 pid,
1063 usename,
1064 datname,
1065 client_addr,
1066 wait_event,
1067 query_start,
1068 now() - query_start as runtime,
1069 query
1070from
1071 pg_stat_activity
1072where
1073 state = 'active'
1074 and wait_event_type = 'Lock'
1075 and query_start is not null
1076 and now() - query_start > interval '30 seconds'
1077 and pid <> pg_backend_pid()
1078order by
1079 runtime desc
1080limit 10)
1081insert
1082 into
1083 health_results
1084select
1085 'MEDIUM' as severity,
1086 'Query Health' as category,
1087 'Lock-Wait-Heavy Active Queries' as check_name,
1088 concat_ws(' | ',
1089 'pid: ' || lw.pid::text,
1090 'usename: ' || lw.usename,
1091 'datname: ' || lw.datname,
1092 'client_address: ' || coalesce(lw.client_addr::text, 'local'),
1093 'wait_event: ' || coalesce(lw.wait_event, 'unknown'),
1094 'runtime: ' || to_char(lw.runtime, 'HH24:MI:SS')
1095 ) as object_name,
1096 'Active queries waiting on locks for extended time can block throughput and cause cascading latency' as issue_description,
1097 left(regexp_replace(lw.query, E'[\n\r\t]+', ' ', 'g'), 500) as current_value,
1098 'Reduce transaction duration, enforce consistent lock ordering, and investigate blockers first' as recommended_action,
1099 'https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-ACTIVITY-VIEW \
1100 https://www.postgresql.org/docs/current/explicit-locking.html' as documentation_link,
1101 3 as severity_order
1102from
1103 lw;
1104-- MEDIUM: Idle in transaction over 5 minutes
1105insert
1106 into
1107 health_results
1108select
1109 'MEDIUM' as severity,
1110 'Query Health' as category,
1111 'Idle In Transaction Over 5 Minutes' as check_name,
1112 concat_ws(' | ',
1113 'pid: ' || psa.pid::text,
1114 'usename: ' || psa.usename,
1115 'datname: ' || psa.datname,
1116 'client_address: ' || coalesce(psa.client_addr::text, 'local'),
1117 'idle_duration: ' || to_char(now() - psa.state_change, 'HH24:MI:SS')
1118 ) as object_name,
1119 'Sessions left idle in transaction hold snapshots and locks longer than necessary, which can hurt query performance and vacuum progress' as issue_description,
1120 left(regexp_replace(psa.query, E'[\n\r\t]+', ' ', 'g'), 500) as current_value,
1121 'Commit or rollback promptly and move application processing outside transaction boundaries' as recommended_action,
1122 'https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-ACTIVITY-VIEW \
1123 https://www.postgresql.org/docs/current/routine-vacuuming.html' as documentation_link,
1124 3 as severity_order
1125from
1126 pg_stat_activity psa
1127where
1128 psa.state = 'idle in transaction'
1129 and psa.state_change is not null
1130 and now() - psa.state_change > interval '5 minutes'
1131 and psa.pid <> pg_backend_pid()
1132order by
1133 now() - psa.state_change desc;
1134-- LOW: Missing indexes on foreign keys
1135 insert
1136 into
1137 health_results
1138 select
1139 'LOW' as severity,
1140 'Table Health' as category,
1141 'Missing FK Index' as check_name,
1142 n.nspname || '.' || t.relname || '.' || string_agg(a.attname, ', ') as object_name,
1143 'Foreign key constraint missing supporting index for efficient joins' as issue_description,
1144 'FK constraint without index' as current_value,
1145 'Consider adding index on foreign key columns for better join performance' as recommended_action,
1146 'https://www.postgresql.org/docs/current/ddl-constraints.html' as documentation_link,
1147 4 as severity_order
1148from
1149 pg_constraint c
1150join pg_class t on
1151 c.conrelid = t.oid
1152join pg_namespace n on
1153 t.relnamespace = n.oid
1154join pg_attribute a on
1155 a.attrelid = t.oid
1156 and a.attnum = any(c.conkey)
1157where
1158 c.contype = 'f'
1159 and n.nspname not like all(array['information_schema', 'pg_catalog', 'pg_toast', 'pg_temp%'])
1160 and not exists (
1161 select
1162 1
1163 from
1164 pg_index i
1165 where
1166 i.indrelid = c.conrelid
1167 and i.indkey::int2[] @> c.conkey::int2[]
1168 )
1169group by
1170 n.nspname,
1171 t.relname,
1172 c.conname,
1173 1,
1174 2,
1175 3,
1176 5,
1177 6,
1178 7,
1179 8,
1180 9;
1181-- LOW: Connections IDLE for > 1 hour
1182 with ic as (
1183select
1184 pid,
1185 usename,
1186 application_name,
1187 client_addr,
1188 state,
1189 state_change,
1190 now() - state_change as idle_duration
1191from
1192 pg_stat_activity
1193where
1194 state = 'idle'
1195 and state_change < now() - interval '1 hour'
1196 and pid <> pg_backend_pid()
1197 )
1198 insert
1199 into
1200 health_results
1201 select
1202 'LOW' as severity,
1203 'Connection Health' as category,
1204 'Idle Connections Over 1 Hour' as check_name,
1205 ic.usename || ' (PID: ' || ic.pid || ')' as object_name,
1206 'Connection has been idle for ' ||
1207 extract(epoch from ic.idle_duration)::int / 3600 || ' hours ' ||
1208 (extract(epoch from ic.idle_duration)::int % 3600) / 60 || ' minutes. ' ||
1209 'Application: ' || coalesce(ic.application_name, 'unknown') ||
1210 ', Client: ' || coalesce(ic.client_addr::text, 'local') as issue_description,
1211 ic.idle_duration::text as current_value,
1212 'Review if this connection is still needed. Consider implementing connection pooling (PgBouncer), setting idle_session_timeout, or terminating with pg_terminate_backend(' || ic.pid || ')' as recommended_action,
1213 'https://www.postgresql.org/docs/current/runtime-config-client.html' as documentation_link,
1214 4 as severity_order
1215from
1216 ic;
1217-- LOW: Tables with zero or only one column
1218with sct as (
1219select
1220 pc.oid::regclass::text as table_name,
1221 pg_size_pretty(pg_table_size(pc.oid)) as table_size,
1222 pg_table_size(pc.oid) as table_size_bytes,
1223 count(a.attname) as column_count
1224from
1225 pg_catalog.pg_class pc
1226inner join
1227 pg_catalog.pg_namespace nsp on
1228 nsp.oid = pc.relnamespace
1229left join
1230 pg_catalog.pg_attribute a on
1231 a.attrelid = pc.oid
1232 and a.attnum > 0
1233 and not a.attisdropped
1234where
1235 pc.relkind in ('r', 'p')
1236 and not pc.relispartition
1237 and nsp.nspname not in ('pg_catalog', 'information_schema', 'pg_toast')
1238 group by
1239 pc.oid
1240 having
1241 count(a.attname) <= 1
1242)
1243insert
1244 into
1245 health_results
1246select
1247 'LOW' as severity,
1248 'Table Health' as category,
1249 'Table With Single Or No Columns' as check_name,
1250 quote_ident(sct.table_name) as object_name,
1251 'Table has ' || sct.column_count || ' column(s). This may indicate an abandoned table, incomplete migration, or design issue.' as issue_description,
1252 sct.column_count || ' column(s), Size: ' || sct.table_size as current_value,
1253 'Review if this table is still needed. Consider removing if unused or completing the schema if it was left incomplete.' as recommended_action,
1254 'https://www.postgresql.org/docs/current/ddl.html /
1255https://github.com/mfvanek/pg-index-health-sql/blob/master/sql/tables_with_zero_or_one_column.sql' as documentation_link,
1256 4 as severity_order
1257from
1258 sct
1259order by
1260 sct.table_size_bytes desc;
1261-- LOW: Tables with no recent acitivty
1262with it as (
1263select
1264 schemaname || '.' || relname as table_name,
1265 pg_size_pretty(pg_total_relation_size(relid)) as table_size,
1266 pg_total_relation_size(relid) as table_size_bytes,
1267 coalesce(seq_scan, 0) + coalesce(idx_scan, 0) as total_scans,
1268 coalesce(n_tup_ins, 0) + coalesce(n_tup_upd, 0) + coalesce(n_tup_del, 0) as total_writes,
1269 greatest(last_vacuum, last_autovacuum, last_analyze, last_autoanalyze) as last_maintenance
1270from
1271 pg_stat_user_tables
1272where
1273 schemaname not like all(array['pg_catalog', 'information_schema', 'pg_toast', 'pg_temp%'])
1274 and coalesce(seq_scan, 0) + coalesce(idx_scan, 0) = 0
1275 and coalesce(n_tup_ins, 0) + coalesce(n_tup_upd, 0) + coalesce(n_tup_del, 0) = 0
1276)
1277insert
1278 into
1279 health_results
1280select
1281 'LOW' as severity,
1282 'Table Health' as category,
1283 'Table With No Activity Since Stats Reset' as check_name,
1284 quote_ident(it.table_name) as object_name,
1285 'Table has had no reads or writes since stats were last reset. Last maintenance: ' ||
1286 coalesce(it.last_maintenance::text, 'never') as issue_description,
1287 'Total scans: ' || it.total_scans || ', Total writes: ' || it.total_writes ||
1288 ', Size: ' || it.table_size as current_value,
1289 'Review if this table is still needed. Check pg_stat_reset() history to determine stats age. Consider archiving or dropping if no longer in use.' as recommended_action,
1290 'https://www.postgresql.org/docs/current/monitoring-stats.html' as documentation_link,
1291 4 as severity_order
1292from
1293 it
1294order by
1295 it.table_size_bytes desc;
1296-- LOW: Roles that have never logged in (with LOGIN rights)
1297with ur as (
1298select
1299 r.rolname as role_name,
1300 r.rolcreaterole,
1301 r.rolcanlogin,
1302 r.rolsuper,
1303 r.rolvaliduntil,
1304 array_agg(m.rolname) filter (
1305 where m.rolname is not null) as member_of
1306from
1307 pg_roles r
1308left join
1309 pg_auth_members am on
1310 am.member = r.oid
1311left join
1312 pg_roles m on
1313 m.oid = am.roleid
1314where
1315 r.rolcanlogin = true
1316 and r.rolname not like 'pg_%'
1317 and r.rolname not in ('postgres', 'rds_superuser', 'rdsadmin', 'azure_superuser', 'cloudsqlsuperuser')
1318 and not exists (
1319 select
1320 1
1321 from
1322 pg_stat_activity psa
1323 where
1324 psa.usename = r.rolname
1325 )
1326 and (
1327 select
1328 coalesce(max(backend_start), '1970-01-01')
1329 from
1330 pg_stat_activity
1331 where
1332 usename = r.rolname
1333 ) = '1970-01-01'
1334 group by
1335 r.rolname,
1336 r.rolcreaterole,
1337 r.rolcanlogin,
1338 r.rolsuper,
1339 r.rolvaliduntil
1340)
1341insert
1342 into
1343 health_results
1344select
1345 'LOW' as severity,
1346 'Security Health' as category,
1347 'Role Never Logged In' as check_name,
1348 ur.role_name as object_name,
1349 'Role has LOGIN privilege but has never connected (since stats reset). ' ||
1350 case
1351 when ur.rolsuper then 'WARNING: Has SUPERUSER privilege. '
1352 else ''
1353 end ||
1354 case
1355 when ur.rolvaliduntil is not null then 'Expires: ' || ur.rolvaliduntil::text
1356 else 'No expiration set'
1357 end as issue_description,
1358 'Member of: ' || coalesce(array_to_string(ur.member_of, ', '), 'none') as current_value,
1359 'Review if this role is still needed. Consider removing LOGIN privilege or dropping the role if unused.' as recommended_action,
1360 'https://www.postgresql.org/docs/current/sql-droprole.html' as documentation_link,
1361 4 as severity_order
1362from
1363 ur
1364order by
1365 ur.rolsuper desc,
1366 ur.role_name;
1367-- LOW: Indexes with low usage
1368with lui as (
1369select
1370 quote_ident(schemaname) || '.' || quote_ident(indexrelname) as index_name,
1371 quote_ident(schemaname) || '.' || quote_ident(relname) as table_name,
1372 pg_size_pretty(pg_relation_size(indexrelid)) as index_size,
1373 pg_relation_size(indexrelid) as index_size_bytes,
1374 idx_scan,
1375 idx_tup_read,
1376 idx_tup_fetch
1377from
1378 pg_stat_user_indexes
1379where
1380 idx_scan > 0
1381 and idx_scan < 100
1382 and pg_relation_size(indexrelid) > 1024 * 1024
1383 -- > 1MB
1384)
1385insert
1386 into
1387 health_results
1388select
1389 'LOW' as severity,
1390 'Index Health' as category,
1391 'Index With Very Low Usage' as check_name,
1392 lui.index_name as object_name,
1393 'Index on ' || lui.table_name || ' has been scanned only ' || lui.idx_scan ||
1394 ' times since stats reset. May not be worth the maintenance overhead.' as issue_description,
1395 'Scans: ' || lui.idx_scan || ', Tuples read: ' || lui.idx_tup_read ||
1396 ', Size: ' || lui.index_size as current_value,
1397 'Monitor usage over a full business cycle before removing. Verify index is not used for constraints or infrequent but critical queries.' as recommended_action,
1398 'https://www.postgresql.org/docs/current/monitoring-stats.html' as documentation_link,
1399 4 as severity_order
1400from
1401 lui
1402order by
1403 lui.index_size_bytes desc;
1404-- LOW: Check for truely empty tables in the database
1405with et as (
1406select
1407 n.nspname || '.' || c.relname as table_name,
1408 pg_size_pretty(pg_total_relation_size(c.oid)) as table_size,
1409 pg_total_relation_size(c.oid) as table_size_bytes,
1410 s.last_vacuum,
1411 s.last_analyze,
1412 c.reltuples::bigint as estimated_rows
1413from
1414 pg_class c
1415join
1416 pg_namespace n on
1417 n.oid = c.relnamespace
1418left join
1419 pg_stat_user_tables s on
1420 s.relid = c.oid
1421where
1422 c.relkind = 'r'
1423 and n.nspname not in ('pg_catalog', 'information_schema', 'pg_toast')
1424 and c.reltuples = 0
1425 and s.n_live_tup = 0
1426)
1427insert
1428 into
1429 health_results
1430select
1431 'LOW' as severity,
1432 'Table Health' as category,
1433 'Empty Table' as check_name,
1434 et.table_name as object_name,
1435 'Table contains no rows. Last vacuum: ' || coalesce(et.last_vacuum::text, 'never') ||
1436 ', Last analyze: ' || coalesce(et.last_analyze::text, 'never') as issue_description,
1437 '0 rows, Size: ' || et.table_size as current_value,
1438 'Review if this table is still needed. May be an abandoned table, pending migration, or staging table that was never cleaned up.' as recommended_action,
1439 'https://www.postgresql.org/docs/current/routine-vacuuming.html' as documentation_link,
1440 4 as severity_order
1441from
1442 et
1443order by
1444 et.table_size_bytes desc;
1445-- INFO: Database size and growth
1446 insert
1447 into
1448 health_results
1449 select
1450 'INFO' as severity,
1451 'Database Health' as category,
1452 'Database Size' as check_name,
1453 current_database() as object_name,
1454 'Current database size information' as issue_description,
1455 pg_size_pretty(pg_database_size(current_database())) as current_value,
1456 'Monitor growth trends and plan capacity accordingly' as recommended_action,
1457 'https://www.postgresql.org/docs/current/diskusage.html' as documentation_link,
1458 5 as severity_order;
1459-- INFO: Version and configuration
1460 insert
1461 into
1462 health_results
1463 select
1464 'INFO' as severity,
1465 'System Info' as category,
1466 'PostgreSQL Version' as check_name,
1467 'System' as object_name,
1468 'Current PostgreSQL version and basic configuration' as issue_description,
1469 version() as current_value,
1470 'Keep PostgreSQL updated and review configuration settings' as recommended_action,
1471 'https://www.postgresql.org/docs/current/upgrading.html' as documentation_link,
1472 5 as severity_order;
1473-- INFO: shared_buffers current value
1474insert into health_results
1475select
1476 'INFO' as severity,
1477 'System Health' as category,
1478 'shared_buffers Setting' as check_name,
1479 'System' as object_name,
1480 'Current value of shared_buffers. Recommended: ~25% of total system RAM for dedicated database servers.' as issue_description,
1481 current_setting('shared_buffers') as current_value,
1482 'No action needed if already tuned. For dedicated DB servers with 8GB+ RAM, target 25% of total RAM. Changes require a PostgreSQL restart.' as recommended_action,
1483 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS' as documentation_link,
1484 5 as severity_order;
1485-- HIGH: shared_buffers still at 128MB PostgreSQL default
1486insert into health_results
1487select
1488 'HIGH' as severity,
1489 'System Health' as category,
1490 'shared_buffers At Default' as check_name,
1491 'System' as object_name,
1492 'shared_buffers is set to the PostgreSQL default of 128MB. On any real workload this is almost certainly too low.' as issue_description,
1493 current_setting('shared_buffers') as current_value,
1494 'Set shared_buffers to approximately 25% of total system RAM (e.g., 2GB on an 8GB server). Requires a PostgreSQL restart.' as recommended_action,
1495 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS' as documentation_link,
1496 2 as severity_order
1497where pg_size_bytes(current_setting('shared_buffers')) = pg_size_bytes('128MB');
1498
1499-- INFO: work_mem current value
1500insert into health_results
1501select
1502 'INFO' as severity,
1503 'System Health' as category,
1504 'work_mem Setting' as check_name,
1505 'System' as object_name,
1506 'Current value of work_mem. Allocated per sort/hash operation per session — multiply by max_connections and parallel workers to estimate peak memory consumption.' as issue_description,
1507 current_setting('work_mem') || ' (max_connections: ' || current_setting('max_connections') || ')' as current_value,
1508 'For OLTP workloads, 16-32MB is a common starting point. Monitor pg_stat_statements for temp file spills to determine if higher is warranted. Use SET work_mem per-session for large one-off queries rather than setting globally.' as recommended_action,
1509 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM' as documentation_link,
1510 5 as severity_order;
1511
1512-- MEDIUM: work_mem still at 4MB PostgreSQL default
1513insert into health_results
1514select
1515 'MEDIUM' as severity,
1516 'System Health' as category,
1517 'work_mem At Default' as check_name,
1518 'System' as object_name,
1519 'work_mem is set to the PostgreSQL default of 4MB. On modern hardware this often causes unnecessary sort and hash spills to disk.' as issue_description,
1520 current_setting('work_mem') as current_value,
1521 'Consider raising work_mem to 16-32MB for OLTP workloads. Be aware that work_mem is allocated per operation per session — high concurrency multiplies total memory usage.' as recommended_action,
1522 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM' as documentation_link,
1523 3 as severity_order
1524where pg_size_bytes(current_setting('work_mem')) = pg_size_bytes('4MB');
1525
1526-- INFO: effective_cache_size current value
1527insert into health_results
1528select
1529 'INFO' as severity,
1530 'System Health' as category,
1531 'effective_cache_size Setting' as check_name,
1532 'System' as object_name,
1533 'Current value of effective_cache_size. Tells the query planner how much memory is available for disk caching. Does not allocate memory — purely advisory.' as issue_description,
1534 current_setting('effective_cache_size') as current_value,
1535 'Set to ~50-75% of total system RAM (shared_buffers + expected OS page cache). Underestimates cause the planner to prefer nested loops over index scans.' as recommended_action,
1536 'https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE' as documentation_link,
1537 5 as severity_order;
1538
1539-- INFO: maintenance_work_mem current value
1540insert into health_results
1541select
1542 'INFO' as severity,
1543 'System Health' as category,
1544 'maintenance_work_mem Setting' as check_name,
1545 'System' as object_name,
1546 'Current value of maintenance_work_mem. Used by VACUUM, CREATE INDEX, ALTER TABLE, and each autovacuum worker.' as issue_description,
1547 current_setting('maintenance_work_mem') as current_value,
1548 'Consider 256MB-1GB on modern hardware. Higher values speed up index builds and autovacuum on large tables. Changes take effect immediately for new sessions.' as recommended_action,
1549 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM' as documentation_link,
1550 5 as severity_order;
1551
1552-- INFO: Transaction ID wraparound risk per database
1553insert into health_results
1554select
1555 'INFO' as severity,
1556 'System Health' as category,
1557 'Transaction ID Wraparound Risk' as check_name,
1558 datname as object_name,
1559 'Age of the oldest unfrozen transaction ID in this database. PostgreSQL must freeze XIDs before reaching ~2.1 billion to prevent data loss from wraparound.' as issue_description,
1560 datname || ': XID age ' || trim(to_char(age(datfrozenxid), 'FM999,999,999,990')) ||
1561 ' (' || round(age(datfrozenxid)::numeric * 100 / 2000000000, 1)::text ||
1562 '% of wraparound window, ~' ||
1563 trim(to_char(greatest(2000000000::bigint - age(datfrozenxid)::bigint, 0), 'FM999,999,999,990')) ||
1564 ' remaining)' as current_value,
1565 'Run VACUUM FREEZE on databases approaching high XID age. Ensure autovacuum is enabled and not blocked. Monitor databases with age > 500,000,000.' as recommended_action,
1566 'https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-FOR-WRAPAROUND' as documentation_link,
1567 5 as severity_order
1568from
1569 pg_database
1570where
1571 datallowconn = true;
1572
1573-- INFO: Checkpoint statistics (PG15/16: pg_stat_bgwriter, PG17+: pg_stat_checkpointer)
1574insert into health_results
1575select
1576 'INFO' as severity,
1577 'System Health' as category,
1578 'Checkpoint Stats' as check_name,
1579 'System' as object_name,
1580 'Checkpoint activity since stats last reset. Forced checkpoints occur when WAL fills up before the scheduled interval — high ratios suggest max_wal_size may be too small. PG15/16 reads from pg_stat_bgwriter; PG17+ reads from pg_stat_checkpointer.' as issue_description,
1581 _pg_firstaid_checkpoint_stats() as current_value,
1582 'If forced checkpoints are consistently above 50% of total, consider increasing max_wal_size. Reset stats with: SELECT pg_stat_reset_shared(''' ||
1583 case
1584 when current_setting('server_version_num')::int >= 170000 then 'checkpointer'
1585 else 'bgwriter'
1586 end ||
1587 ''').' as recommended_action,
1588 'https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-BGWRITER-VIEW' as documentation_link,
1589 5 as severity_order;
1590
1591-- INFO: Server role (primary vs standby)
1592insert into health_results
1593select
1594 'INFO' as severity,
1595 'System Info' as category,
1596 'Server Role' as check_name,
1597 'System' as object_name,
1598 'Whether this server is operating as a primary or standby replica. Context for interpreting other checks — some checks are only relevant on standbys.' as issue_description,
1599 case
1600 when pg_is_in_recovery() then 'Standby (replica)'
1601 else 'Primary'
1602 end as current_value,
1603 'No action needed — informational.' as recommended_action,
1604 'https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-RECOVERY-INFO-TABLE' as documentation_link,
1605 5 as severity_order;
1606
1607-- INFO: Connection utilization
1608insert into health_results
1609select
1610 'INFO' as severity,
1611 'System Health' as category,
1612 'Connection Utilization' as check_name,
1613 'System' as object_name,
1614 'Current connection usage as a percentage of max_connections. Includes all connection states, not just active queries.' as issue_description,
1615 count(*)::text || ' total / ' || current_setting('max_connections') || ' max (' ||
1616 round(100.0 * count(*) / current_setting('max_connections')::int, 1)::text || '% used)' as current_value,
1617 'If consistently above 80%, consider a connection pooler such as PgBouncer. Reserve headroom for superuser connections (superuser_reserved_connections).' as recommended_action,
1618 'https://www.postgresql.org/docs/current/runtime-config-connection.html' as documentation_link,
1619 5 as severity_order
1620from
1621 pg_stat_activity;
1622
1623-- INFO: Installed Extensions
1624 insert
1625 into
1626 health_results
1627 select
1628 'INFO' as severity,
1629 'System Info' as category,
1630 'Installed Extension' as check_name,
1631 'System' as object_name,
1632 'Installed Postgres Extension' as issue_description,
1633 pe.extname || ':' || pe.extversion as current_value,
1634 'Before updating to the latest minor/major version of PG, verify extension compatability' as recommended_action,
1635 'https://youtu.be/mpEdQm3TpE0?si=VMcHBo1VnDfGZvtI&t=937' as documentation_link,
1636 --Link is from a fantastic talk from SCALE 22x on bugging pg_extension maintainers!
1637 5 as severity_order
1638from
1639 pg_extension pe;
1640-- INFO: Server Uptime
1641 insert
1642 into
1643 health_results
1644 select
1645 'INFO' as severity,
1646 'System Info' as category,
1647 'Server Uptime' as check_name,
1648 'System' as object_name,
1649 'Current Uptime of Server' as issue_description,
1650 current_timestamp - pg_postmaster_start_time() as current_value,
1651 'No Recommendation - Informational' as recommended_action,
1652 'N/A' as documentation_link,
1653 5 as severity_order;
1654-- INFO: Log Directory
1655 with ld as (
1656select
1657 current_setting('log_directory') as log_directory
1658 )
1659 insert
1660 into
1661 health_results
1662 select
1663 'INFO' as severity,
1664 'System Info' as category,
1665 'Is Logging Enabled' as check_name,
1666 'System' as object_name,
1667 'If no log file is present, this indicates logging is not enabled' as issue_description,
1668 ld.log_directory as current_value,
1669 'Logging enabled will assist with troubleshooting future issues. Dont you like logs?' as recommended_action,
1670 'For self-hosting: https://www.postgresql.org/docs/current/runtime-config-logging.html /
1671 For AWS Aurora/RDS: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_LogAccess.Concepts.PostgreSQL.overview.parameter-groups.html /
1672 For GCP Cloud SQL: https://docs.cloud.google.com/sql/docs/postgres/flags /
1673 For Azure Database for PostgreSQL: https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-server-parameters
1674 ' as documentation_link,
1675 5 as severity_order
1676from
1677 ld;
1678-- INFO: Log File(s) Size(s)
1679begin
1680 with ls as (
1681select
1682 ROUND(sum(stat.size) / (1024.0 * 1024.0), 2) || ' MB' as size_mb
1683from
1684 pg_ls_dir(current_setting('log_directory')) as logs
1685cross join lateral
1686 pg_stat_file(current_setting('log_directory') || '/' || logs) as stat)
1687 insert
1688 into
1689 health_results
1690 select
1691 'INFO' as severity,
1692 'System Info' as category,
1693 'Size of ALL Logfiles combined' as check_name,
1694 'System' as object_name,
1695 'Monitoring your logfile size will prevent from filling up storage (or expanding your storage in cloud managed). This can also lead to the server cashing when the logfile cannot be saved.' as issue_description,
1696 ls.size_mb as current_value,
1697 'Set log_rotation_age and size for proper rotation of log files. This will prevent runaway log sizes.' as recommended_action,
1698 'For self-hosting:https://www.postgresql.org/docs/current/runtime-config-logging.html /
1699 For AWS Aurora/RDS: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_LogAccess.Concepts.PostgreSQL.overview.parameter-groups.html /
1700 For GCP Cloud SQL: https://docs.cloud.google.com/sql/docs/postgres/flags /
1701 For Azure Database for PostgreSQL: https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-server-parameters
1702 ' as documentation_link,
1703 5 as severity_order
1704from
1705 ls;
1706exception
1707when insufficient_privilege then
1708 insert
1709 into
1710 health_results
1711 select
1712 'INFO' as severity,
1713 'System Info' as category,
1714 'Size of ALL Logfiles combined' as check_name,
1715 'System' as object_name,
1716 'Unable to check log file sizes - insufficient privileges' as issue_description,
1717 'Permission denied for pg_ls_dir' as current_value,
1718 'If this is a managed instance (ex:AWS RDS), you will not be able to view this information from SQL. For RDS, use AWS CLI: aws rds describe-db-log-files --db-instance-identifier <instance-name>. Otherwise, grant pg_read_server_files role or run as superuser to enable this check.' as recommended_action,
1719 'For AWS Aurora/RDS: https://docs.aws.amazon.com/cli/latest/reference/rds/describe-db-log-files.html /
1720 For GCP Cloud SQL: https://docs.cloud.google.com/sql/docs/postgres/logging /
1721 For Azure Database for PostgreSQL: https://learn.microsoft.com/en-us/cli/azure/postgres/flexible-server/server-logs?view=azure-cli-latest
1722 ' as documentation_link,
1723 5 as severity_order;
1724end;
1725-- Return results ordered by severity
1726 return QUERY
1727 select
1728 hr.severity,
1729 hr.category,
1730 hr.check_name,
1731 hr.object_name,
1732 hr.issue_description,
1733 hr.current_value,
1734 hr.recommended_action,
1735 hr.documentation_link
1736from
1737 health_results hr
1738order by
1739 hr.severity_order,
1740 hr.category,
1741 hr.check_name;
1742-- Clean up
1743 drop table health_results;
1744end;
1745
1746$$ language plpgsql;