metadlabs

Free tool · Postgres 12–18

Postgres health check

One read-only query that looks for the problems we find most often in database audits: wraparound risk, missing and unused indexes, bloat, vacuum lag, slow queries and risky settings. Paste the output below and you get a prioritized report, with the SQL to fix each finding.

30 checksfree · MIT · v1.0

  • One read-only SELECT: nothing installed, nothing written
  • RDS, Aurora, Cloud SQL, Azure, Supabase, Neon or your own servers
  • Output analyzed in your browser, never uploaded
  • Every finding comes with the SQL to fix it

How to run it

Three steps, about two minutes.

  1. 01

    Get the script

    Download it, or fetch it from a terminal. It's plain SQL, so read it first: that's the point of publishing it.

    bash
    curl -O https://metadlabs.com/tools/pg-health-check.sql
    pg-health-check.sql · 816 linesdownload
    + view the full script
    -- ============================================================================
    --  Postgres health check - Metad Labs - v1.0 (2026-09)
    --  https://metadlabs.com/tools/postgres-health-check
    --
    --  One read-only SELECT. It reads system catalogs and statistics views only:
    --  no writes, no extensions created, no settings changed. Safe on production.
    --
    --  Run it (psql shown; any SQL client works, e.g. pgAdmin, DBeaver, the
    --  Supabase / RDS / Neon SQL editors):
    --
    --    psql -X "$DATABASE_URL" -f pg-health-check.sql > health.txt
    --
    --  Tips
    --  * Connect as a superuser, or a role granted pg_monitor, for complete
    --    results. Other roles still work but see less of other users' sessions
    --    and queries.
    --  * Table and index checks cover the database you connect to. Run it once
    --    per database you care about.
    --  * Usage counters (index scans, query stats) accumulate since the last stats
    --    reset. The stats_age row tells you how long that window is.
    --  * Each row's check name is an anchor on the page above, with what it means
    --    and how to fix it. Paste the output there for a readable report; it is
    --    analyzed in your browser and never uploaded.
    --
    --  Supports PostgreSQL 12 and newer. MIT licensed.
    -- ============================================================================
    
    with
    cfg as (
      select
        current_setting('server_version_num')::int / 10000 as major,
        split_part(current_setting('server_version'), ' ', 1) as version,
        current_setting('block_size')::int as bs,
        (select setting::bigint from pg_settings where name = 'autovacuum_freeze_max_age') as freeze_max_age,
        (select setting::int from pg_settings where name = 'max_connections') as max_conn,
        pg_is_in_recovery() as standby,
        now() - pg_postmaster_start_time() as uptime
    ),
    db as (
      select d.*,
        extract(epoch from now() - coalesce(d.stats_reset, pg_postmaster_start_time())) / 86400.0 as days
      from pg_stat_database d
      where d.datname = current_database()
    ),
    setting as (
      select name, setting, unit,
        case when unit in ('B', 'kB', '8kB', 'MB') and setting ~ '^[0-9]+$'
             then pg_size_pretty(setting::bigint * case unit when 'kB' then 1024 when '8kB' then 8192 when 'MB' then 1048576 else 1 end)
             else setting || coalesce(unit, '') end as pretty
      from pg_settings
    ),
    
    -- ---------------------------------------------------------------- overview --
    c_server_version as (
      select 1 as n, 'server_version'::text as chk,
        case when eol < current_date then 'critical'
             when eol < current_date + 365 then 'warning'
             else 'ok' end as sev,
        'PostgreSQL ' || version as obj,
        case when eol < current_date
               then 'major version ' || major || ' reached end of life in ' || to_char(eol, 'Mon YYYY') || ' and gets no more security or bug fixes. Plan the upgrade now.'
             when eol < current_date + 365
               then 'community support for version ' || major || ' ends in ' || to_char(eol, 'Mon YYYY') || '. Start planning the upgrade.'
             when known
               then 'supported until ' || to_char(eol, 'Mon YYYY') || '. Keep up with minor releases for security fixes.'
             else 'a current major version. Keep up with minor releases for security fixes.' end as finding,
        0::numeric as ord
      from (
        select cfg.*, v.eol is not null as known,
          coalesce(v.eol, case when cfg.major < 12 then date '2000-01-01' else date '2099-01-01' end) as eol
        from cfg
        left join (values (12, date '2024-11-14'), (13, date '2025-11-13'), (14, date '2026-11-12'),
                          (15, date '2027-11-11'), (16, date '2028-11-09'), (17, date '2029-11-08'),
                          (18, date '2030-11-14'), (19, date '2031-11-13')) v(major, eol)
          on v.major = cfg.major
      ) x
    ),
    c_overview as (
      select 2 as n, 'overview'::text as chk, 'info'::text as sev, current_database()::text as obj,
        'database size ' || pg_size_pretty(pg_database_size(current_database()))
          || '; server up ' || justify_interval(date_trunc('minute', cfg.uptime))
          || case when cfg.standby then '; this is a read replica: write-side checks are limited' else '' end as finding,
        0::numeric as ord
      from cfg
    ),
    c_stats_age as (
      select 3 as n, 'stats_age'::text as chk,
        case when stats_reset > now() - interval '7 days' then 'warning' else 'info' end as sev,
        current_database()::text as obj,
        case when stats_reset is null
               then 'usage counters have never been reset, so they span the life of the database (or since its last crash recovery)'
             else 'usage counters were last reset on ' || to_char(stats_reset, 'YYYY-MM-DD') || ' (' || round(days) || ' days ago)'
               || case when stats_reset > now() - interval '7 days'
                       then '. That is too short a window to trust the unused-index and query results.' else '' end
        end as finding,
        0::numeric as ord
      from db
    ),
    
    -- ------------------------------------------------------ pg_stat_statements --
    pgss_loaded as (
      select exists (select 1 from setting where name = 'pg_stat_statements.max') as loaded
    ),
    pgss_src as (
      select n.nspname, e.extversion,
        exists (select 1 from pg_attribute a
                where a.attrelid = to_regclass(format('%I.pg_stat_statements', n.nspname))
                  and a.attname = 'total_exec_time') as exec_cols
      from pg_extension e
      join pg_namespace n on n.oid = e.extnamespace
      cross join pgss_loaded
      where e.extname = 'pg_stat_statements' and pgss_loaded.loaded
    ),
    pgss as (
      select x.*
      from pgss_src s,
      lateral xmltable('/table/row' passing query_to_xml(format($q$
          select queryid::text as queryid, calls, total_ms, mean_ms, rows, pct, query,
                 rank_total <= 10 as is_top, eligible and rank_mean <= 10 as is_slow
          from (
            select *,
              row_number() over (order by total_ms desc) as rank_total,
              row_number() over (partition by eligible order by mean_ms desc) as rank_mean
            from (
              select queryid, calls, rows,
                round(%1$s::numeric, 1) as total_ms,
                round(%2$s::numeric, 1) as mean_ms,
                coalesce(round((100 * %1$s / nullif(sum(%1$s) over (), 0))::numeric, 1), 0) as pct,
                left(regexp_replace(query, '[[:space:][:cntrl:]]+', ' ', 'g'), 90) as query,
                calls >= 10 and query !~* '^\s*(vacuum|analyze|create|alter|drop|copy|refresh|reindex|cluster|checkpoint|truncate|grant|revoke|comment|lock)\M' as eligible
              from %3$I.pg_stat_statements
              where dbid = (select oid from pg_database where datname = current_database())
            ) s
          ) r
          where rank_total <= 10 or (eligible and rank_mean <= 10)
        $q$,
        case when s.exec_cols then 'total_exec_time' else 'total_time' end,
        case when s.exec_cols then 'mean_exec_time' else 'mean_time' end,
        s.nspname), false, false, '')
        columns queryid text, calls numeric, total_ms numeric, mean_ms numeric, rows numeric,
                pct numeric, query text, is_top boolean, is_slow boolean) x
    ),
    c_pgss as (
      select 4 as n, 'pg_stat_statements'::text as chk,
        case when s.nspname is not null then 'ok' else 'warning' end as sev,
        'pg_stat_statements'::text as obj,
        case when s.nspname is not null
               then 'enabled (version ' || s.extversion || '), so the top_queries and slow_queries checks below are based on real query stats'
             when l.loaded
               then 'loaded but not installed in this database. Run CREATE EXTENSION pg_stat_statements; (no restart needed), then re-run this script for the query checks.'
             else 'not enabled, so the query checks were skipped. It is the single most useful Postgres performance tool: add it to shared_preload_libraries, restart, then CREATE EXTENSION pg_stat_statements;'
        end as finding,
        0::numeric as ord
      from pgss_loaded l
      left join pgss_src s on true
    ),
    c_top_queries as (
      select 28 as n, 'top_queries'::text as chk, 'info'::text as sev,
        'query ' || queryid || ': ' || query as obj,
        round(pct, 1) || '% of total query time; ' || to_char(calls, 'FM999,999,999,999,990') || ' calls; '
          || to_char(mean_ms, 'FM999,999,990.0') || ' ms avg; '
          || to_char(round(rows / nullif(calls, 0), 1), 'FM999,999,990.0') || ' rows/call' as finding,
        pct as ord
      from pgss where is_top
    ),
    c_slow_queries as (
      select 29 as n, 'slow_queries'::text as chk,
        case when mean_ms >= 1000 then 'warning' else 'info' end as sev,
        'query ' || queryid || ': ' || query as obj,
        to_char(mean_ms, 'FM999,999,990.0') || ' ms per call on average; '
          || to_char(calls, 'FM999,999,999,999,990') || ' calls; '
          || to_char(round(rows / nullif(calls, 0), 1), 'FM999,999,990.0') || ' rows/call' as finding,
        mean_ms as ord
      from pgss where is_slow and mean_ms >= 250
    ),
    
    -- ------------------------------------------------ vacuum and wraparound --
    xid as (
      select datname, age(datfrozenxid) as xid_age, mxid_age(datminmxid) as mxid_age,
        100.0 * greatest(age(datfrozenxid), mxid_age(datminmxid)) / 2147483648 as pct
      from pg_database
    ),
    c_xid_wraparound as (
      select 5 as n, 'xid_wraparound'::text as chk,
        case when pct >= 50 then 'critical' else 'warning' end as sev,
        datname::text as obj,
        case when xid_age >= mxid_age
               then 'transaction ID age ' || to_char(xid_age, 'FM999,999,999,990')
             else 'multixact ID age ' || to_char(mxid_age, 'FM999,999,999,990') end
          || ': ' || round(pct) || '% of the way to the forced wraparound shutdown. Find what is stopping vacuum from freezing (long transactions, abandoned replication slots or prepared transactions) and fix it.' as finding,
        pct as ord
      from xid where pct >= 25
      union all
      select 5, 'xid_wraparound', 'ok', 'all databases',
        'oldest is ' || datname || ' at ' || round(pct, 1) || '% of the wraparound limit', 0
      from (select * from xid order by pct desc limit 1) x
      where not exists (select 1 from xid where pct >= 25)
    ),
    frz as (
      select format('%I.%I', n.nspname, c.relname) as tbl,
        greatest(age(c.relfrozenxid), age(t.relfrozenxid)) as xid_age, c.relpersistence
      from pg_class c
      join pg_namespace n on n.oid = c.relnamespace
      left join pg_class t on t.oid = c.reltoastrelid
      where c.relkind in ('r', 'm')
    ),
    c_table_freeze_age as (
      (select 6 as n, 'table_freeze_age'::text as chk,
        case when xid_age >= 1073741824 then 'critical' else 'warning' end as sev,
        tbl as obj,
        'transaction ID age ' || to_char(xid_age, 'FM999,999,999,990') || ', '
          || round(xid_age::numeric / cfg.freeze_max_age, 1) || 'x autovacuum_freeze_max_age. '
          || case when relpersistence = 't'
                  then 'Temporary tables are never vacuumed by autovacuum: end the session that owns it.'
                  else 'Autovacuum should have frozen it by now. Check for blockers, or run VACUUM (FREEZE, VERBOSE) on it.' end as finding,
        xid_age::numeric as ord
      from frz, cfg
      where xid_age > 1.5 * cfg.freeze_max_age
      order by xid_age desc limit 10)
      union all
      select 6, 'table_freeze_age', 'ok', current_database(),
        'no table is overdue for freezing (all under 1.5x autovacuum_freeze_max_age)', 0
      from cfg
      where not exists (select 1 from frz where xid_age > 1.5 * cfg.freeze_max_age)
    ),
    long_tx as (
      select a.pid, a.usename, a.application_name, a.state, a.query,
        now() - a.xact_start as tx_age, now() - a.state_change as state_age
      from pg_stat_activity a
      where a.pid <> pg_backend_pid()
        and a.backend_type = 'client backend'
        and a.xact_start is not null
        and (a.xact_start < now() - interval '15 minutes'
             or (a.state like 'idle in transaction%' and a.state_change < now() - interval '5 minutes'))
    ),
    c_long_transactions as (
      (select 7 as n, 'long_transactions'::text as chk,
        case when tx_age > interval '1 hour' then 'critical' else 'warning' end as sev,
        'pid ' || pid || ' (' || coalesce(usename::text, '?') || coalesce('/' || nullif(application_name, ''), '') || ')' as obj,
        coalesce(state, 'unknown state') || '; transaction open ' || date_trunc('second', tx_age)
          || case when state like 'idle in transaction%' then ', idle for ' || date_trunc('second', state_age) else '' end
          || '; ' || coalesce(left(regexp_replace(query, '\s+', ' ', 'g'), 70), '') as finding,
        extract(epoch from tx_age)::numeric as ord
      from long_tx
      order by tx_age desc limit 10)
      union all
      select 7, 'long_transactions', 'ok', current_database(),
        'no transaction open longer than 15 minutes and nothing idle in transaction for over 5 minutes', 0
      where not exists (select 1 from long_tx)
    ),
    c_prepared_transactions as (
      select 8 as n, 'prepared_transactions'::text as chk, 'critical'::text as sev,
        gid::text as obj,
        'prepared ' || date_trunc('second', now() - prepared) || ' ago in database ' || database
          || ' and never committed or rolled back. It blocks vacuum and holds its locks until you run COMMIT PREPARED or ROLLBACK PREPARED.' as finding,
        extract(epoch from now() - prepared)::numeric as ord
      from pg_prepared_xacts
      where prepared < now() - interval '5 minutes'
      union all
      select 8, 'prepared_transactions', 'ok', current_database(), 'no forgotten prepared transactions', 0
      where not exists (select 1 from pg_prepared_xacts where prepared < now() - interval '5 minutes')
    ),
    slots as (
      select s.slot_name, s.slot_type, s.active, s.database,
        case when not cfg.standby then pg_wal_lsn_diff(pg_current_wal_lsn(), s.restart_lsn) end as retained,
        greatest(age(s.xmin), age(s.catalog_xmin)) as xmin_age
      from pg_replication_slots s, cfg
    ),
    c_replication_slots as (
      select 9 as n, 'replication_slots'::text as chk,
        case when not active and (coalesce(retained, 0) >= 1073741824 or coalesce(xmin_age, 0) >= 100000000) then 'critical'
             else 'warning' end as sev,
        slot_name::text as obj,
        case when active then 'active ' else 'INACTIVE ' end || slot_type || ' slot'
          || coalesce(' holding ' || pg_size_pretty(retained::bigint) || ' of WAL', '')
          || coalesce(', xmin age ' || to_char(xmin_age, 'FM999,999,999,990'), '')
          || case when active then '. Its consumer is falling behind.'
                  else '. Nothing is reading it, so WAL piles up until the disk fills, and it blocks vacuum. Drop it with pg_drop_replication_slot() if it is abandoned.' end as finding,
        coalesce(retained, 0)::numeric as ord
      from slots
      where not active or coalesce(retained, 0) >= 5368709120
      union all
      select 9, 'replication_slots', 'ok', current_database(),
        case when count(*) = 0 then 'no replication slots'
             else count(*) || ' replication slot(s), all active and keeping up' end, 0
      from slots
      having not bool_or(not active or coalesce(retained, 0) >= 5368709120) or count(*) = 0
    ),
    
    -- ----------------------------------------------------- sessions and locks --
    waits as (
      select a.pid, a.usename, a.wait_event, now() - a.query_start as waited,
        pg_blocking_pids(a.pid) as blockers, a.query
      from pg_stat_activity a
      where a.wait_event_type = 'Lock' and a.pid <> pg_backend_pid()
    ),
    c_lock_waits as (
      (select 10 as n, 'lock_waits'::text as chk,
        case when waited > interval '30 seconds' then 'warning' else 'info' end as sev,
        'pid ' || pid || ' (' || coalesce(usename::text, '?') || ')' as obj,
        'waiting on a ' || wait_event || ' lock for ' || date_trunc('second', waited)
          || coalesce(', blocked by pid ' || nullif(array_to_string(blockers, ', '), ''), '')
          || '; ' || coalesce(left(regexp_replace(query, '\s+', ' ', 'g'), 70), '') as finding,
        extract(epoch from waited)::numeric as ord
      from waits
      order by waited desc limit 10)
      union all
      select 10, 'lock_waits', 'ok', current_database(), 'no sessions waiting on locks right now', 0
      where not exists (select 1 from waits)
    ),
    conn as (
      select count(*) as total,
        count(*) filter (where state = 'active') as active,
        count(*) filter (where state = 'idle') as idle,
        count(*) filter (where state like 'idle in transaction%') as idle_tx
      from pg_stat_activity
      where backend_type = 'client backend'
    ),
    c_connections as (
      select 11 as n, 'connections'::text as chk,
        case when 100.0 * total / max_conn >= 90 then 'critical'
             when 100.0 * total / max_conn >= 75 then 'warning'
             when idle >= 50 and idle > 5 * active then 'info'
             else 'ok' end as sev,
        'max_connections = ' || max_conn as obj,
        total || ' of ' || max_conn || ' connections in use (' || round(100.0 * total / max_conn) || '%): '
          || active || ' active, ' || idle || ' idle, ' || idle_tx || ' idle in transaction'
          || case when idle >= 50 and idle > 5 * active
                  then '. Most are idle: a pooler such as PgBouncer, or smaller app-side pools, would cut memory use and headroom problems.'
                  else '' end as finding,
        0::numeric as ord
      from conn, cfg
    ),
    
    -- ---------------------------------------------------- tables and vacuum --
    dead as (
      select format('%I.%I', s.schemaname, s.relname) as tbl, s.n_live_tup, s.n_dead_tup,
        100.0 * s.n_dead_tup / nullif(s.n_live_tup + s.n_dead_tup, 0) as pct,
        greatest(s.last_vacuum, s.last_autovacuum) as last_vac,
        coalesce(c.reloptions::text ~ 'autovacuum_enabled=(false|off)', false) as av_off
      from pg_stat_user_tables s
      join pg_class c on c.oid = s.relid
      where s.n_dead_tup >= 10000
        and s.n_dead_tup >= 0.2 * (s.n_live_tup + s.n_dead_tup)
    ),
    c_autovacuum_lag as (
      (select 12 as n, 'autovacuum_lag'::text as chk, 'warning'::text as sev, tbl as obj,
        round(pct) || '% dead rows (' || to_char(n_dead_tup, 'FM999,999,999,990') || ' dead, '
          || to_char(n_live_tup, 'FM999,999,999,990') || ' live), last vacuum '
          || coalesce(to_char(last_vac, 'YYYY-MM-DD'), 'never')
          || case when av_off then '; autovacuum is DISABLED on this table' else '' end as finding,
        n_dead_tup::numeric as ord
      from dead
      order by n_dead_tup desc limit 10)
      union all
      select 12, 'autovacuum_lag', 'ok', current_database(),
        'autovacuum is keeping up: no table has more than 20% dead rows', 0
      where not exists (select 1 from dead)
    ),
    stale as (
      select format('%I.%I', schemaname, relname) as tbl, n_live_tup, n_mod_since_analyze,
        greatest(last_analyze, last_autoanalyze) as last_an
      from pg_stat_user_tables
      where n_live_tup >= 10000
        and (greatest(last_analyze, last_autoanalyze) is null
             or n_mod_since_analyze > 0.5 * n_live_tup)
    ),
    c_stale_statistics as (
      (select 13 as n, 'stale_statistics'::text as chk, 'warning'::text as sev, tbl as obj,
        case when last_an is null
               then 'never analyzed (' || to_char(n_live_tup, 'FM999,999,999,990') || ' rows), so the planner is guessing row counts. Run ANALYZE on it.'
             else to_char(n_mod_since_analyze, 'FM999,999,999,990') || ' rows changed since the last analyze on '
               || to_char(last_an, 'YYYY-MM-DD') || ' (' || round(100.0 * n_mod_since_analyze / n_live_tup) || '% of the table). Plans may be based on stale numbers.'
        end as finding,
        n_live_tup::numeric as ord
      from stale
      order by n_live_tup desc limit 10)
      union all
      select 13, 'stale_statistics', 'ok', current_database(), 'planner statistics look fresh on all tables over 10,000 rows', 0
      where not exists (select 1 from stale)
    ),
    bloat_cols as (
      select c.oid, n.nspname, c.relname, c.relpages, c.reltuples,
        coalesce((regexp_match(array_to_string(c.reloptions, ','), 'fillfactor=(\d+)'))[1]::int, 100) as ff,
        sum((1 - coalesce(s.null_frac, 0)) * coalesce(s.avg_width, 0)) as data_width,
        coalesce(max(s.null_frac), 0) > 0 as has_nulls,
        count(*) as natts,
        count(s.attname) as nstats
      from pg_class c
      join pg_namespace n on n.oid = c.relnamespace
      join pg_attribute a on a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped
      left join pg_stats s on s.schemaname = n.nspname and s.tablename = c.relname
                          and s.attname = a.attname and not s.inherited
      where c.relkind in ('r', 'm')
        and n.nspname not in ('pg_catalog', 'information_schema') and n.nspname !~ '^pg_toast'
        and c.relpages >= 1280
        and c.reltuples > 0
      group by c.oid, n.nspname, c.relname, c.relpages, c.reltuples, c.reloptions
    ),
    bloat as (
      select format('%I.%I', nspname, relname) as tbl, relpages::bigint * bs as size,
        greatest(relpages - est_pages, 0)::bigint * bs as wasted,
        greatest(100.0 * (relpages - est_pages) / relpages, 0) as pct
      from (
        select b.*, cfg.bs,
          -- 23-byte tuple header + null bitmap and the row data, each padded to 8 bytes,
          -- plus a 4-byte line pointer; the page keeps a 24-byte header and fillfactor free.
          ceil(b.reltuples / least(291, greatest(1, floor((cfg.bs - 24) * b.ff / 100.0 / (
            ceil((23 + case when b.has_nulls then ceil(b.natts / 8.0) else 0 end) / 8.0) * 8
            + ceil(b.data_width / 8.0) * 8 + 4))))) as est_pages
        from bloat_cols b, cfg
        where b.natts = b.nstats
      ) e
    ),
    c_table_bloat as (
      (select 14 as n, 'table_bloat'::text as chk,
        case when pct >= 50 and wasted >= 1073741824 then 'warning' else 'info' end as sev,
        tbl as obj,
        'about ' || round(pct) || '% bloat: ~' || pg_size_pretty(wasted) || ' of ' || pg_size_pretty(size)
          || ' is empty space (an estimate from planner statistics)' as finding,
        wasted::numeric as ord
      from bloat
      where pct >= 30 and wasted >= 104857600
      order by wasted desc limit 10)
      union all
      select 14, 'table_bloat', 'ok', current_database(),
        'no table over 10 MB has more than an estimated 30% (100 MB) of bloat', 0
      where not exists (select 1 from bloat where pct >= 30 and wasted >= 104857600)
    ),
    
    -- ------------------------------------------------------------- indexes --
    idx as (
      select i.indexrelid, i.indrelid, ic.relam, i.indisunique, i.indisprimary, i.indisvalid,
        i.indnkeyatts, i.indnatts,
        string_to_array(i.indkey::text, ' ')::int[] as keys,
        string_to_array(i.indclass::text, ' ') as cls,
        string_to_array(i.indcollation::text, ' ') as colls,
        string_to_array(i.indoption::text, ' ') as opts,
        coalesce(pg_get_expr(i.indexprs, i.indrelid), '') as exprs,
        coalesce(pg_get_expr(i.indpred, i.indrelid), '') as pred,
        exists (select 1 from pg_constraint k where k.conindid = i.indexrelid) as has_constraint,
        format('%I.%I', n.nspname, ic.relname) as idx_name,
        format('%I.%I', n.nspname, tc.relname) as tbl_name,
        pg_relation_size(i.indexrelid) as size,
        regexp_replace(pg_get_indexdef(i.indexrelid), '^.* USING ', '') as def
      from pg_index i
      join pg_class ic on ic.oid = i.indexrelid
      join pg_class tc on tc.oid = i.indrelid
      join pg_namespace n on n.oid = ic.relnamespace
      where n.nspname not in ('pg_catalog', 'information_schema') and n.nspname !~ '^pg_toast'
    ),
    unused as (
      select x.*, s.idx_scan
      from idx x
      join pg_stat_user_indexes s on s.indexrelid = x.indexrelid
      where s.idx_scan = 0 and x.indisvalid and not x.indisunique and not x.has_constraint
    ),
    c_unused_indexes as (
      (select 15 as n, 'unused_indexes'::text as chk,
        case when size >= 10485760 and (select stats_reset is null or stats_reset < now() - interval '7 days' from db)
             then 'warning' else 'info' end as sev,
        idx_name as obj,
        'no scans since stats reset; ' || pg_size_pretty(size) || '; on ' || tbl_name || '; ' || def as finding,
        size::numeric as ord
      from unused
      order by size desc limit 10)
      union all
      select 15, 'unused_indexes', 'info', '(total)',
        count(*) || ' unused indexes in all, ' || pg_size_pretty(sum(size)) || ' combined (only the 10 largest are listed)', -1
      from unused
      having count(*) > 10
      union all
      select 15, 'unused_indexes', 'ok', current_database(),
        'every non-unique index has been used since stats reset', 0
      where not exists (select 1 from unused)
    ),
    dups as (
      select tbl_name,
        array_agg(idx_name order by has_constraint desc, indisunique desc, indexrelid) as names,
        sum(size) - max(size) as saved,
        min(def) as def
      from idx
      where indisvalid
      group by indrelid, tbl_name, relam, keys, indnkeyatts, cls, colls, opts, exprs, pred
      having count(*) > 1
    ),
    c_duplicate_indexes as (
      select 16 as n, 'duplicate_indexes'::text as chk, 'warning'::text as sev, tbl_name as obj,
        'identical indexes ' || def || ': keep ' || names[1] || ', drop ' || array_to_string(names[2:], ', ')
          || ' (saves ~' || pg_size_pretty(saved) || ')' as finding,
        saved::numeric as ord
      from dups
      union all
      select 16, 'duplicate_indexes', 'ok', current_database(), 'no duplicate indexes', 0
      where not exists (select 1 from dups)
    ),
    redundant as (
      select l.idx_name, l.def, l.size, r.idx_name as by_name, r.def as by_def, l.tbl_name
      from idx l
      join idx r on r.indrelid = l.indrelid and r.indexrelid <> l.indexrelid and r.relam = l.relam
      where l.relam = (select oid from pg_am where amname = 'btree')
        and l.indisvalid and r.indisvalid
        and not l.indisunique and not l.has_constraint
        and l.exprs = '' and r.exprs = '' and l.pred = '' and r.pred = ''
        and l.indnatts = l.indnkeyatts
        and l.indnkeyatts < r.indnkeyatts
        and r.keys[1:l.indnkeyatts] = l.keys
        and r.cls[1:l.indnkeyatts] = l.cls
        and r.colls[1:l.indnkeyatts] = l.colls
        and r.opts[1:l.indnkeyatts] = l.opts
    ),
    c_redundant_indexes as (
      (select 17 as n, 'redundant_indexes'::text as chk, 'info'::text as sev, idx_name as obj,
        def || ' is a leading prefix of ' || by_name || ' ' || by_def || ', which can serve the same queries. Dropping it saves '
          || pg_size_pretty(size) || ' and speeds up writes.' as finding,
        size::numeric as ord
      from redundant
      order by size desc limit 10)
      union all
      select 17, 'redundant_indexes', 'ok', current_database(), 'no index is a plain prefix of another', 0
      where not exists (select 1 from redundant)
    ),
    c_invalid_indexes as (
      select 18 as n, 'invalid_indexes'::text as chk, 'warning'::text as sev, idx_name as obj,
        'INVALID on ' || tbl_name || ' (' || pg_size_pretty(size) || '), left by a failed or still-running CREATE INDEX CONCURRENTLY. Writes still maintain it, but queries never use it. Drop it and build it again.' as finding,
        size::numeric as ord
      from idx where not indisvalid
      union all
      select 18, 'invalid_indexes', 'ok', current_database(), 'no invalid indexes', 0
      where not exists (select 1 from idx where not indisvalid)
    ),
    fk as (
      select k.conname, k.conrelid, k.confrelid,
        format('%I.%I', n.nspname, c.relname) as tbl,
        format('%I.%I', fn.nspname, f.relname) as parent,
        (select string_agg(quote_ident(a.attname), ', ' order by u.ord)
           from unnest(k.conkey) with ordinality u(attnum, ord)
           join pg_attribute a on a.attrelid = k.conrelid and a.attnum = u.attnum) as cols,
        coalesce(nullif(pg_relation_size(k.conrelid), 0),
                 (select sum(pg_relation_size(p.relid)) from pg_partition_tree(k.conrelid) p)) as size
      from pg_constraint k
      join pg_class c on c.oid = k.conrelid
      join pg_namespace n on n.oid = c.relnamespace
      join pg_class f on f.oid = k.confrelid
      join pg_namespace fn on fn.oid = f.relnamespace
      where k.contype = 'f' and k.conparentid = 0
        and not exists (
          select 1 from pg_index i
          where i.indrelid = k.conrelid
            and i.indpred is null
            and array_length(k.conkey, 1) <= i.indnkeyatts
            and (string_to_array(i.indkey::text, ' ')::int2[])[1:array_length(k.conkey, 1)] @> k.conkey
            and (string_to_array(i.indkey::text, ' ')::int2[])[1:array_length(k.conkey, 1)] <@ k.conkey
        )
    ),
    c_missing_fk_indexes as (
      (select 19 as n, 'missing_fk_indexes'::text as chk,
        case when size >= 52428800 then 'warning' else 'info' end as sev,
        tbl || ' (' || cols || ')' as obj,
        'foreign key ' || quote_ident(conname) || ' to ' || parent || ' has no index on ' || tbl || ' ('
          || pg_size_pretty(size) || '). Every delete or key update on ' || parent || ' scans it, and joins on it cannot use an index.' as finding,
        size::numeric as ord
      from fk
      where size >= 1048576
      order by size desc limit 10)
      union all
      select 19, 'missing_fk_indexes', 'ok', current_database(), 'every foreign key on a table over 1 MB has a matching index', 0
      where not exists (select 1 from fk where size >= 1048576)
    ),
    seqs as (
      select format('%I.%I', schemaname, relname) as tbl, seq_scan, seq_tup_read, coalesce(idx_scan, 0) as idx_scan,
        pg_relation_size(relid) as size, seq_tup_read / seq_scan as avg_rows
      from pg_stat_user_tables
      where seq_scan >= 100
        and seq_tup_read / seq_scan >= 10000
        and pg_relation_size(relid) >= 10485760
    ),
    c_sequential_scans as (
      (select 20 as n, 'sequential_scans'::text as chk,
        case when seq_scan > idx_scan then 'warning' else 'info' end as sev,
        tbl as obj,
        to_char(seq_scan, 'FM999,999,999,990') || ' sequential scans reading ~' || to_char(avg_rows, 'FM999,999,999,990')
          || ' rows each, vs ' || to_char(idx_scan, 'FM999,999,999,990') || ' index scans (' || pg_size_pretty(size)
          || '). Some query filtering this table is probably missing an index.' as finding,
        seq_tup_read::numeric as ord
      from seqs
      order by seq_tup_read desc limit 10)
      union all
      select 20, 'sequential_scans', 'ok', current_database(), 'no table over 10 MB is being read by repeated large sequential scans', 0
      where not exists (select 1 from seqs)
    ),
    seq_all as (
      -- Built from catalogs by OID (like the pg_sequences view) so a role without
      -- access to some schemas still gets a result instead of an error.
      select c.oid, format('%I.%I', n.nspname, c.relname) as seq, ps.seqmax,
        has_sequence_privilege(c.oid, 'SELECT, USAGE') as readable
      from pg_sequence ps
      join pg_class c on c.oid = ps.seqrelid
      join pg_namespace n on n.oid = c.relnamespace
      where ps.seqincrement > 0
    ),
    seq_use as (
      select s.seq, lv.last_value, col.col, col.typ,
        least(s.seqmax, coalesce(col.type_max, s.seqmax)) as cap
      from seq_all s
      cross join lateral (select case when s.readable then pg_sequence_last_value(s.oid) end as last_value) lv
      left join lateral (
        select format('%I.%I', ns.nspname, t.relname) || '.' || quote_ident(a.attname) as col,
          format_type(a.atttypid, a.atttypmod) as typ,
          case a.atttypid when 'int2'::regtype then 32767 when 'int4'::regtype then 2147483647 end as type_max
        from pg_depend d
        join pg_attribute a on a.attrelid = d.refobjid and a.attnum = d.refobjsubid
        join pg_class t on t.oid = d.refobjid
        join pg_namespace ns on ns.oid = t.relnamespace
        where d.classid = 'pg_class'::regclass and d.refclassid = 'pg_class'::regclass
          and d.objid = s.oid and d.deptype in ('a', 'i')
        limit 1
      ) col on true
      where lv.last_value is not null
    ),
    c_sequence_exhaustion as (
      select 21 as n, 'sequence_exhaustion'::text as chk,
        case when 100.0 * last_value / cap >= 75 then 'critical' else 'warning' end as sev,
        coalesce(col, seq) as obj,
        round(100.0 * last_value / cap, 1) || '% used (' || to_char(last_value, 'FM999,999,999,999,999,990') || ' of '
          || to_char(cap, 'FM999,999,999,999,999,990') || coalesce(', column is ' || typ, '')
          || '). Inserts fail when it runs out: migrate the column to bigint before then.' as finding,
        (100.0 * last_value / cap)::numeric as ord
      from seq_use
      where 100.0 * last_value / cap >= 50
      union all
      select 21, 'sequence_exhaustion',
        case when hidden > 0 then 'info' else 'ok' end, current_database(),
        coalesce('highest is ' || (select coalesce(col, seq) || ' at ' || round(100.0 * last_value / cap, 1) || '% of its limit'
                                  from seq_use order by 1.0 * last_value / cap desc limit 1) || '. ',
                 case when hidden > 0 then '' else 'no sequences in use' end)
          || case when hidden > 0
                  then hidden || ' sequence(s) could not be read by this role: run as the owner or a superuser to check them'
                  else '' end, 0
      from (select count(*) filter (where not readable) as hidden from seq_all) h
      where not exists (select 1 from seq_use where 100.0 * last_value / cap >= 50)
    ),
    c_tables_without_pk as (
      (select 26 as n, 'tables_without_pk'::text as chk, 'info'::text as sev,
        format('%I.%I', n.nspname, c.relname) as obj,
        'no primary key (' || pg_size_pretty(pg_total_relation_size(c.oid)) || '). Logical replication, pg_repack and most ORMs need one, and duplicate rows can creep in.' as finding,
        pg_total_relation_size(c.oid)::numeric as ord
      from pg_class c
      join pg_namespace n on n.oid = c.relnamespace
      where c.relkind in ('r', 'p') and not c.relispartition
        and n.nspname not in ('pg_catalog', 'information_schema') and n.nspname !~ '^pg_toast'
        and not exists (select 1 from pg_constraint k where k.conrelid = c.oid and k.contype = 'p')
        and not exists (select 1 from pg_depend d where d.classid = 'pg_class'::regclass and d.objid = c.oid and d.deptype = 'e')
        and pg_total_relation_size(c.oid) >= 1048576
      order by pg_total_relation_size(c.oid) desc limit 10)
      union all
      select 26, 'tables_without_pk', 'ok', current_database(), 'every table over 1 MB has a primary key', 0
      where not exists (
        select 1 from pg_class c
        join pg_namespace n on n.oid = c.relnamespace
        where c.relkind in ('r', 'p') and not c.relispartition
          and n.nspname not in ('pg_catalog', 'information_schema') and n.nspname !~ '^pg_toast'
          and not exists (select 1 from pg_constraint k where k.conrelid = c.oid and k.contype = 'p')
          and not exists (select 1 from pg_depend d where d.classid = 'pg_class'::regclass and d.objid = c.oid and d.deptype = 'e')
          and pg_total_relation_size(c.oid) >= 1048576)
    ),
    
    -- ------------------------------------------------------ database activity --
    c_cache_hit_ratio as (
      select 22 as n, 'cache_hit_ratio'::text as chk,
        case when blks_hit + blks_read < 1000000 then 'info'
             when 100.0 * blks_hit / (blks_hit + blks_read) < 95 then 'warning'
             when 100.0 * blks_hit / (blks_hit + blks_read) < 99 then 'info'
             else 'ok' end as sev,
        current_database()::text as obj,
        case when blks_hit + blks_read < 1000000 then 'not enough reads yet to judge'
             else round(100.0 * blks_hit / (blks_hit + blks_read), 2) || '% of block reads served from shared_buffers ('
               || (select pretty from setting where name = 'shared_buffers') || ')'
               || case when 100.0 * blks_hit / (blks_hit + blks_read) < 99
                       then '. The working set may not fit in memory, or large scans are churning the cache.' else '' end
        end as finding,
        0::numeric as ord
      from db
    ),
    c_temp_files as (
      select 23 as n, 'temp_files'::text as chk,
        case when temp_bytes / greatest(days, 1) >= 1073741824 then 'warning'
             when temp_files > 0 then 'info' else 'ok' end as sev,
        current_database()::text as obj,
        case when temp_files = 0 then 'no queries have spilled to temporary files'
             else to_char(temp_files, 'FM999,999,999,990') || ' temp files, ' || pg_size_pretty(temp_bytes)
               || ' written (~' || pg_size_pretty((temp_bytes / greatest(days, 1))::bigint) || '/day). Sorts and hashes that exceed work_mem ('
               || (select pretty from setting where name = 'work_mem') || ') spill to disk.' end as finding,
        0::numeric as ord
      from db
    ),
    ckpt as (
      select x.timed, x.requested
      from xmltable('/table/row' passing query_to_xml(
        case when (select major from cfg) >= 17
             then 'select num_timed as timed, num_requested as requested from pg_stat_checkpointer'
             else 'select checkpoints_timed as timed, checkpoints_req as requested from pg_stat_bgwriter' end,
        false, false, '') columns timed numeric, requested numeric) x
    ),
    c_checkpoints as (
      select 24 as n, 'checkpoints'::text as chk,
        case when timed + requested >= 10 and requested > 0.2 * (timed + requested) then 'warning' else 'ok' end as sev,
        'max_wal_size = ' || (select pretty from setting where name = 'max_wal_size') as obj,
        to_char(requested, 'FM999,999,990') || ' of ' || to_char(timed + requested, 'FM999,999,990')
          || ' checkpoints were forced by WAL volume rather than the timer'
          || case when timed + requested >= 10 and requested > 0.2 * (timed + requested)
                  then '. Raising max_wal_size spreads checkpoint I/O out and cuts full-page writes.' else '' end as finding,
        0::numeric as ord
      from ckpt
    ),
    c_rollbacks as (
      select 25 as n, 'rollbacks'::text as chk,
        case when xact_commit + xact_rollback < 10000 then 'ok'
             when 100.0 * xact_rollback / (xact_commit + xact_rollback) >= 10 then 'warning'
             when 100.0 * xact_rollback / (xact_commit + xact_rollback) >= 2 then 'info'
             else 'ok' end as sev,
        current_database()::text as obj,
        round(100.0 * xact_rollback / nullif(xact_commit + xact_rollback, 0), 1) || '% of transactions rolled back ('
          || to_char(xact_rollback, 'FM999,999,999,990') || ')'
          || case when deadlocks > 0 then '; ' || deadlocks || ' deadlocks' else '; no deadlocks' end
          || case when xact_commit + xact_rollback >= 10000 and 100.0 * xact_rollback / (xact_commit + xact_rollback) >= 2
                  then '. A high rate usually means application errors or retries; check the Postgres log.' else '' end as finding,
        0::numeric as ord
      from db
    ),
    
    -- ------------------------------------------------------------- settings --
    flags as (
      select * from (values
        ('autovacuum', 'critical', 'off', 'autovacuum is off. Dead rows pile up and the database heads for wraparound. Turn it back on.'),
        ('fsync', 'critical', 'off', 'fsync is off: a crash can corrupt the database. Only acceptable for throwaway data.'),
        ('full_page_writes', 'critical', 'off', 'full_page_writes is off: a crash can leave torn pages. Turn it back on unless your storage guarantees atomic 8 kB writes.'),
        ('shared_buffers', 'warning', '16384', 'shared_buffers is still the 128 MB default. A common starting point is about 25% of RAM.'),
        ('work_mem', 'info', '4096', 'work_mem is the 4 MB default. Sorts and hashes bigger than that spill to disk (see temp_files).'),
        ('maintenance_work_mem', 'info', '65536', 'maintenance_work_mem is the 64 MB default, which makes vacuum and index builds slower. 512 MB to 1 GB is common.'),
        ('effective_cache_size', 'info', '524288', 'effective_cache_size is the 4 GB default. Set it to roughly 50-75% of RAM so the planner knows how much the OS caches.'),
        ('random_page_cost', 'info', '4', 'random_page_cost is 4, which assumes spinning disks. On SSD or cloud storage, 1.1 lets the planner pick index scans more often.'),
        ('track_io_timing', 'info', 'off', 'track_io_timing is off, so EXPLAIN and pg_stat_statements cannot show time spent on I/O. The overhead is small on modern hardware.'),
        ('idle_in_transaction_session_timeout', 'info', '0', 'idle_in_transaction_session_timeout is 0: a client that forgets to commit can block vacuum indefinitely. 5-15 minutes is a common safety net.'),
        ('log_min_duration_statement', 'info', '-1', 'slow queries are not logged. Setting log_min_duration_statement to 500-1000 ms gives you a record of them.')
      ) f(name, sev, bad, msg)
    ),
    c_settings as (
      select 27 as n, 'settings'::text as chk, f.sev, f.name as obj, f.msg as finding, 0::numeric as ord
      from flags f join setting s on s.name = f.name and s.setting = f.bad
      union all
      select 27, 'settings', case when s.setting::int > 1000 then 'warning' else 'info' end, 'max_connections',
        'max_connections is ' || s.setting || '. Each connection costs memory and adds contention; a pooler such as PgBouncer serves more clients with far fewer backends.', 0
      from setting s where s.name = 'max_connections' and s.setting::int > 500
      union all
      select 27, 'settings', 'ok', current_database(), 'no red flags in the core settings checked', 0
      where not exists (select 1 from flags f join setting s on s.name = f.name and s.setting = f.bad)
        and not exists (select 1 from setting s where s.name = 'max_connections' and s.setting::int > 500)
    ),
    c_largest_tables as (
      (select 30 as n, 'largest_tables'::text as chk, 'info'::text as sev,
        format('%I.%I', n.nspname, c.relname) as obj,
        pg_size_pretty(pg_total_relation_size(c.oid)) || ' total: table ' || pg_size_pretty(pg_relation_size(c.oid))
          || ', indexes ' || pg_size_pretty(pg_indexes_size(c.oid))
          || ', TOAST ' || pg_size_pretty(greatest(pg_total_relation_size(c.oid) - pg_relation_size(c.oid) - pg_indexes_size(c.oid), 0))
          || case when c.reltuples > 0 then '; ~' || to_char(c.reltuples, 'FM999,999,999,999,990') || ' rows' else '' end as finding,
        pg_total_relation_size(c.oid)::numeric as ord
      from pg_class c
      join pg_namespace n on n.oid = c.relnamespace
      where c.relkind in ('r', 'm')
        and n.nspname not in ('pg_catalog', 'information_schema') and n.nspname !~ '^pg_toast'
      order by pg_total_relation_size(c.oid) desc limit 5)
    ),
    
    results as (
      select * from c_server_version
      union all select * from c_overview
      union all select * from c_stats_age
      union all select * from c_pgss
      union all select * from c_xid_wraparound
      union all select * from c_table_freeze_age
      union all select * from c_long_transactions
      union all select * from c_prepared_transactions
      union all select * from c_replication_slots
      union all select * from c_lock_waits
      union all select * from c_connections
      union all select * from c_autovacuum_lag
      union all select * from c_stale_statistics
      union all select * from c_table_bloat
      union all select * from c_unused_indexes
      union all select * from c_duplicate_indexes
      union all select * from c_redundant_indexes
      union all select * from c_invalid_indexes
      union all select * from c_missing_fk_indexes
      union all select * from c_sequential_scans
      union all select * from c_sequence_exhaustion
      union all select * from c_cache_hit_ratio
      union all select * from c_temp_files
      union all select * from c_checkpoints
      union all select * from c_rollbacks
      union all select * from c_tables_without_pk
      union all select * from c_settings
      union all select * from c_top_queries
      union all select * from c_slow_queries
      union all select * from c_largest_tables
    )
    select
      sev as severity,
      chk as "check",
      -- One line per row, and no "|" so the output survives copy and paste.
      translate(regexp_replace(obj, '\s+', ' ', 'g'), '|', bar) as object,
      translate(regexp_replace(finding, '\s+', ' ', 'g'), '|', bar) as finding
    from results,
      -- chr(166) is a broken bar. Plain ASCII elsewhere so any server encoding works.
      (select case when getdatabaseencoding() = 'UTF8' then chr(166) else '/' end as bar) b
    order by case sev when 'critical' then 1 when 'warning' then 2 when 'info' then 3 else 4 end, n, ord desc, obj;
    
  2. 02

    Run it

    With psql, connected as a superuser or a role with pg_monitor. The -X skips your .psqlrc so the output format is predictable.

    bash
    psql -X "$DATABASE_URL" -f pg-health-check.sql > health.txt

    Postgres in Docker:

    bash
    docker exec -i my-postgres psql -X -U postgres -d mydb < pg-health-check.sql > health.txt

    Using a GUI (pgAdmin, DBeaver, DataGrip, TablePlus) or your provider's SQL editor (Supabase, Neon, RDS query editor)? Open the file, run it as a whole, and copy all result rows with headers.

  3. 03

    Paste the output

    Paste it into the box below, or drop the file on it. You get the findings ranked by severity, with what each one means and the SQL to fix it.

    go to the analyzer ↓

Analyze

Paste the output, get a report.

No output handy? Try the sample. It's real output from a demo database we seeded with the usual suspects.

health.txt

Analyzed in your browser. Nothing is uploaded unless you choose to send it to us.

What it checks

All 30 checks, explained.

What each check looks at, why it matters, and how to fix it. The check name in the script's output links here.

Outage risks

Things that take a database down or stop writes.

Transaction ID wraparound

xid_wraparound

How far each database is towards the roughly 2.1 billion transaction ID limit (and the separate multixact limit). Warning at 25%, critical at 50%.

Why it matters. Postgres stores transaction IDs in 32 bits, and vacuum has to “freeze” old rows before the counter wraps. If it can’t, Postgres stops accepting writes to protect your data, and recovery means a long single-user-mode vacuum. It is one of the few ways a healthy-looking database goes down with no warning.

How to fix. Something is stopping vacuum from freezing. The usual causes are a long-running or idle-in-transaction session, an abandoned replication slot, or a forgotten prepared transaction, and the checks below catch all three. Clear the blocker, then let autovacuum catch up or run VACUUM (FREEZE) on the oldest tables.

Tables overdue for freezing

table_freeze_age

Tables whose oldest unfrozen transaction ID is more than 1.5× autovacuum_freeze_max_age. Autovacuum starts a forced anti-wraparound vacuum at 1×, so a table this far past it means that vacuum is failing or never finishing.

Why it matters. These are the tables that will cause a wraparound shutdown. Catching them at 300 million is a routine fix; catching them at 2 billion is an outage.

How to fix. Check for blockers (long transactions, slots, prepared transactions). Then vacuum the table directly, at a quiet time if it is large. Raising autovacuum_vacuum_cost_limit, or giving busy tables their own cost settings, helps autovacuum keep up.

Sequences running out

sequence_exhaustion

Sequences, and the integer columns they feed, that have used more than 50% of their range. Critical at 75%. Most often an `integer` (serial) id column heading for 2,147,483,647.

Why it matters. When the sequence reaches the column’s limit, every insert fails with “integer out of range” (or “reached maximum value of sequence”). This is a common way that fast-growing tables break years after launch.

How to fix. Migrate the column (and any foreign keys pointing at it) to bigint before it runs out. On small tables a direct ALTER is fine. On big ones, add a bigint column, backfill in batches and swap, because ALTER COLUMN TYPE rewrites the table under an exclusive lock.

Replication slots

replication_slots

Replication slots that are inactive, or that are active but more than 5 GB behind.

Why it matters. A slot makes the server keep every WAL segment its consumer hasn’t confirmed. An abandoned slot (an old replica, a CDC tool you turned off, a Debezium test) keeps WAL piling up until the disk fills and the database stops. It also holds back vacuum.

How to fix. If nothing will ever read from the slot again, drop it. If a consumer is just behind, find out why. On Postgres 13+, max_slot_wal_keep_size caps how much WAL a slot can hold.

Forgotten prepared transactions

prepared_transactions

Two-phase-commit transactions (PREPARE TRANSACTION) that have been waiting for more than 5 minutes.

Why it matters. A prepared transaction survives restarts and holds its locks and its snapshot until someone commits or rolls it back. A forgotten one blocks vacuum everywhere and can lead to wraparound.

How to fix. Find out which application or transaction manager created it. If it is truly orphaned, roll it back (or commit it, if you know it should have committed).

Long-running transactions

long_transactions

Sessions with a transaction open for more than 15 minutes, or sitting “idle in transaction” for more than 5. Critical after an hour.

Why it matters. While a transaction is open, vacuum can’t remove any row version that it might still see, anywhere in the database. One forgotten BEGIN in a console or a stuck worker can bloat every busy table and hold locks that block migrations.

How to fix. Find what opened it (application_name, client address) and fix the code path that doesn’t commit. Set idle_in_transaction_session_timeout as a safety net. For a one-off, cancel the query or terminate the session.

Sessions waiting on locks

lock_waits

Sessions that are blocked on a lock right now, and which sessions are blocking them. A warning once a wait passes 30 seconds.

Why it matters. Lock queues cascade. A migration waiting for an ACCESS EXCLUSIVE lock behind one slow query blocks every later query on that table, which is how a “quick ALTER TABLE” takes a site down.

How to fix. Look at the blocking session first: it is usually a long transaction or an idle-in-transaction session. For migrations, set lock_timeout (e.g. 5s) so DDL gives up instead of queueing everyone behind it.

Connection usage

connections

Client connections in use vs max_connections, broken down by state. Warning at 75%, critical at 90%. It also points out when most connections are idle.

Why it matters. When you hit max_connections, new connections are refused, which looks like a full outage to the app. Each connection is a process using memory, so hundreds of idle connections cost RAM and CPU even when they do nothing.

How to fix. Put a pooler in front (PgBouncer in transaction mode, or your platform’s built-in one) and shrink application pool sizes. Raising max_connections is rarely the right fix.

Vacuum & bloat

Dead rows, stale statistics and wasted space.

Autovacuum falling behind

autovacuum_lag

Tables where at least 20% of rows are dead (and at least 10,000 of them), with the last vacuum time and whether autovacuum is disabled on the table.

Why it matters. Dead rows make scans and indexes bigger and slower, and the space isn’t reused until vacuum runs. It is usually a sign that autovacuum’s defaults are too gentle for a busy table, or that something is blocking it.

How to fix. Vacuum the table now, then tune autovacuum for it: a lower scale factor on big tables means it runs after thousands of changes instead of 20% of the table. If autovacuum is disabled on the table, turn it back on.

Stale planner statistics

stale_statistics

Tables over 10,000 rows that have never been analyzed, or where more than half the rows changed since the last analyze.

Why it matters. The planner chooses joins and indexes from row-count estimates. With stale stats it can pick a nested loop over millions of rows, the classic “the query was fast yesterday” plan flip.

How to fix. Run ANALYZE on the table. If it keeps going stale, lower autovacuum_analyze_scale_factor for it. After a bulk load or restore, always run ANALYZE yourself.

Table bloat

table_bloat

Tables over 10 MB where an estimate from planner statistics says at least 30% (and 100 MB) is empty space. It is an estimate: accurate to a few percent on typical tables, less so on tables with very wide or heavily compressed columns.

Why it matters. Empty space isn’t always bad, because Postgres reuses it for new rows. But a table that is 60% empty makes sequential scans read twice the pages, wastes cache, and often means vacuum fell behind at some point.

How to fix. If the table will grow back into the space, fix the vacuum problem and leave it. To reclaim it, use pg_repack (online) or VACUUM FULL (which locks the table for the whole rewrite). Confirm with the pgstattuple extension first.

Indexes

Indexes that are missing, unused, duplicated or broken.

Foreign keys without an index

missing_fk_indexes

Foreign keys on tables over 1 MB where no index starts with the foreign key columns. Warning when the table is over 50 MB.

Why it matters. Postgres indexes the referenced (parent) side automatically, not the referencing side. Without an index, every delete or key update on the parent scans the whole child table, often while holding locks, and joins on that column can’t use an index.

How to fix. Create the index concurrently so writes aren’t blocked while it builds.

Large tables read by sequential scans

sequential_scans

Tables over 10 MB that have been sequentially scanned at least 100 times, reading 10,000+ rows each time. Warning when they get more sequential scans than index scans.

Why it matters. A query that reads a whole large table to return a few rows is the most common cause of a slow endpoint and high database CPU. It usually means a missing index on the column in the WHERE clause.

How to fix. Find the queries touching this table in pg_stat_statements (top_queries helps), run EXPLAIN (ANALYZE, BUFFERS) on them, and index the filter columns. Seq scans are fine for reports that read most of the table.

Unused indexes

unused_indexes

Non-unique indexes with zero scans since statistics were last reset, largest first. Indexes that back a constraint are never listed.

Why it matters. Every index makes every insert and update slower, takes disk and cache space, and adds to vacuum and backup time. Unused ones cost all that for nothing.

How to fix. Check before dropping: the counters are per server, so an index used only on a read replica shows zero here, and a short stats window (see stats_age) can miss monthly jobs. If it is unused everywhere, drop it concurrently. Keeping the CREATE INDEX statement makes it easy to recreate.

Duplicate indexes

duplicate_indexes

Two or more indexes on the same table with exactly the same columns, operator classes, expressions and predicate.

Why it matters. A duplicate doubles the write and storage cost and gives nothing back. They usually come from an ORM migration and a hand-written index doing the same thing.

How to fix. Keep the one that backs a constraint (or the unique one) and drop the rest.

Redundant (prefix) indexes

redundant_indexes

B-tree indexes whose columns are a leading prefix of another index on the same table, e.g. (customer_id) next to (customer_id, created_at).

Why it matters. The wider index can answer the same lookups, so the narrow one mostly adds write cost. It is informational because the narrow index is smaller and occasionally faster for heavy lookups.

How to fix. Usually safe to drop the narrower index. Check it isn’t unique or used for something the wider one can’t do.

Invalid indexes

invalid_indexes

Indexes marked invalid, which is what a failed CREATE INDEX CONCURRENTLY or REINDEX CONCURRENTLY leaves behind (or one still being built).

Why it matters. An invalid index is still updated on every write but never used by queries: pure cost. If it was meant to be unique, the uniqueness isn’t enforced either.

How to fix. Make sure no build is still running, drop it, fix the cause (often duplicate values for a unique index, or a lock timeout), and build it again.

Queries & I/O

Where query time goes, and what spills to disk.

pg_stat_statements

pg_stat_statements

Whether the pg_stat_statements extension is loaded and installed in this database. The top_queries and slow_queries checks need it.

Why it matters. It records total time, calls and rows for every normalized query, so you can see what the database actually spends its time on. Without it, you are guessing which query to optimize.

How to fix. Add it to shared_preload_libraries and restart, then create the extension in each database. On managed services it is usually a parameter-group flag (RDS, Aurora, Cloud SQL, Azure) or already on (Supabase, Neon).

Top queries by total time

top_queries

The 10 queries that used the most total execution time in this database, with their share of the total, calls, average time and rows per call.

Why it matters. Total time is what uses up your database’s capacity. A 2 ms query called 5 million times can matter more than one 3-second report. Optimizing the top two or three often cuts overall load in half.

How to fix. Get the full query text by queryid, run EXPLAIN (ANALYZE, BUFFERS) with realistic parameters, and look for sequential scans, big sorts and bad row estimates. For very frequent fast queries, caching or batching (N+1 loops) often beats tuning SQL.

Slowest queries per call

slow_queries

Queries called at least 10 times that average 250 ms or more (maintenance commands excluded). Warning at 1 second.

Why it matters. These are the queries users feel: page loads, API timeouts, background jobs piling up. They also hold connections longer, which eats into your pool.

How to fix. Same approach as top queries: EXPLAIN (ANALYZE, BUFFERS), then fix the plan with an index, a rewrite or fresher statistics. If it is a report, move it to a replica or precompute it.

Temp file spills

temp_files

How much data queries wrote to temporary files since the stats reset, because a sort or hash didn’t fit in work_mem. Warning at more than 1 GB a day.

Why it matters. Spilling to disk makes sorts, hash joins and aggregates many times slower, and heavy temp I/O competes with everything else.

How to fix. Find the queries that spill, and fix them first (an index can remove a sort entirely). Then raise work_mem for the roles or sessions that need it. Raising it globally multiplies by connections × sort nodes, so be careful.

Cache hit ratio

cache_hit_ratio

The share of block reads served from shared_buffers rather than the OS or disk. Warning under 95%, a note under 99%.

Why it matters. For a transactional app, most reads should come from memory. A low ratio means the working set doesn’t fit, or big scans keep evicting hot data. It is a symptom, not a cause: the OS page cache still catches many misses.

How to fix. Look at sequential_scans and top_queries first, because removing big scans usually fixes it. If the hot data really is bigger than memory, raise shared_buffers or move to a bigger instance.

Configuration

Version, settings and schema hygiene.

Postgres version

server_version

The server’s major version and its community end-of-life date. Critical after end of life, warning within a year of it.

Why it matters. After end of life there are no security or bug fixes. Each new major version also brings real performance gains, and upgrading across several versions at once is harder than keeping up.

How to fix. Plan a major upgrade (pg_upgrade, logical replication, or your provider’s blue/green upgrade). Between majors, keep up with minor releases: they are bug and security fixes only.

Settings red flags

settings

Core settings left at defaults that rarely suit production, plus dangerous ones: autovacuum, fsync or full_page_writes turned off, or max_connections over 500.

Why it matters. The defaults are sized to boot on almost anything, not for your server. shared_buffers at 128 MB on a 64 GB machine leaves most of the RAM unused by Postgres itself.

How to fix. Tune for your hardware and workload. The values in the fix are common starting points, not rules. On managed services, change them in the parameter group or dashboard instead of ALTER SYSTEM.

Forced checkpoints

checkpoints

The share of checkpoints triggered by WAL volume (max_wal_size) rather than the timer. Warning above 20%.

Why it matters. Frequent forced checkpoints cause I/O spikes and extra WAL, because every page’s first change after a checkpoint writes a full page image. They show up as periodic latency bumps.

How to fix. Raise max_wal_size so checkpoints are driven by checkpoint_timeout instead. It only costs disk space for WAL and a slightly longer crash recovery.

Rollbacks and deadlocks

rollbacks

The share of transactions that rolled back, and the deadlock count, since the stats reset. Warning above 10%.

Why it matters. A high rollback rate usually means application errors (constraint violations, serialization failures, timeouts) that are being retried or ignored. Some frameworks roll back read-only transactions by design, which is harmless.

How to fix. Check the Postgres log for the errors behind the rollbacks, and your app’s error tracking. For deadlocks, make code paths lock rows in a consistent order.

Tables without a primary key

tables_without_pk

Tables over 1 MB with no primary key (extension-owned tables are skipped).

Why it matters. Logical replication can’t replicate updates and deletes without one, and neither can most zero-downtime upgrade tools. pg_repack refuses to run, ORMs struggle, and duplicate rows creep in.

How to fix. Add a primary key on an existing unique, non-null column, or add an identity column. On a big table, build the unique index concurrently first, then attach it with ADD PRIMARY KEY USING INDEX.

Context

Background numbers that help you read the rest.

Overview

overview

Database size, server uptime, and whether this is a read replica.

Why it matters. Useful context for everything else, since a replica shows its own index and query usage, not the primary’s.

How to fix. Nothing to fix.

Statistics window

stats_age

When the usage counters for this database were last reset. Warning if that was under 7 days ago.

Why it matters. Index scans, query stats, temp files and rollbacks are all counted since this point. A reset yesterday means “unused” indexes may just be used by weekly jobs.

How to fix. Wait for a representative window (at least a week, ideally a month) before dropping indexes based on this report.

Largest tables

largest_tables

The five biggest tables, split into heap, indexes and TOAST.

Why it matters. Where your storage goes, and where bloat, missing indexes and vacuum lag cost the most.

How to fix. Nothing to fix. If indexes are bigger than the table, check the index findings above.

FAQ

Before you run it.

Found a bug or want a check added? Email hello@metadlabs.com.

Is it safe to run on production?

Yes. It is a single SELECT over system catalogs and statistics views: it writes nothing, installs nothing and changes no settings. It usually finishes in a second or two. Like any query that reads table sizes, it briefly takes the same shared lock a SELECT would, so if a migration is holding an exclusive lock on a table it waits behind it. To cap that, run it with PGOPTIONS='-c statement_timeout=60s'.

Does my data leave my machine?

The script runs on your database and prints its results to you. This page reads pasted output with JavaScript in your browser and uploads nothing. The only exception is if you choose to send the summary to us for a review, which emails it to our engineers.

What permissions does it need?

Any role can run it. A superuser, or a role granted pg_monitor, sees everything, including other users' sessions and query text. The sequence check also needs SELECT on the sequences, which the table owner or a superuser has. On RDS and Aurora, the master user is enough.

Which Postgres versions and platforms does it support?

PostgreSQL 12 through 18. We test every release on 12, 13, 14, 16, 17 and 18. It only uses standard catalog views, so it works on managed services too. On Aurora, the checkpoint and bloat checks mean less, because Aurora's storage layer works differently.

It ran against one database. What about the others?

Cluster-wide checks (wraparound, connections, replication slots, prepared transactions, settings) cover the whole server. Table, index and query checks cover the database you connect to, so run it once per database that matters.

How often should I run it?

Monthly, and before and after big changes like a major upgrade, a traffic launch or a large migration. Keep the output files: comparing two runs shows what is getting worse.

Can we use it in our own tooling?

Yes. It is MIT licensed. Put it in your repo, run it from CI or a cron job, and alert on critical rows.

Found more than your team has time to fix?

The Metad Diagnostic goes past the snapshot: 5 days on your real workload, quick wins shipped as PRs, and a ranked plan for the rest. $2,500, fixed.

Newsletter

New checks, and what they catch

We add checks as we see new failure modes in the field. Get them, plus our Postgres and performance write-ups, about every two weeks.

No pitch. Unsubscribe from any email.