Auto-Purge Old Database Rows on a Schedule

Databases accumulate dead weight over time: expired sessions, rows marked deleted but never removed, audit logs older than your retention policy. Leaving these rows in place bloats table scans, slows index lookups, and inflates backup sizes. A scheduled purge job removes them on a predictable cadence without any manual intervention.

Schedule

0 3 * * *

Every day at 3:00 AM server time

Setup

  1. 1

    Identify the tables and retention windows

    List every table that accumulates disposable data: sessions (expire_at column), soft-deleted records (deleted_at column), job logs, audit trails, notification history. For each, decide your retention window, for example keep sessions for 7 days after expiry, keep soft-deleted rows for 30 days, keep logs for 90 days. Write this down before touching any SQL.

  2. 2

    Write and test the DELETE statements in a transaction

    Run each DELETE manually on a staging copy first. Use a LIMIT clause (e.g. DELETE ... LIMIT 5000) to avoid locking a large table in one shot. Wrap multiple statements in a transaction so a mid-run failure does not leave the database in a half-cleaned state. Confirm row counts match expectations before scheduling.

  3. 3

    Create the purge script on your server

    Save the script (see the Script section) to a dedicated path such as /opt/scripts/db-purge.sh. Make it executable with chmod +x. Store database credentials in a .env file or in the system environment, never hard-coded in the script. Test a dry run by executing the script manually and verifying the expected rows are removed.

  4. 4

    Register a heartbeat monitor in CronJobPro

    In CronJobPro, create a new Heartbeat monitor. Set the expected period to 24 hours and add a grace period of 15 minutes. Copy the unique ping URL you receive (https://cronjobpro.com/ping/<your-token>) and paste it into your script as shown in the Script section. Configure your alert channel (email, Slack, PagerDuty, etc.) so you are notified if the ping does not arrive on time.

  5. 5

    Schedule the job in system cron

    Open the crontab with crontab -e and add the entry: 0 3 * * * /opt/scripts/db-purge.sh >> /var/log/db-purge.log 2>&1. Save and verify with crontab -l. The script will run nightly at 3 AM, perform the purge, and call the CronJobPro ping URL on success so you have confirmation the job completed without errors.

The script

bash

#!/usr/bin/env bash
set -euo pipefail

# Load credentials from environment or a .env file
# Example .env: DB_HOST=localhost DB_PORT=5432 DB_NAME=myapp DB_USER=appuser DB_PASS=secret
if [ -f /opt/scripts/.env ]; then
  # shellcheck source=/dev/null
  source /opt/scripts/.env
fi

HEARTBEAT_URL="https://cronjobpro.com/ping/${CRONJOBPRO_TOKEN:-REPLACE_WITH_YOUR_TOKEN}"

log() {
  echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] $*"
}

run_purge() {
  PGPASSWORD="$DB_PASS" psql \
    --host="$DB_HOST" \
    --port="${DB_PORT:-5432}" \
    --username="$DB_USER" \
    --dbname="$DB_NAME" \
    --no-password \
    --tuples-only \
    <<'SQL'
BEGIN;

-- 1. Delete expired sessions older than 7 days past expiry
DELETE FROM sessions
WHERE expire_at < NOW() - INTERVAL '7 days'
  AND id IN (
    SELECT id FROM sessions
    WHERE expire_at < NOW() - INTERVAL '7 days'
    LIMIT 5000
  );

-- 2. Hard-delete soft-deleted records older than 30 days
DELETE FROM users
WHERE deleted_at IS NOT NULL
  AND deleted_at < NOW() - INTERVAL '30 days'
  AND id IN (
    SELECT id FROM users
    WHERE deleted_at IS NOT NULL
      AND deleted_at < NOW() - INTERVAL '30 days'
    LIMIT 5000
  );

-- 3. Purge application logs older than 90 days
DELETE FROM app_logs
WHERE created_at < NOW() - INTERVAL '90 days'
  AND id IN (
    SELECT id FROM app_logs
    WHERE created_at < NOW() - INTERVAL '90 days'
    LIMIT 10000
  );

COMMIT;
SQL
}

main() {
  log "Starting database purge"

  if run_purge; then
    log "Purge completed successfully"
    # Notify CronJobPro that the job succeeded
    curl --silent --max-time 10 --retry 3 "$HEARTBEAT_URL" > /dev/null
    log "Heartbeat ping sent"
  else
    EXIT_CODE=$?
    log "Purge failed with exit code $EXIT_CODE"
    # Report failure to CronJobPro
    curl --silent --max-time 10 --retry 3 "${HEARTBEAT_URL}/fail" > /dev/null
    exit $EXIT_CODE
  fi
}

main

Monitor it

Create a Heartbeat monitor in CronJobPro with a 24-hour period and a 15-minute grace period, matching the nightly 3 AM schedule. The script calls https://cronjobpro.com/ping/your-token only after all DELETE statements commit successfully; if any statement fails, it calls /ping/your-token/fail instead and exits with a non-zero code. If the server never runs the script at all (reboot, cron misconfiguration, disk full), no ping arrives and CronJobPro fires an alert after the grace window expires. Set your preferred alert channel (email, Slack, Discord, PagerDuty, or webhook) in the monitor settings so the right person is paged when a purge cycle is missed or errors out. Review the CronJobPro run history alongside your local /var/log/db-purge.log to correlate timing and row-count output whenever an alert fires.

Frequently asked questions

Is it safe to run large DELETEs without splitting them into batches?

No. Deleting tens of thousands of rows in a single statement acquires locks for the full duration of the transaction, which can block concurrent reads and writes. The script limits each DELETE to 5,000 or 10,000 rows per run. If more rows accumulate than one batch covers, the next nightly run clears the remainder, which is acceptable for most retention policies. For very high-volume tables consider running the job more frequently or looping inside the script until zero rows remain.

Should I use a dedicated database user for the purge script?

Yes. Create a user with DELETE and SELECT privileges on only the specific tables being purged, and no other permissions. This limits the blast radius if the credentials are ever exposed. Grant nothing on tables the purge script does not touch.

What if the DELETE statements succeed but the heartbeat ping fails due to a network issue?

CronJobPro will fire a missed-ping alert. The data has already been purged correctly, so no data harm occurred. Investigate the network issue, confirm the purge log shows success, and manually acknowledge the alert in CronJobPro. The script uses curl --retry 3 to reduce transient failures, but a prolonged outage will still miss the ping.

Can I adapt this script for MySQL or SQLite instead of PostgreSQL?

Yes. For MySQL, replace the psql call with mysql -h $DB_HOST -u $DB_USER -p$DB_PASS $DB_NAME and adjust the SQL syntax (LIMIT works the same way in MySQL DELETEs). For SQLite, use the sqlite3 command with the database file path. The heartbeat ping logic at the end of the script is database-agnostic and stays the same regardless of which database engine you use.

More recipes

Auto-Purge Old Database Rows on a Schedule | CronJobPro