Automate PostgreSQL VACUUM ANALYZE with Monitoring

PostgreSQL accumulates dead tuples from updates and deletes, and its query planner relies on up-to-date statistics to choose efficient execution plans. Running VACUUM ANALYZE on a schedule reclaims dead row storage, prevents transaction ID wraparound, and refreshes column statistics so queries stay fast. Pairing this with a CronJobPro heartbeat monitor ensures you are alerted if the job silently fails or takes too long to complete.

Schedule

0 3 * * 0

Every Sunday at 3:00 AM

Setup

  1. 1

    Create the maintenance script on your server

    Save the script below to a path such as /usr/local/bin/pg-vacuum.sh and make it executable with: chmod +x /usr/local/bin/pg-vacuum.sh. Fill in your DB connection details and the heartbeat token from step 3.

  2. 2

    Test the script manually before scheduling

    Run the script as the system user that owns the PostgreSQL role, for example: sudo -u postgres /usr/local/bin/pg-vacuum.sh. Check for errors in the output and confirm vacuumdb exits with code 0.

  3. 3

    Create a Heartbeat monitor in CronJobPro

    In CronJobPro, create a new Heartbeat monitor, set the expected period to 7 days, and set a grace period of 30 minutes. Copy the unique ping URL you receive, which will look like https://cronjobpro.com/ping/<token>. Paste this token into the HEARTBEAT_URL variable in the script.

  4. 4

    Register the cron entry on your server

    Open the crontab for the postgres user with: sudo -u postgres crontab -e. Add the line: 0 3 * * 0 /usr/local/bin/pg-vacuum.sh >> /var/log/pg-vacuum.log 2>&1. This runs the script every Sunday at 3 AM and appends all output to a log file.

  5. 5

    Configure alerts in CronJobPro

    Under your heartbeat monitor settings, add at least one alert channel such as email or Slack. CronJobPro will notify you if the ping URL is not called within the expected period plus the grace window, meaning the job missed its window or crashed before completing.

The script

bash

#!/usr/bin/env bash
# pg-vacuum.sh — weekly VACUUM ANALYZE for all databases
# Fill in your connection details and heartbeat token before deploying.

set -euo pipefail

# --- Configuration ---
DB_HOST="localhost"
DB_PORT="5432"
DB_USER="postgres"
# Space-separated list of databases to maintain. Use __all__ to iterate
# every database returned by psql.
DATABASES="myapp_production"
HEARTBEAT_URL="https://cronjobpro.com/ping/YOUR_TOKEN_HERE"
LOG_PREFIX="[pg-vacuum $(date '+%Y-%m-%d %H:%M:%S')]"

# --- Run VACUUM ANALYZE ---
echo "$LOG_PREFIX Starting VACUUM ANALYZE"

if [ "$DATABASES" = "__all__" ]; then
  DATABASES=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres \
    -Atc "SELECT datname FROM pg_database WHERE datistemplate = false AND datallowconn = true;")
fi

EXIT_CODE=0

for DB in $DATABASES; do
  echo "$LOG_PREFIX Running vacuumdb on database: $DB"
  if vacuumdb \
      --host="$DB_HOST" \
      --port="$DB_PORT" \
      --username="$DB_USER" \
      --analyze \
      --verbose \
      --dbname="$DB"; then
    echo "$LOG_PREFIX Completed successfully: $DB"
  else
    echo "$LOG_PREFIX ERROR: vacuumdb failed for database: $DB" >&2
    EXIT_CODE=1
  fi
done

# --- Report result to CronJobPro ---
if [ "$EXIT_CODE" -eq 0 ]; then
  echo "$LOG_PREFIX All databases processed. Sending heartbeat ping."
  curl --silent --max-time 10 --retry 3 "$HEARTBEAT_URL" \
    --output /dev/null
  echo "$LOG_PREFIX Heartbeat sent."
else
  echo "$LOG_PREFIX One or more databases failed. Sending failure ping." >&2
  curl --silent --max-time 10 --retry 3 "${HEARTBEAT_URL}/fail" \
    --output /dev/null
  exit 1
fi

Monitor it

Create a Heartbeat monitor in CronJobPro and set the period to 7 days with a grace window of 30 minutes. The script calls the ping URL only after vacuumdb exits successfully for every target database; if the job crashes, is skipped by cron, or a database fails, the ping is either not sent or the /fail endpoint is called instead. If CronJobPro does not receive a successful ping within 7 days and 30 minutes of the last confirmed ping, it fires alerts to every channel you have configured, which can include email, Slack, Discord, Teams, PagerDuty, Opsgenie, or a custom webhook. This means you are protected against silent failures where the cron entry fires but the vacuumdb command exits with an error, as well as against situations where the cron daemon itself is not running or the server is offline during the maintenance window.

Frequently asked questions

Will VACUUM ANALYZE lock my tables and affect running queries?

Plain VACUUM ANALYZE does not take an exclusive lock and can run concurrently with reads and writes. Only VACUUM FULL takes an exclusive lock and rewrites the table. For routine maintenance, VACUUM ANALYZE is safe to run on a live production database.

Does PostgreSQL already auto-vacuum? Why run this manually?

Yes, autovacuum runs automatically, but it uses conservative throttling to avoid impacting application load. A scheduled VACUUM ANALYZE gives you a predictable maintenance window with no throttling, which is useful for tables that accumulate heavy write load during the week or for databases where autovacuum tuning has not been customised.

How do I target only specific tables instead of the whole database?

Replace the vacuumdb call with a psql command and pass the table name with the -t flag to vacuumdb, for example: vacuumdb --analyze --table=public.orders --dbname=myapp_production. You can loop over a hardcoded list of high-churn tables for a faster, more targeted job.

What should I set as the heartbeat period if I change the schedule?

Set the heartbeat period to match your cron interval. If you run the job daily, set the period to 1 day. If weekly, set it to 7 days. Always add a grace window of at least 15 to 30 minutes to account for variable runtime, so a slightly slow run does not trigger a false alert.

More recipes

Automate PostgreSQL VACUUM ANALYZE with Monitoring | CronJobPro