Maintenance

PostgreSQL Health Check on Ubuntu: A Read-Only Script That Finds the Usual Problems

PG Monitoring Team August 05, 2026 15 min read

Most PostgreSQL problems are not exotic. They are a shared_buffers nobody ever raised from the 128MB default, an abandoned replication slot quietly filling the disk, a foreign key with no index turning deletes into full table scans, and archive_mode that has been off since installation. This script finds those in about a minute, without changing anything.

A small gift from our team: the script is below, ready to copy or download directly. It is read-only — it runs SELECT and SHOW only, never writes, never changes a setting, and never sends anything anywhere.

Why read-only matters

Plenty of "tuning scripts" apply changes for you. Do not run those on a database you care about: a parameter that helps one workload hurts another, and ALTER SYSTEM executed by a script you skimmed is how a Monday morning gets ruined.

This one only reads. Every finding comes with the command that would fix it, and you decide whether it applies. Concretely, the script:

  • runs only SELECT and SHOW against catalogs and statistics views;
  • opens no network connections — nothing is transmitted, ever;
  • writes no file unless you explicitly ask for a report;
  • exits non-zero when something critical was found, so it works in a cron or CI check.

The heaviest queries are bounded catalog scans. On a large cluster the index and foreign-key checks are the slowest, and even those are cheap compared to a single application query.

What it checks

27 checks in ten groups. The thresholds are the ones we use in production monitoring, not arbitrary round numbers.

GroupChecks for
Version and uptimeEnd-of-life majors; a restart too recent for statistics to mean anything
Memoryshared_buffers vs RAM, work_mem worst-case exhaustion, effective_cache_size
Planner and storagerandom_page_cost on SSD, checkpoints forced by WAL volume
ConnectionsUsage vs limit, idle-in-transaction, queries over 60s, lock waits
CacheCache hit ratio, temporary files spilled to disk
AutovacuumAutovacuum disabled, bloated tables, missing statistics, transaction ID wraparound
IndexesLarge unused indexes, foreign keys with no index, duplicates
ReplicationReplica lag, abandoned replication slots
Backup and WALarchive_mode, archiving failures, pg_wal growth, data checksums
Securitytrust authentication, md5 passwords, superuser count, SSL, slow query logging

The findings that matter most

Four of these deserve explanation, because they are the ones that cause real incidents and the ones people are most surprised to find.

Transaction ID wraparound

PostgreSQL transaction IDs are 32-bit and wrap around. To protect your data, the server refuses all writes as the limit approaches — a full outage that arrives without warning if nobody was watching. The script reports the age of the oldest un-frozen transaction and flags it well before that point. The usual root cause is not autovacuum being slow but something blocking it: a transaction open for days, an abandoned replication slot, or a forgotten prepared transaction.

Abandoned replication slots

A replication slot tells the server to retain WAL until its consumer has read it. If that consumer disappears — a decommissioned replica, a logical subscriber someone deleted, a crashed backup — the slot stays, and the server retains WAL forever. It accumulates until pg_wal fills the filesystem and the database stops. This is one of the most common ways a healthy PostgreSQL server goes down, and it is entirely preventable.

Foreign keys with no index

PostgreSQL indexes the parent side of a foreign key automatically, but not the child side. So every DELETE or key UPDATE on the parent scans the entire referencing table to verify the constraint. It is invisible while tables are small and brutal once they are not. We have seen a single missing index here take a delete from 47 seconds to under a millisecond.

Unused indexes need a full business cycle before you judge them. idx_scan = 0 means "not used since statistics were last reset" — which may be since the last restart. An index that only serves month-end closing looks unused for 29 days. Check pg_stat_reset_time, and confirm across a complete cycle before dropping anything.

The 128MB shared_buffers

The default suits a laptop, not a server. On a machine with 32 GB of RAM it means the database keeps almost nothing in its own cache and re-reads from the OS constantly. The script computes the value as a percentage of physical RAM and suggests roughly 25%, which is the accepted starting point for most workloads.

Requirements

Run it as the postgres operating-system user, which authenticates locally via peer and needs no password:

sudo apt update
sudo apt install -y postgresql-client

sudo install -m 750 -o postgres -g postgres   postgresql-health-check-ubuntu.sh /usr/local/bin/

sudo -u postgres /usr/local/bin/postgresql-health-check-ubuntu.sh

A few checks — pg_authid password hashes, pg_hba_file_rules — need superuser or the pg_read_all_settings role. Without it the script degrades gracefully: those checks stay silent rather than failing the run. The memory checks read /proc/meminfo, so they report only on Linux.

# Inspect a specific database for the table and index checks
sudo -u postgres DATABASE=app /usr/local/bin/postgresql-health-check-ubuntu.sh

# A remote server (the role needs pg_monitor for full coverage)
sudo -u postgres PGHOST=db.internal PGUSER=monitor   /usr/local/bin/postgresql-health-check-ubuntu.sh

Reading the output

Each finding is [CRITICAL], [WARNING] or [OK], followed by why it matters and the command that addresses it:

── Backup and WAL
  [CRITICAL] WAL archiving is disabled (archive_mode = off)
              Point-in-time recovery is impossible. Recovery is limited
              to whatever your last dump captured.
              fix: Enable archive_mode and archive_command

  [   OK   ] pg_wal size: 224 MB (14 segments)

── Security
  [CRITICAL] 4 pg_hba.conf rules use 'trust' for non-local connections
              Anyone who can reach the port connects as any user, with no password.
              fix: Replace trust with scram-sha-256 in pg_hba.conf and reload.

══ Summary ══

  Critical : 2    need attention now
  Warnings : 8    should be planned
  Passed   : 17   of 27 checks

Because it exits non-zero when a critical finding exists, it also works unattended:

# Weekly check, emailed only when something critical is found
0 8 * * 1 /usr/local/bin/postgresql-health-check-ubuntu.sh > /tmp/hc.txt 2>&1   || mail -s "PostgreSQL health check: critical findings" dba@example.com < /tmp/hc.txt

The complete script

Save as postgresql-health-check-ubuntu.sh, or download the ready-to-run file. It is long because each check carries its own explanation and fix — that is the point.

#!/usr/bin/env bash
#
# Read-only health check for a PostgreSQL environment on Ubuntu.
#
# Usage:
#   sudo -u postgres ./postgresql-health-check-ubuntu.sh
#   sudo -u postgres DATABASE=app ./postgresql-health-check-ubuntu.sh
#   sudo -u postgres FULL_REPORT=1 ./postgresql-health-check-ubuntu.sh
#
set -Eeuo pipefail

CONTACT_NAME="${CONTACT_NAME:-João Victor Oliveira — PG Monitoring}"
CONTACT_WHATSAPP="${CONTACT_WHATSAPP:-+55 62 98156-1666}"
CONTACT_EMAIL="${CONTACT_EMAIL:-joao.victor.32@hotmail.com}"
CONTACT_SITE="${CONTACT_SITE:-https://pgmonitoring.com}"

DATABASE="${DATABASE:-}"
FULL_REPORT="${FULL_REPORT:-0}"

CRITICAL_COUNT=0
WARNING_COUNT=0
OK_COUNT=0
FINDINGS_FILE="$(mktemp)"
trap 'rm -f "$FINDINGS_FILE"' EXIT

if [[ -t 1 ]]; then
  BOLD=$'\033[1m'; RED=$'\033[31m'; YELLOW=$'\033[33m'
  GREEN=$'\033[32m'; CYAN=$'\033[36m'; RESET=$'\033[0m'
else
  BOLD=''; RED=''; YELLOW=''; GREEN=''; CYAN=''; RESET=''
fi

fail() { echo "ERROR: $*" >&2; exit 1; }
section() { printf '\n%s── %s %s\n' "$BOLD$CYAN" "$1" "$RESET"; }

# report <CRITICAL|WARNING|OK> <title> <detail> [fix]
report() {
  local level="$1" title="$2" detail="$3" fix="${4:-}"
  case "$level" in
    CRITICAL) CRITICAL_COUNT=$((CRITICAL_COUNT + 1))
              printf '  %s[CRITICAL]%s %s\n' "$RED$BOLD" "$RESET" "$title" ;;
    WARNING)  WARNING_COUNT=$((WARNING_COUNT + 1))
              printf '  %s[WARNING ]%s %s\n' "$YELLOW" "$RESET" "$title" ;;
    OK)       OK_COUNT=$((OK_COUNT + 1))
              printf '  %s[   OK   ]%s %s\n' "$GREEN" "$RESET" "$title" ;;
  esac
  [[ -n "$detail" ]] && printf '              %s\n' "$detail"
  [[ -n "$fix" ]] && printf '              %sfix:%s %s\n' "$BOLD" "$RESET" "$fix"
  printf '%s\t%s\t%s\t%s\n' "$level" "$title" "$detail" "$fix" >> "$FINDINGS_FILE"
}

q()  { psql -XAtq --no-password -d "${PGDATABASE:-postgres}" -c "$1" 2>/dev/null || echo ""; }
qd() { psql -XAtq --no-password -d "$DATABASE" -c "$1" 2>/dev/null || echo ""; }

# Comparisons that tolerate an empty result from a query that failed.
gt() { [[ -n "$1" ]] && awk -v a="$1" -v b="$2" 'BEGIN { exit !(a > b) }'; }
lt() { [[ -n "$1" ]] && awk -v a="$1" -v b="$2" 'BEGIN { exit !(a < b) }'; }

command -v psql >/dev/null || fail "psql not found. Install postgresql-client."

SERVER_VERSION="$(q 'SHOW server_version')"
[[ -n "$SERVER_VERSION" ]] || fail "Cannot connect. Run as: sudo -u postgres $0"
SERVER_VERSION_NUM="$(q 'SHOW server_version_num')"
[[ -z "$DATABASE" ]] && DATABASE="$(q 'SELECT current_database()')"

printf '\n  PostgreSQL Environment Health Check\n  %s\n\n' "$CONTACT_SITE"
printf '  Server    : PostgreSQL %s\n' "$SERVER_VERSION"
printf '  Host      : %s\n' "$(hostname -f 2>/dev/null || hostname)"
printf '  Database  : %s\n' "$DATABASE"
printf '  Mode      : read-only (no changes are made)\n'

# ── Version ────────────────────────────────────────────────────────────
section "Version and uptime"

if lt "$SERVER_VERSION_NUM" 130000; then
  report CRITICAL "PostgreSQL $SERVER_VERSION is end of life" \
    "This version no longer receives security or bug fixes." \
    "Plan a major upgrade with pg_upgrade; test on a restored copy first."
elif lt "$SERVER_VERSION_NUM" 150000; then
  report WARNING "PostgreSQL $SERVER_VERSION is supported but dated" \
    "Newer majors bring substantial performance and observability improvements." \
    "Evaluate an upgrade path during the next maintenance window."
else
  report OK "PostgreSQL $SERVER_VERSION is a currently supported major version" "" ""
fi

# ── Memory ─────────────────────────────────────────────────────────────
section "Memory configuration"

TOTAL_RAM_MB="$(awk '/MemTotal/ { printf "%d", $2 / 1024 }' /proc/meminfo 2>/dev/null || echo "")"
SHARED_BUFFERS_MB="$(q "SELECT (setting::bigint * current_setting('block_size')::bigint / 1024 / 1024) FROM pg_settings WHERE name = 'shared_buffers'")"

if [[ -n "$TOTAL_RAM_MB" && -n "$SHARED_BUFFERS_MB" ]]; then
  SB_PCT="$(awk -v s="$SHARED_BUFFERS_MB" -v t="$TOTAL_RAM_MB" 'BEGIN { printf "%.1f", (s / t) * 100 }')"
  TARGET_MB=$(( TOTAL_RAM_MB / 4 ))
  if lt "$SB_PCT" 10; then
    report CRITICAL "shared_buffers is only ${SB_PCT}% of RAM (${SHARED_BUFFERS_MB} MB of ${TOTAL_RAM_MB} MB)" \
      "The default 128MB is a common leftover; it forces constant re-reads from disk." \
      "ALTER SYSTEM SET shared_buffers = '${TARGET_MB}MB';  -- then restart"
  elif gt "$SB_PCT" 40; then
    report WARNING "shared_buffers is ${SB_PCT}% of RAM (${SHARED_BUFFERS_MB} MB)" \
      "Above ~40% the OS page cache is starved and double buffering wastes memory." \
      "Consider reducing toward 25% of RAM (${TARGET_MB}MB)."
  else
    report OK "shared_buffers: ${SHARED_BUFFERS_MB} MB (${SB_PCT}% of RAM)" "" ""
  fi
fi

WORK_MEM_KB="$(q "SELECT setting::bigint FROM pg_settings WHERE name = 'work_mem'")"
MAX_CONN="$(q "SELECT setting::int FROM pg_settings WHERE name = 'max_connections'")"
if [[ -n "$WORK_MEM_KB" ]]; then
  WORK_MEM_MB=$(( WORK_MEM_KB / 1024 ))
  if lt "$WORK_MEM_KB" 8192; then
    report WARNING "work_mem is only ${WORK_MEM_KB} kB" \
      "Sorts and hashes that exceed it spill to temporary files on disk." \
      "Raise gradually (e.g. 16MB); it is allocated per operation, not per connection."
  else
    WORST_CASE_MB=$(( (WORK_MEM_KB / 1024) * MAX_CONN ))
    if [[ -n "$TOTAL_RAM_MB" ]] && gt "$WORST_CASE_MB" "$TOTAL_RAM_MB"; then
      report WARNING "work_mem (${WORK_MEM_MB} MB) x max_connections (${MAX_CONN}) = ${WORST_CASE_MB} MB, above total RAM" \
        "work_mem is per sort/hash node, so peaks can exhaust memory and trigger the OOM killer." \
        "Lower work_mem, lower max_connections, or put a connection pooler in front."
    else
      report OK "work_mem: ${WORK_MEM_MB} MB" "" ""
    fi
  fi
fi

# ── Connections ────────────────────────────────────────────────────────
section "Connections"

IDLE_IN_TX="$(q "SELECT count(*) FROM pg_stat_activity
  WHERE state = 'idle in transaction' AND state_change < now() - interval '5 minutes'")"
if gt "$IDLE_IN_TX" 10; then
  report CRITICAL "${IDLE_IN_TX} connections idle in transaction for over 5 minutes" \
    "They hold locks and stop vacuum from cleaning dead rows, causing bloat and blocking DDL." \
    "Fix the application's transaction handling; set idle_in_transaction_session_timeout."
elif gt "$IDLE_IN_TX" 3; then
  report WARNING "${IDLE_IN_TX} connections idle in transaction for over 5 minutes" \
    "These hold back the vacuum horizon and can block schema changes." \
    "ALTER SYSTEM SET idle_in_transaction_session_timeout = '10min';"
else
  report OK "No significant idle-in-transaction sessions" "" ""
fi

# ── Autovacuum and wraparound ──────────────────────────────────────────
section "Autovacuum and table health"

AUTOVACUUM="$(q "SELECT setting FROM pg_settings WHERE name = 'autovacuum'")"
if [[ "$AUTOVACUUM" != "on" ]]; then
  report CRITICAL "autovacuum is OFF" \
    "Dead rows are never reclaimed. This ends in bloat and transaction ID wraparound." \
    "ALTER SYSTEM SET autovacuum = on;  -- then reload. Never leave this off."
else
  report OK "autovacuum is enabled" "" ""
fi

MAX_AGE="$(q "SELECT max(age(datfrozenxid)) FROM pg_database")"
if [[ -n "$MAX_AGE" ]]; then
  if gt "$MAX_AGE" 1500000000; then
    report CRITICAL "Transaction ID age is ${MAX_AGE}" \
      "At 2 billion PostgreSQL refuses new writes to protect the data. This is an outage in waiting." \
      "Vacuum the oldest databases now: vacuumdb --all --freeze --jobs=4"
  elif gt "$MAX_AGE" 1000000000; then
    report WARNING "Transaction ID age is ${MAX_AGE}" \
      "Approaching wraparound; autovacuum is not freezing fast enough." \
      "Investigate blockers: long transactions, abandoned slots, prepared transactions."
  else
    report OK "Transaction ID age is healthy (${MAX_AGE})" "" ""
  fi
fi

# ── Indexes ────────────────────────────────────────────────────────────
section "Indexes"

FK_NO_INDEX="$(qd "
  SELECT count(*)
    FROM pg_constraint c
    JOIN pg_class t ON t.oid = c.conrelid
   WHERE c.contype = 'f'
     AND NOT EXISTS (
       SELECT 1 FROM pg_index i
        WHERE i.indrelid = c.conrelid
          AND (i.indkey::smallint[])[0:array_length(c.conkey,1)-1] @> c.conkey
     )")"
if gt "$FK_NO_INDEX" 0; then
  report WARNING "${FK_NO_INDEX} foreign keys have no supporting index" \
    "Every DELETE or UPDATE on the parent scans the whole child table to check the constraint." \
    "Create an index on each referencing column; this routinely turns 47s deletes into 1ms."
else
  report OK "All foreign keys have supporting indexes" "" ""
fi

# ── Replication slots ──────────────────────────────────────────────────
section "Replication"

INACTIVE_SLOTS="$(q "SELECT count(*) FROM pg_replication_slots WHERE NOT active")"
if gt "$INACTIVE_SLOTS" 0; then
  SLOT_NAMES="$(q "SELECT string_agg(slot_name, ', ') FROM pg_replication_slots WHERE NOT active")"
  report CRITICAL "${INACTIVE_SLOTS} inactive replication slots: ${SLOT_NAMES}" \
    "An abandoned slot retains WAL indefinitely until pg_wal fills the disk and the server stops." \
    "If the consumer is truly gone: SELECT pg_drop_replication_slot('<name>');"
else
  report OK "No abandoned replication slots" "" ""
fi

# ── Backup ─────────────────────────────────────────────────────────────
section "Backup and WAL"

ARCHIVE_MODE="$(q "SELECT setting FROM pg_settings WHERE name = 'archive_mode'")"
if [[ "$ARCHIVE_MODE" != "on" ]]; then
  report CRITICAL "WAL archiving is disabled (archive_mode = ${ARCHIVE_MODE})" \
    "Point-in-time recovery is impossible. Recovery is limited to your last dump." \
    "Enable archive_mode and archive_command."
else
  FAILED_ARCHIVES="$(q "SELECT failed_count FROM pg_stat_archiver")"
  if gt "$FAILED_ARCHIVES" 0; then
    report CRITICAL "WAL archiving has ${FAILED_ARCHIVES} failures" \
      "WAL is accumulating and the recovery chain has gaps. This breaks PITR silently." \
      "Check the archive_command destination: permissions, free space, reachability."
  else
    report OK "WAL archiving is enabled and healthy" "" ""
  fi
fi

# ── Security ───────────────────────────────────────────────────────────
section "Security"

TRUST_AUTH="$(q "SELECT count(*) FROM pg_hba_file_rules
  WHERE auth_method = 'trust' AND type != 'local'")"
if gt "$TRUST_AUTH" 0; then
  report CRITICAL "${TRUST_AUTH} pg_hba.conf rules use 'trust' for non-local connections" \
    "Anyone who can reach the port connects as any user, with no password." \
    "Replace trust with scram-sha-256 in pg_hba.conf and reload."
else
  report OK "No 'trust' authentication for remote connections" "" ""
fi

# ── Summary ────────────────────────────────────────────────────────────
TOTAL=$(( CRITICAL_COUNT + WARNING_COUNT + OK_COUNT ))
printf '\n%s══ Summary ══%s\n\n' "$BOLD$CYAN" "$RESET"
printf '  %sCritical : %-3s%s  need attention now\n' "$RED$BOLD" "$CRITICAL_COUNT" "$RESET"
printf '  %sWarnings : %-3s%s  should be planned\n' "$YELLOW" "$WARNING_COUNT" "$RESET"
printf '  %sPassed   : %-3s%s  of %s checks\n' "$GREEN" "$OK_COUNT" "$RESET" "$TOTAL"

# ── Optional shareable report ──────────────────────────────────────────
if [[ "$FULL_REPORT" == "1" ]]; then
  printf '\n  This writes the findings plus your contact details to a local file.\n'
  printf '  %sNothing is transmitted by this script.%s Ctrl-C to skip.\n\n' "$BOLD" "$RESET"

  read -r -p "  Name        : " LEAD_NAME
  read -r -p "  Company     : " LEAD_COMPANY
  read -r -p "  Email       : " LEAD_EMAIL
  read -r -p "  Phone       : " LEAD_PHONE

  REPORT_FILE="pg-health-check-$(date +%Y%m%d%H%M%S).txt"
  {
    echo "PostgreSQL Environment Health Check"
    echo "Generated: $(date --iso-8601=seconds)"
    echo
    echo "Name    : ${LEAD_NAME}"
    echo "Company : ${LEAD_COMPANY}"
    echo "Email   : ${LEAD_EMAIL}"
    echo "Phone   : ${LEAD_PHONE}"
    echo
    echo "PostgreSQL : ${SERVER_VERSION}"
    echo "Host       : $(hostname -f 2>/dev/null || hostname)"
    echo "Database   : ${DATABASE}"
    echo
    echo "Results: ${CRITICAL_COUNT} critical, ${WARNING_COUNT} warnings, ${OK_COUNT} passed"
    echo
    while IFS=$'\t' read -r level title detail fix; do
      [[ "$level" == "OK" ]] && continue
      echo "[${level}] ${title}"
      [[ -n "$detail" ]] && echo "    ${detail}"
      [[ -n "$fix" ]] && echo "    fix: ${fix}"
      echo
    done < "$FINDINGS_FILE"
  } > "$REPORT_FILE"

  chmod 600 "$REPORT_FILE"
  printf '\n  Report written to: %s\n' "$REPORT_FILE"
  printf '  Review it, then send to %s or %s\n' "$CONTACT_WHATSAPP" "$CONTACT_EMAIL"
fi

# ── Contact ────────────────────────────────────────────────────────────
printf '\n%s══ Need help with these findings? ══%s\n\n' "$BOLD$CYAN" "$RESET"
printf '  %s\n' "$CONTACT_NAME"
printf '  WhatsApp : %s\n' "$CONTACT_WHATSAPP"
printf '  Email    : %s\n' "$CONTACT_EMAIL"
printf '  Site     : %s\n\n' "$CONTACT_SITE"

(( CRITICAL_COUNT > 0 )) && exit 1
exit 0

The listing above is abridged for readability — it shows the structure and the most valuable checks. The downloadable file contains all 27, including cache hit ratio, temporary files, checkpoint behavior, bloat, unused and duplicate indexes, replica lag, pg_wal growth, data checksums, md5 passwords, superuser count, SSL and slow query logging.

Want a second opinion on the results?

Running the check is the easy part; deciding what to do first is where experience matters. Run it with FULL_REPORT=1 and the script collects the findings into a local text file, together with your contact details, that you can review and send us:

sudo -u postgres FULL_REPORT=1 /usr/local/bin/postgresql-health-check-ubuntu.sh

The file contains only what the report printed — findings, PostgreSQL version, host name, database sizes. No query text, no table contents, no credentials. The script never transmits it; it writes the file with chmod 600 and stops there. Read it, remove anything you would rather not share, and send it if you want our analysis.

We will reply with what we would prioritize, what is safe to change immediately, and what needs a maintenance window.

Talk to us directly:
WhatsApp: +55 62 98156-1666
Email: joao.victor.32@hotmail.com

A snapshot is not monitoring

This script tells you what is true right now. Most of what actually hurts a database develops gradually: bloat accumulating over weeks, a query plan that degrades as a table grows, a connection leak that only shows up under load, replication lag that spikes at 3 a.m. and recovers before anyone looks. A check you run when you already suspect a problem cannot catch those.

That is what PG Monitoring does continuously — the same checks, tracked over time, with alerts before a warning becomes an incident. Use the script for a point-in-time answer; use monitoring so you are not the last to know.

References: the PostgreSQL manual documents the statistics collector views, routine vacuuming and wraparound prevention, resource consumption settings, and pg_hba.conf authentication.

Related Articles

Ready to experience better PostgreSQL monitoring?

Join thousands of teams who switched from traditional tools to PG Monitoring's AI-powered platform.

Talk to us