Database Maintenance Health Checks: SQL Server and PostgreSQL
Every production database accumulates the same class of problems over time. Indexes fragment as rows are inserted and deleted unevenly. Dead tuples pile up faster than background processes can reclaim them. Queries that once ran against a small table now scan tens of millions of rows because nobody added an index when the table grew. And somewhere in the application layer, a prepared statement that worked perfectly at launch is now scanning every partition of a table that has grown to five hundred partitions, because the plan was generated once and never revisited.
Left unchecked, these issues compound in ways that are hard to untangle after the fact. A fragmented index slows reads, slow reads delay vacuum, a stalled vacuum causes bloat, and bloat makes the missing index problem measurably worse. The two scripts in this post are designed to surface all of these issues in a single pass, so you can see the full picture before deciding where to act.
What We Are Checking and Why
Before diving into the scripts, it is worth understanding the five categories each one targets and what makes them worth checking systematically rather than reactively.
Fragmented indexes are the most visible form of storage decay. As rows are inserted, updated, and deleted, index pages fill unevenly. On SQL Server, fragmentation above 30% warrants a full REBUILD while anything between 10 and 30% usually calls for a REORGANIZE. On PostgreSQL, the equivalent concept is index bloat — dead index entries that accumulate until a REINDEX sweeps them away.
Page splits are what cause fragmentation in the first place. When a new row needs to be inserted into a full B-tree page, SQL Server splits that page into two, leaving both of them half empty. A high rate of page splits often signals a poor fill factor setting on the index, or an insert pattern that consistently targets the same region of the key space. PostgreSQL surfaces this indirectly through table and index bloat metrics rather than a dedicated page split counter.
Vacuum settings are a PostgreSQL concern with no direct SQL Server equivalent. PostgreSQL’s MVCC model never overwrites rows in place. It writes a new row version and marks the old one dead, leaving reclamation entirely to autovacuum. If autovacuum is misconfigured, disabled per table, or simply falling behind under write load, dead tuples accumulate and bloat grows. In the most severe case the database approaches transaction ID wraparound, which will cause a full outage if not addressed. This is the one issue in this post that can take a database completely offline with almost no warning.
Missing indexes are easy to over-report, and most tools do exactly that. Every query planner is happy to suggest an index that would help its specific query, but adding indexes has real costs because every write to the table must update every index on it. The scripts here filter aggressively, surfacing only indexes where the estimated benefit relative to actual query workload is large enough to clearly outweigh the write overhead.
Prepared statements on partitioned tables represent the most subtle problem on this list. Partitioned tables only deliver their performance promise when the query planner can eliminate irrelevant partitions at planning time. With prepared statements, the plan is generated once and reused across subsequent executions. If the partition key predicate is missing, wrapped in a function that makes it non-SARGable, or if PostgreSQL selects a generic plan after five executions that ignores partition bounds entirely, every execution silently scans all partitions. A query that should read one partition reads fifty, and nothing in the application logs will tell you why.
SQL Server: Maintenance Health Check
The script below targets SQL Server 2016 and later. Run it against the specific database you want to inspect, and ensure the executing account holds the VIEW SERVER STATE permission for DMV access.
Three tuning variables at the top of the script let you control the signal to noise ratio. Raise @MinPageCount to skip smaller indexes with limited impact, and raise @MinImpactScore if the missing index results are too numerous for your workload.
-- ============================================================
-- SQL SERVER DATABASE MAINTENANCE HEALTH CHECK
-- Run on SQL Server 2016+ (some DMVs require 2019+ as noted)
-- ============================================================
SET NOCOUNT ON;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
DECLARE @MinFragmentationPct FLOAT = 10.0;
DECLARE @MinPageCount INT = 500;
DECLARE @MinImpactScore FLOAT = 100000.0;
DECLARE @MinAvgFullScanCost FLOAT = 1.0;
PRINT '================================================================';
PRINT 'SQL SERVER MAINTENANCE HEALTH CHECK — ' + CONVERT(VARCHAR, GETDATE(), 120);
PRINT '================================================================';
-- 1. FRAGMENTED INDEXES
-- Uses sys.dm_db_index_physical_stats (LIMITED mode for speed)
-- 10-30% frag => REORGANIZE; >30% => REBUILD recommended
PRINT '--- [1] FRAGMENTED INDEXES ---';
SELECT
DB_NAME() AS database_name,
OBJECT_SCHEMA_NAME(i.object_id) AS schema_name,
OBJECT_NAME(i.object_id) AS table_name,
i.name AS index_name,
ips.index_type_desc,
CAST(ips.avg_fragmentation_in_percent AS DECIMAL(5,1)) AS fragmentation_pct,
ips.page_count,
CASE
WHEN ips.avg_fragmentation_in_percent >= 30 THEN 'REBUILD'
WHEN ips.avg_fragmentation_in_percent >= 10 THEN 'REORGANIZE'
ELSE 'OK'
END AS recommended_action
FROM sys.dm_db_index_physical_stats(
DB_ID(), NULL, NULL, NULL, 'LIMITED') AS ips
JOIN sys.indexes i
ON i.object_id = ips.object_id
AND i.index_id = ips.index_id
WHERE ips.avg_fragmentation_in_percent >= @MinFragmentationPct
AND ips.page_count >= @MinPageCount
AND i.index_id > 0
ORDER BY ips.avg_fragmentation_in_percent DESC;
-- 2. PAGE SPLITS & FORWARDED FETCHES
-- High forwarded_fetch_count on a heap = lots of row movement.
-- High leaf_allocation_count from operational stats = page splits on index.
PRINT '--- [2] PAGE SPLITS & FORWARDED FETCHES ---';
SELECT
OBJECT_SCHEMA_NAME(ios.object_id) AS schema_name,
OBJECT_NAME(ios.object_id) AS table_name,
'HEAP' AS index_type,
ios.forwarded_fetch_count,
ios.range_scan_count,
'High forwarded_fetch_count indicates row forwarding on a heap. '
+ 'Consider adding a clustered index.' AS recommendation
FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) ios
JOIN sys.indexes i
ON i.object_id = ios.object_id
AND i.index_id = ios.index_id
AND i.type = 0
WHERE ios.forwarded_fetch_count > 1000
ORDER BY ios.forwarded_fetch_count DESC;
SELECT
OBJECT_SCHEMA_NAME(ios.object_id) AS schema_name,
OBJECT_NAME(ios.object_id) AS table_name,
i.name AS index_name,
ios.leaf_allocation_count AS page_splits_proxy,
ios.nonleaf_allocation_count AS nonleaf_splits,
ios.leaf_insert_count,
ios.leaf_update_count,
'High leaf_allocation_count relative to inserts/updates suggests frequent '
+ 'page splits. Consider increasing fillfactor or rebuilding the index.'
AS recommendation
FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) ios
JOIN sys.indexes i
ON i.object_id = ios.object_id
AND i.index_id = ios.index_id
WHERE i.type > 0
AND ios.leaf_allocation_count > 1000
AND (ios.leaf_insert_count + ios.leaf_update_count) > 0
AND (CAST(ios.leaf_allocation_count AS FLOAT)
/ NULLIF(ios.leaf_insert_count + ios.leaf_update_count, 0)) > 0.1
ORDER BY ios.leaf_allocation_count DESC;
-- 3. HIGH-IMPACT MISSING INDEXES
-- Only surfaces indexes with a meaningful estimated impact score.
-- Impact = avg_total_user_cost * avg_user_impact * (user_seeks + user_scans)
PRINT '--- [3] HIGH-IMPACT MISSING INDEXES ---';
SELECT TOP 30
ROUND(
migs.avg_total_user_cost
* migs.avg_user_impact
* (migs.user_seeks + migs.user_scans), 0) AS impact_score,
migs.user_seeks,
migs.user_scans,
ROUND(migs.avg_user_impact, 1) AS avg_benefit_pct,
OBJECT_SCHEMA_NAME(mid.object_id) AS schema_name,
OBJECT_NAME(mid.object_id) AS table_name,
mid.equality_columns,
mid.inequality_columns,
mid.included_columns,
'CREATE INDEX [IX_' + OBJECT_NAME(mid.object_id)
+ '_missing_' + CAST(mid.index_handle AS VARCHAR) + '] ON '
+ OBJECT_SCHEMA_NAME(mid.object_id) + '.' + OBJECT_NAME(mid.object_id)
+ ' (' + ISNULL(mid.equality_columns, '')
+ CASE WHEN mid.inequality_columns IS NOT NULL
AND mid.equality_columns IS NOT NULL THEN ', ' ELSE '' END
+ ISNULL(mid.inequality_columns, '') + ')'
+ CASE WHEN mid.included_columns IS NOT NULL
THEN ' INCLUDE (' + mid.included_columns + ')' ELSE '' END
+ ';' AS suggested_ddl
FROM sys.dm_db_missing_index_details mid
JOIN sys.dm_db_missing_index_groups mig
ON mig.index_handle = mid.index_handle
JOIN sys.dm_db_missing_index_group_stats migs
ON migs.group_handle = mig.index_group_handle
WHERE mid.database_id = DB_ID()
AND ROUND(
migs.avg_total_user_cost
* migs.avg_user_impact
* (migs.user_seeks + migs.user_scans), 0) >= @MinImpactScore
ORDER BY impact_score DESC;
-- 4. CACHED PLANS DOING FULL SCANS ON PARTITIONED TABLES
-- Looks for plans in cache that reference partitioned tables
-- AND contain Table Scan or Clustered Index Scan operators.
PRINT '--- [4] CACHED PLANS: FULL SCANS ON PARTITIONED TABLES ---';
;WITH PartitionedTables AS (
SELECT DISTINCT
OBJECT_SCHEMA_NAME(i.object_id) + '.' + OBJECT_NAME(i.object_id) AS full_name
FROM sys.partitions p
JOIN sys.indexes i ON i.object_id = p.object_id AND i.index_id = p.index_id
WHERE p.partition_number > 1
OR EXISTS (
SELECT 1 FROM sys.partition_schemes ps
JOIN sys.indexes i2 ON i2.data_space_id = ps.data_space_id
WHERE i2.object_id = i.object_id
)
)
SELECT
qs.execution_count,
qs.total_logical_reads / NULLIF(qs.execution_count, 0) AS avg_logical_reads,
qs.total_elapsed_time / NULLIF(qs.execution_count, 0) / 1000 AS avg_elapsed_ms,
qs.total_worker_time / NULLIF(qs.execution_count, 0) / 1000 AS avg_cpu_ms,
pt.full_name AS partitioned_table,
SUBSTRING(st.text, 1, 500) AS sql_text_preview,
qp.query_plan AS execution_plan_xml,
'Review plan XML for TableScan / Index Scan without partition elimination. '
+ 'Ensure predicates on the partition key are SARGable and that '
+ 'sp_executesql parameters match the partition key data type exactly.'
AS recommendation
FROM sys.dm_exec_cached_plans cp
CROSS APPLY sys.dm_exec_sql_text(cp.plan_handle) st
CROSS APPLY sys.dm_exec_query_plan(cp.plan_handle) qp
JOIN sys.dm_exec_query_stats qs ON qs.plan_handle = cp.plan_handle
JOIN PartitionedTables pt
ON st.text LIKE '%' + pt.full_name + '%'
WHERE cp.objtype IN ('Prepared', 'Proc', 'Adhoc')
AND CAST(qp.query_plan AS NVARCHAR(MAX))
LIKE '%TableScan%'
OR CAST(qp.query_plan AS NVARCHAR(MAX))
LIKE '%PhysicalOp="Clustered Index Scan"%'
AND qs.total_logical_reads / NULLIF(qs.execution_count, 0) > 10000
ORDER BY avg_logical_reads DESC;
-- 5. UNUSED INDEXES
-- Indexes being maintained on every write but never used for reads.
PRINT '--- [5] UNUSED INDEXES ---';
SELECT
OBJECT_SCHEMA_NAME(i.object_id) AS schema_name,
OBJECT_NAME(i.object_id) AS table_name,
i.name AS index_name,
i.type_desc,
ISNULL(us.user_seeks, 0) AS user_seeks,
ISNULL(us.user_scans, 0) AS user_scans,
ISNULL(us.user_lookups,0) AS user_lookups,
ISNULL(us.user_updates,0) AS user_updates,
'DROP INDEX [' + i.name + '] ON ['
+ OBJECT_SCHEMA_NAME(i.object_id) + '].['
+ OBJECT_NAME(i.object_id) + '];' AS suggested_drop_ddl
FROM sys.indexes i
LEFT JOIN sys.dm_db_index_usage_stats us
ON us.object_id = i.object_id
AND us.index_id = i.index_id
AND us.database_id = DB_ID()
WHERE i.object_id > 100
AND i.index_id > 1
AND i.is_primary_key = 0
AND i.is_unique_constraint = 0
AND ISNULL(us.user_seeks, 0) = 0
AND ISNULL(us.user_scans, 0) = 0
AND ISNULL(us.user_lookups,0) = 0
AND ISNULL(us.user_updates,0) > 500
ORDER BY us.user_updates DESC;
PRINT 'Health check complete.';
PRINT 'NOTE: DMV stats reset on SQL Server restart.'; The missing index output includes a ready to run CREATE INDEX statement for each suggestion. Treat these as strong leads rather than instructions to execute directly. Always verify that a suggested index does not duplicate an existing one, and consider consolidating multiple suggestions on the same table into a single composite index before applying anything. For the full scan results in section 4, the execution_plan_xml column can be opened directly in SSMS to visually confirm which operator is causing the scan and whether partition elimination is appearing in the plan at all.
PostgreSQL: Maintenance Health Check
The PostgreSQL script targets version 13 and later and should be run as a superuser or a role with the pg_monitor privilege available since PostgreSQL 14. For the best results, two extensions should be installed before running the script.
-- Add to postgresql.conf before installing the extension:
-- shared_preload_libraries = 'pg_stat_statements'
-- Then run in your database:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE EXTENSION IF NOT EXISTS pgstattuple; pg_stat_statements powers the slow query analysis in sections 3 and 4. pgstattuple gives accurate index bloat figures at the page level and without it the script falls back to a heuristic estimate, which is still useful but considerably less precise on tables with variable row widths.
-- ============================================================
-- POSTGRESQL DATABASE MAINTENANCE HEALTH CHECK
-- Compatible with PostgreSQL 13+
-- Run as superuser or a role with pg_monitor privilege (PG14+)
-- ============================================================
\timing on
-- 1. INDEX BLOAT
-- Uses pgstattuple if available, otherwise heuristic estimate.
-- High bloat => REINDEX CONCURRENTLY recommended.
\echo '--- [1] INDEX BLOAT ---'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgstattuple') THEN
RAISE NOTICE 'pgstattuple available — using accurate bloat stats';
ELSE
RAISE NOTICE 'pgstattuple not installed. Falling back to heuristic estimate.';
RAISE NOTICE 'Install with: CREATE EXTENSION pgstattuple;';
END IF;
END $$;
WITH constants AS (
SELECT
current_setting('block_size')::NUMERIC AS bs,
23 AS hdr,
4 AS ma
),
bloat_calc AS (
SELECT
schemaname, tablename, indexname, bs,
CEIL(
(reltuples * (6 + ma
- CASE WHEN index_max_width < ma THEN index_max_width ELSE ma END
+ nulldatawidth + hdr
))::NUMERIC / (bs - 20)
) AS est_pages_live,
relpages AS actual_pages
FROM (
SELECT
n.nspname AS schemaname, t.relname AS tablename,
ix.relname AS indexname, ix.relpages, ix.reltuples,
c.bs, c.hdr, c.ma,
COALESCE((SELECT AVG(sta.stawidth) FROM pg_statistic sta
WHERE sta.starelid = t.oid), 50) AS index_max_width,
COALESCE((SELECT SUM(sta.stawidth) FROM pg_statistic sta
WHERE sta.starelid = t.oid), 50) AS nulldatawidth
FROM pg_class ix
JOIN pg_index id ON id.indexrelid = ix.oid
JOIN pg_class t ON t.oid = id.indrelid
JOIN pg_namespace n ON n.oid = ix.relnamespace
CROSS JOIN constants c
WHERE ix.relkind = 'i'
AND ix.relpages > 100
AND n.nspname NOT IN ('pg_catalog','pg_toast','information_schema')
) sub
)
SELECT
schemaname, tablename, indexname,
actual_pages,
GREATEST(est_pages_live, 1) AS est_pages_live,
ROUND(
100.0 * (actual_pages - GREATEST(est_pages_live, 1))
/ NULLIF(actual_pages, 0), 1
) AS bloat_pct,
CASE
WHEN actual_pages > GREATEST(est_pages_live, 1)
THEN 'REINDEX CONCURRENTLY ' || schemaname || '.' || indexname || ';'
ELSE 'OK'
END AS recommended_action
FROM bloat_calc
WHERE actual_pages > GREATEST(est_pages_live, 1)
AND ROUND(
100.0 * (actual_pages - GREATEST(est_pages_live, 1))
/ NULLIF(actual_pages, 0), 1
) >= 20
ORDER BY bloat_pct DESC
LIMIT 50;
-- 2. VACUUM & AUTOVACUUM HEALTH
-- 2a. Tables overdue for vacuum or analyze
-- 2b. Tables with custom autovacuum storage options
-- 2c. Transaction ID wraparound risk
\echo '--- [2a] TABLES OVERDUE FOR VACUUM / ANALYZE ---'
SELECT
n.nspname AS schema_name,
c.relname AS table_name,
c.reltuples::BIGINT AS est_row_count,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
psut.n_dead_tup AS dead_tuples,
CASE WHEN c.reltuples > 0
THEN ROUND(100.0 * psut.n_dead_tup / c.reltuples, 1)
ELSE 0 END AS dead_pct,
psut.last_vacuum, psut.last_autovacuum,
psut.last_analyze, psut.last_autoanalyze,
CASE
WHEN psut.n_dead_tup > 10000
AND (psut.last_autovacuum IS NULL
OR psut.last_autovacuum < NOW() - INTERVAL '3 days')
THEN 'VACUUM ANALYZE ' || n.nspname || '.' || c.relname || ';'
WHEN psut.last_analyze IS NULL AND psut.last_autoanalyze IS NULL
THEN 'ANALYZE ' || n.nspname || '.' || c.relname || ';'
ELSE 'OK'
END AS recommendation
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_stat_user_tables psut ON psut.relid = c.oid
WHERE c.relkind = 'r'
AND n.nspname NOT IN ('pg_catalog','pg_toast','information_schema')
AND (psut.n_dead_tup > 5000
OR psut.last_analyze IS NULL
OR psut.last_autoanalyze IS NULL)
ORDER BY dead_tuples DESC
LIMIT 40;
\echo '--- [2b] TABLES WITH CUSTOM AUTOVACUUM SETTINGS ---'
SELECT
n.nspname AS schema_name,
c.relname AS table_name,
unnest(c.reloptions) AS storage_option
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.reloptions IS NOT NULL AND c.relkind = 'r'
AND n.nspname NOT IN ('pg_catalog','information_schema')
AND EXISTS (
SELECT 1 FROM unnest(c.reloptions) opt
WHERE opt LIKE 'autovacuum%' OR opt LIKE 'toast.autovacuum%'
)
ORDER BY n.nspname, c.relname;
\echo '--- [2c] TRANSACTION ID WRAPAROUND RISK ---'
SELECT
n.nspname AS schema_name,
c.relname AS table_name,
age(c.relfrozenxid) AS xid_age,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
CASE
WHEN age(c.relfrozenxid) > 1500000000
THEN '*** CRITICAL — VACUUM FREEZE IMMEDIATELY ***'
WHEN age(c.relfrozenxid) > 1000000000
THEN 'WARNING — schedule VACUUM FREEZE soon'
ELSE 'OK'
END AS wraparound_risk
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r','t')
AND age(c.relfrozenxid) > 500000000
ORDER BY age(c.relfrozenxid) DESC;
-- 3. HIGH-IMPACT MISSING INDEXES
-- Sequential scan rate vs index usage on large tables.
-- Drill-down via pg_stat_statements for actual slow queries.
\echo '--- [3] HIGH-IMPACT MISSING INDEXES ---'
SELECT
n.nspname AS schema_name,
c.relname AS table_name,
c.reltuples::BIGINT AS est_rows,
pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
psut.seq_scan,
psut.seq_tup_read,
psut.idx_scan,
CASE WHEN psut.idx_scan > 0
THEN ROUND(psut.seq_scan::NUMERIC / psut.idx_scan, 2)
ELSE psut.seq_scan END AS seq_to_idx_ratio,
psut.seq_tup_read / NULLIF(psut.seq_scan, 0) AS avg_rows_per_seq_scan,
'High sequential scan rate. Identify query predicates via '
|| 'pg_stat_statements and add targeted indexes.' AS recommendation
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_stat_user_tables psut ON psut.relid = c.oid
WHERE c.relkind = 'r'
AND n.nspname NOT IN ('pg_catalog','pg_toast','information_schema')
AND c.reltuples > 10000
AND psut.seq_scan > 100
AND (psut.idx_scan = 0
OR psut.seq_scan::FLOAT / NULLIF(psut.idx_scan, 0) > 0.5)
AND psut.seq_tup_read / NULLIF(psut.seq_scan, 0) > 1000
ORDER BY psut.seq_tup_read DESC
LIMIT 30;
\echo '--- [3b] TOP SEQ-SCAN QUERIES (pg_stat_statements) ---'
SELECT
ROUND(total_exec_time::NUMERIC, 0) AS total_exec_ms,
calls,
ROUND(mean_exec_time::NUMERIC, 2) AS avg_exec_ms,
ROUND(rows::NUMERIC / NULLIF(calls, 0), 0) AS avg_rows_returned,
LEFT(query, 300) AS query_preview
FROM pg_stat_statements
WHERE query ILIKE '%SELECT%'
AND query NOT ILIKE '%pg_stat%'
AND mean_exec_time > 500
AND rows / NULLIF(calls, 0) > 500
ORDER BY total_exec_time DESC
LIMIT 20;
-- 4. PREPARED STATEMENTS ON PARTITIONED TABLES
-- Partition pruning failures stem from: mismatched parameter types,
-- non-SARGable predicates, or generic plans ignoring partition bounds.
\echo '--- [4a] PARTITIONED TABLES IN THIS DATABASE ---'
SELECT
n.nspname AS schema_name,
c.relname AS table_name,
CASE p.partstrat
WHEN 'r' THEN 'RANGE'
WHEN 'l' THEN 'LIST'
WHEN 'h' THEN 'HASH'
ELSE p.partstrat::TEXT END AS partition_strategy,
pg_get_partkeydef(c.oid) AS partition_key,
(SELECT COUNT(*) FROM pg_inherits pi
WHERE pi.inhparent = c.oid) AS partition_count,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_partitioned_table p ON p.partrelid = c.oid
WHERE n.nspname NOT IN ('pg_catalog','information_schema')
ORDER BY pg_total_relation_size(c.oid) DESC;
\echo '--- [4b] SESSION PREPARED STATEMENTS ON PARTITIONED TABLES ---'
SELECT
ps.name AS prepared_stmt_name,
ps.statement AS sql_text,
ps.parameter_types,
pt.nspname || '.' || pt.relname AS partitioned_table,
'Run EXPLAIN (ANALYZE, BUFFERS) on this statement to verify '
|| 'partition pruning is occurring.' AS action
FROM pg_prepared_statements ps
CROSS JOIN LATERAL (
SELECT n.nspname, c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_partitioned_table p ON p.partrelid = c.oid
WHERE ps.statement ILIKE '%' || c.relname || '%'
LIMIT 1
) pt
ORDER BY ps.name;
\echo '--- [4c] HIGH-COST QUERIES ON PARTITIONED TABLES (pg_stat_statements) ---'
WITH partitioned AS (
SELECT n.nspname || '.' || c.relname AS full_name, c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_partitioned_table p ON p.partrelid = c.oid
WHERE n.nspname NOT IN ('pg_catalog','information_schema')
)
SELECT
ss.calls,
ROUND(ss.mean_exec_time::NUMERIC, 2) AS avg_exec_ms,
ROUND(ss.total_exec_time::NUMERIC, 0) AS total_exec_ms,
ROUND(ss.rows::NUMERIC / NULLIF(ss.calls,0), 0) AS avg_rows,
ROUND(ss.shared_blks_read::NUMERIC
/ NULLIF(ss.calls, 0), 0) AS avg_blks_read,
p.full_name AS partitioned_table,
LEFT(ss.query, 400) AS query_preview,
'Run EXPLAIN (ANALYZE, BUFFERS) and check for Seq Scan across '
|| 'multiple partitions. Verify the partition key predicate is '
|| 'present and SARGable. Check plan_cache_mode if using PREPARE.'
AS recommendation
FROM pg_stat_statements ss
CROSS JOIN LATERAL (
SELECT p2.full_name, p2.relname
FROM partitioned p2
WHERE ss.query ILIKE '%' || p2.relname || '%'
LIMIT 1
) p
WHERE ss.mean_exec_time > 200
AND ss.rows / NULLIF(ss.calls, 0) > 1000
ORDER BY ss.total_exec_time DESC
LIMIT 25;
-- 5. TABLE BLOAT SUMMARY
-- Dead tuple accumulation as a percentage of total rows.
\echo '--- [5] TABLE BLOAT SUMMARY ---'
SELECT
n.nspname AS schema_name,
c.relname AS table_name,
pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
psut.n_live_tup AS live_rows,
psut.n_dead_tup AS dead_rows,
CASE WHEN (psut.n_live_tup + psut.n_dead_tup) > 0
THEN ROUND(100.0 * psut.n_dead_tup
/ (psut.n_live_tup + psut.n_dead_tup), 1)
ELSE 0 END AS dead_pct,
psut.last_autovacuum,
'VACUUM ANALYZE ' || n.nspname || '.' || c.relname || ';'
AS suggested_action
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_stat_user_tables psut ON psut.relid = c.oid
WHERE c.relkind = 'r'
AND n.nspname NOT IN ('pg_catalog','pg_toast','information_schema')
AND psut.n_dead_tup > 1000
AND CASE WHEN (psut.n_live_tup + psut.n_dead_tup) > 0
THEN 100.0 * psut.n_dead_tup
/ (psut.n_live_tup + psut.n_dead_tup)
ELSE 0 END >= 5.0
ORDER BY dead_pct DESC
LIMIT 40;
\echo 'Health check complete.'
\echo 'Stats reset on pg_stat_reset() or cluster restart.'
\echo 'For partition scan issues, also review the plan_cache_mode GUC.' The most urgent result to check is section 2c, the wraparound risk query. A table with an xid_age above 1.5 billion needs attention before anything else on this list — run VACUUM FREEZE on it immediately and investigate why autovacuum failed to keep up. For the partition scan results in section 4c, the query_preview column gives you enough of the query to run EXPLAIN (ANALYZE, BUFFERS) manually and confirm whether partition pruning is actually happening. The tell is seeing Seq Scan on tablename_p0001, tablename_p0002 repeated for every partition rather than a targeted scan against one or two.
Running These Regularly
A single execution of either script gives you a snapshot, but the DMVs and statistics views they query accumulate data since the last server restart or stats reset. The most useful signal comes from running them against a database that has been under representative production load for at least several days.
On SQL Server, consider scheduling the missing index and unused index sections to run weekly and logging results to a history table. DMV data resets on every restart, so capturing it before planned maintenance windows preserves the context you need to make good decisions. On PostgreSQL, a pg_cron job that calls pg_stat_reset() on a known monthly schedule means you always know the age of your statistics and can reason about trends rather than isolated snapshots. Separately, keep an eye on pg_stat_bgwriter to catch autovacuum falling behind under write load before it becomes a vacuum debt problem.
On both platforms, never apply missing index suggestions or drop unused indexes without validating against the full workload. A missing index DMV only knows about queries that have run since the last restart, and an unused index might be critical for an end of month report that simply has not executed yet in this cycle. The scripts surface the candidates. The judgment about what to act on remains yours.