Skip to content

Instantly share code, notes, and snippets.

@ajithrn
Last active March 11, 2026 19:09
Show Gist options
  • Select an option

  • Save ajithrn/cdcc55078ec1f8194c567cdc9760482b to your computer and use it in GitHub Desktop.

Select an option

Save ajithrn/cdcc55078ec1f8194c567cdc9760482b to your computer and use it in GitHub Desktop.
WordPress post cleanup by year & month(s) using WP-CLI
#!/bin/bash
# =============================================================================
# WordPress Posts & Media Cleanup Script
# =============================================================================
#
# Deletes WordPress posts and their associated media attachments (including
# featured images) for a given year and optional month filter. Attachments
# are deleted first, then posts. A database backup is taken before any
# destructive action unless explicitly skipped.
#
# =============================================================================
# OPTIONS
# =============================================================================
#
# --year=YYYY Year to target (required)
# --months="M M M" Specific months to target, space-separated (1-12)
# If omitted, ALL months in the year are targeted
# --dry-run Preview what will be deleted (nothing is modified)
# --path=/path/to/wp Path to WordPress installation
# If omitted, WP-CLI uses the current directory
# --allow-root Pass --allow-root flag to WP-CLI
# Required when running the script as the root user
# --batch=N Posts to process per batch (default: 50)
# --skip-backup Skip the automatic database backup before deletion
# --log=FILE Log file path (default: cleanup-YYYYMMDD-HHMMSS.log)
# --help, -h Show this help text and exit
#
# =============================================================================
# EXAMPLES
# =============================================================================
#
# ── Basic Usage ──────────────────────────────────────────────────────────────
#
# Preview all posts from 2024 (dry run, nothing deleted):
# ./wp_post_cleanup.sh --year=2024 --dry-run
#
# Delete all posts from January 2023:
# ./wp_post_cleanup.sh --year=2023 --months="1"
#
# Delete all posts from Q1 2021 (Jan, Feb, Mar):
# ./wp_post_cleanup.sh --year=2021 --months="1 2 3"
#
# Delete ALL posts from the entire year 2022:
# ./wp_post_cleanup.sh --year=2022
#
# ── Custom Output & Paths ────────────────────────────────────────────────────
#
# Save log to a custom file:
# ./wp_post_cleanup.sh --year=2020 --months="12" --log=dec2020-cleanup.log
#
# WordPress installed in a non-default directory:
# ./wp_post_cleanup.sh --year=2023 --months="9" --path=/var/www/html
#
# ── Server / Root Usage ──────────────────────────────────────────────────────
#
# Running as root on a server (requires --allow-root):
# ./wp_post_cleanup.sh --year=2023 --path=/var/www/html --allow-root
#
# Skip backup for faster execution (use with caution):
# ./wp_post_cleanup.sh --year=2022 --months="6" --skip-backup --allow-root
#
# ── Automation (Cron) ────────────────────────────────────────────────────────
#
# Auto-clean posts older than 6 months, run at 3 AM on the 1st of each month:
# 0 3 1 * * /path/to/wp_post_cleanup.sh \
# --year=$(date -d '6 months ago' +\%Y) \
# --months=$(date -d '6 months ago' +\%m) \
# --path=/var/www/html --allow-root --skip-backup
#
# NOTE: The cron example uses GNU date syntax (Linux).
# On macOS, replace with: $(date -v-6m +\%Y) and $(date -v-6m +\%m)
#
# =============================================================================
# IMPORTANT NOTES
# =============================================================================
#
# ⚠ ALWAYS run with --dry-run first to preview what will be affected.
#
# ⚠ If --months is omitted, ALL posts from the given --year are deleted.
# This is intentional but can be destructive — double-check your command.
#
# • Months are integers 1-12 (zero-padding like 01 is accepted but optional).
# • All options use "=" syntax (e.g., --year=2024, NOT --year 2024).
# • The script auto-detects your database table prefix (no hardcoded wp_).
# • A database backup is created before deletion unless --skip-backup is set.
# • Deletion is permanent (uses wp post delete --force, bypasses trash).
# • Posts are deleted with their attachments and featured images.
#
# =============================================================================
set -euo pipefail
# ─── Configuration ───────────────────────────────────────────────────────────
DRY_RUN=false
WP_PATH=""
BATCH_SIZE=50
TARGET_YEAR=""
TARGET_MONTHS=""
ALLOW_ROOT=false
SKIP_BACKUP=false
LOG_FILE="cleanup-$(date +%Y%m%d-%H%M%S).log"
# ─── Colors ──────────────────────────────────────────────────────────────────
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m'
BLUE='\033[0;34m' CYAN='\033[0;36m' NC='\033[0m' BOLD='\033[1m'
# ─── Parse Arguments ─────────────────────────────────────────────────────────
for arg in "$@"; do
case $arg in
--dry-run) DRY_RUN=true ;;
--path=*) WP_PATH="${arg#*=}" ;;
--batch=*) BATCH_SIZE="${arg#*=}" ;;
--year=*) TARGET_YEAR="${arg#*=}" ;;
--months=*) TARGET_MONTHS="${arg#*=}" ;;
--allow-root) ALLOW_ROOT=true ;;
--skip-backup) SKIP_BACKUP=true ;;
--log=*) LOG_FILE="${arg#*=}" ;;
--help|-h) sed -n '2,89p' "$0"; exit 0 ;;
*) echo -e "${RED}Unknown option: $arg${NC}"; echo "Run with --help to see available options."; exit 1 ;;
esac
done
# ─── Validation ──────────────────────────────────────────────────────────────
if [ -z "$TARGET_YEAR" ]; then
echo -e "${RED}✖ --year is required. Example: --year=2024${NC}"; exit 1
fi
if ! [[ "$TARGET_YEAR" =~ ^[0-9]{4}$ ]]; then
echo -e "${RED}✖ Invalid year: ${TARGET_YEAR} (must be a 4-digit year)${NC}"; exit 1
fi
if ! [[ "$BATCH_SIZE" =~ ^[0-9]+$ ]] || [ "$BATCH_SIZE" -lt 1 ]; then
echo -e "${RED}✖ Invalid batch size: ${BATCH_SIZE} (must be a positive integer)${NC}"; exit 1
fi
# ─── Build WP-CLI Command ───────────────────────────────────────────────────
WP_CMD="wp"
$ALLOW_ROOT && WP_CMD="$WP_CMD --allow-root"
[ -n "$WP_PATH" ] && WP_CMD="$WP_CMD --path=$WP_PATH"
# ─── Build Month Filter ─────────────────────────────────────────────────────
MONTH_SQL_CLAUSE=""
MONTH_DISPLAY="All"
if [ -n "$TARGET_MONTHS" ]; then
for m in $TARGET_MONTHS; do
if ! [[ "$m" =~ ^[0-9]+$ ]] || [ "$m" -lt 1 ] || [ "$m" -gt 12 ]; then
echo -e "${RED}✖ Invalid month: $m (must be 1-12)${NC}"; exit 1
fi
done
MONTH_CSV=$(echo "$TARGET_MONTHS" | tr ' ' ',')
MONTH_SQL_CLAUSE="AND MONTH(post_date) IN ($MONTH_CSV)"
MONTH_DISPLAY="$TARGET_MONTHS"
fi
# ─── Helper Functions ────────────────────────────────────────────────────────
strip_ansi() { sed $'s/\x1b\[[0-9;]*m//g'; }
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | strip_ansi >> "$LOG_FILE"
echo -e "$1"
}
separator() { echo -e "${BLUE}─────────────────────────────────────────────────────────────${NC}"; }
progress_bar() {
local current=$1 total=$2 label=$3 batch_info=${4:-""}
local percent=0 filled empty bar="" space=""
[ "$total" -gt 0 ] && percent=$(( current * 100 / total ))
filled=$(( percent / 2 )); empty=$(( 50 - filled ))
[ "$filled" -gt 0 ] && bar=$(printf '%0.s█' $(seq 1 "$filled"))
[ "$empty" -gt 0 ] && space=$(printf '%0.s░' $(seq 1 "$empty"))
if [ -n "$batch_info" ]; then
printf "\r ${CYAN}%s${NC} [${GREEN}%s${NC}%s] ${BOLD}%d/%d${NC} (%d%%) ${CYAN}%s${NC} " \
"$label" "$bar" "$space" "$current" "$total" "$percent" "$batch_info"
else
printf "\r ${CYAN}%s${NC} [${GREEN}%s${NC}%s] ${BOLD}%d/%d${NC} (%d%%) " \
"$label" "$bar" "$space" "$current" "$total" "$percent"
fi
}
# Parse WP-CLI SQL output into a bash array (filters to numeric IDs only)
# Usage: parse_ids ARRAY_NAME "$RAW_OUTPUT"
parse_ids() {
local -n arr=$1
local raw="$2"
arr=()
while IFS= read -r line; do
line=$(echo "$line" | tr -d '[:space:]')
[ -n "$line" ] && [[ "$line" =~ ^[0-9]+$ ]] && arr+=("$line")
done <<< "$raw"
}
# Batched delete with progress bar
# Usage: batch_delete ARRAY_NAME "label" "type"
# Sets: _BD_DELETED, _BD_FAILED
batch_delete() {
local -n ids=$1
local label="$2" type="$3"
local total=${#ids[@]}
local total_batches=$(( (total + BATCH_SIZE - 1) / BATCH_SIZE )) batch_num=0
_BD_DELETED=0; _BD_FAILED=0
if [ "$total" -eq 0 ]; then
log "${YELLOW}⚠ No ${type} to delete. Skipping.${NC}"; return
fi
for (( i=0; i<total; i+=BATCH_SIZE )); do
batch_num=$((batch_num + 1))
local batch=("${ids[@]:$i:$BATCH_SIZE}")
local batch_count=${#batch[@]}
local processed=$(( i + batch_count ))
echo "[Batch ${batch_num}/${total_batches}] (${batch_count} ${type}s)" >> "$LOG_FILE"
progress_bar "$processed" "$total" "$label" "Batch ${batch_num}/${total_batches}"
if ! $DRY_RUN; then
local output
output=$($WP_CMD post delete "${batch[@]}" --force 2>&1) || true
# Count successes from WP-CLI output (each success prints "Success: ...")
local batch_ok
batch_ok=$(echo "$output" | grep -ci "success" 2>/dev/null || echo 0)
local batch_fail=$(( batch_count - batch_ok ))
_BD_DELETED=$((_BD_DELETED + batch_ok))
_BD_FAILED=$((_BD_FAILED + batch_fail))
if [ "$batch_fail" -eq 0 ]; then
echo "[OK] Batch ${batch_num}: deleted ${batch_ok} ${type}(s)" >> "$LOG_FILE"
else
echo "[PARTIAL] Batch ${batch_num}: deleted ${batch_ok}, failed ${batch_fail} ${type}(s)" >> "$LOG_FILE"
echo "$output" | grep -iv "success" >> "$LOG_FILE" 2>/dev/null || true
fi
else
_BD_DELETED=$((_BD_DELETED + batch_count))
fi
done
echo "" # newline after progress bar
}
# Run a WP-CLI cache command, log success/failure
flush_cmd() {
if $WP_CMD "$@" 2>/dev/null; then
log "${GREEN}✔ $1 done.${NC}"
else
log "${YELLOW}⚠ $1 skipped.${NC}"
fi
}
check_wp_cli() {
if ! command -v wp &> /dev/null; then
log "${RED}✖ WP-CLI is not installed or not in PATH.${NC}"; exit 1
fi
log "${BLUE}► Checking WP-CLI installation...${NC}"
local wp_error
wp_error=$($WP_CMD core is-installed 2>&1) || {
log "${RED}✖ WordPress check failed:${NC}"
[ -n "$wp_error" ] && log " ${RED}$wp_error${NC}"
log "${YELLOW} Hints: Check --path, ensure WP is installed, or add --allow-root if running as root.${NC}"
exit 1
}
}
# ─── Pre-flight Checks ──────────────────────────────────────────────────────
echo ""
echo -e "${BOLD}${CYAN}╔═══════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${CYAN}║ WordPress Posts & Media Cleanup Script ║${NC}"
echo -e "${BOLD}${CYAN}╚═══════════════════════════════════════════════════════════╝${NC}"
echo ""
$DRY_RUN && echo -e " ${YELLOW}🔍 DRY RUN MODE — Nothing will be deleted${NC}\n"
if [ "$(whoami)" = "root" ] && ! $ALLOW_ROOT; then
echo -e " ${RED}✖ Running as root without --allow-root!${NC}"
echo -e " ${YELLOW} Add --allow-root or run as: sudo -u www-data bash $0 $@${NC}\n"
exit 1
fi
check_wp_cli
log "${GREEN}✔ WP-CLI is working.${NC}"
log "${BLUE}► Detecting table prefix...${NC}"
TABLE_PREFIX=$($WP_CMD db prefix 2>/dev/null || echo "wp_")
TABLE_PREFIX=$(echo "$TABLE_PREFIX" | tr -d '[:space:]')
log "${GREEN}✔ Table prefix: ${TABLE_PREFIX}${NC}"
# ─── Step 1: Gather Posts ────────────────────────────────────────────────────
separator
log "${BLUE}► Step 1: Gathering ${TARGET_YEAR} posts (months: ${MONTH_DISPLAY})... please wait${NC}"
POST_IDS_RAW=$($WP_CMD db query "SELECT ID FROM ${TABLE_PREFIX}posts \
WHERE post_type='post' AND YEAR(post_date)=${TARGET_YEAR} ${MONTH_SQL_CLAUSE} \
ORDER BY ID;" --skip-column-names 2>/dev/null || echo "")
if [ -z "$POST_IDS_RAW" ]; then
log "${YELLOW}⚠ No posts found for ${TARGET_YEAR} (months: ${MONTH_DISPLAY}). Nothing to do.${NC}"; exit 0
fi
declare -a POST_IDS=()
parse_ids POST_IDS "$POST_IDS_RAW"
TOTAL_POSTS=${#POST_IDS[@]}
if [ "$TOTAL_POSTS" -eq 0 ]; then
log "${YELLOW}⚠ No posts found for ${TARGET_YEAR} (months: ${MONTH_DISPLAY}). Nothing to do.${NC}"; exit 0
fi
log "${GREEN}✔ Found ${BOLD}${TOTAL_POSTS}${NC}${GREEN} posts from ${TARGET_YEAR} (months: ${MONTH_DISPLAY}).${NC}"
# ─── Step 2: Gather Attachments ──────────────────────────────────────────────
log "${BLUE}► Step 2: Scanning for media attachments... please wait${NC}"
POST_IDS_CSV=$(IFS=,; echo "${POST_IDS[*]}")
ATTACH_RAW=$($WP_CMD db query "
SELECT DISTINCT id FROM (
SELECT ID AS id FROM ${TABLE_PREFIX}posts
WHERE post_type='attachment' AND post_parent IN ($POST_IDS_CSV)
UNION
SELECT CAST(meta_value AS UNSIGNED) AS id FROM ${TABLE_PREFIX}postmeta
WHERE post_id IN ($POST_IDS_CSV) AND meta_key='_thumbnail_id' AND meta_value REGEXP '^[0-9]+$'
) AS combined ORDER BY id;" --skip-column-names 2>/dev/null || echo "")
declare -a UNIQUE_ATTACHMENT_IDS=()
[ -n "$ATTACH_RAW" ] && parse_ids UNIQUE_ATTACHMENT_IDS "$ATTACH_RAW"
TOTAL_ATTACHMENTS=${#UNIQUE_ATTACHMENT_IDS[@]}
log "${GREEN}✔ Found ${BOLD}${TOTAL_ATTACHMENTS}${NC}${GREEN} media attachments to remove.${NC}"
# ─── Summary & Confirmation ─────────────────────────────────────────────────
separator
echo ""
echo -e " ${BOLD}Summary:${NC}"
echo -e " ├── Posts to delete: ${BOLD}${TOTAL_POSTS}${NC}"
echo -e " ├── Attachments to delete: ${BOLD}${TOTAL_ATTACHMENTS}${NC}"
echo -e " ├── Batch size: ${BOLD}${BATCH_SIZE}${NC}"
echo -e " ├── Year / Months: ${BOLD}${TARGET_YEAR}${NC} / ${BOLD}${MONTH_DISPLAY}${NC}"
echo -e " ├── Log file: ${BOLD}${LOG_FILE}${NC}"
echo -e " └── Mode: ${BOLD}$(if $DRY_RUN; then echo 'DRY RUN'; else echo 'LIVE DELETE'; fi)${NC}"
echo ""
if ! $DRY_RUN; then
echo -e "${YELLOW}⚠ Are you sure you want to proceed? (y/N):${NC} "
read -r response
[[ ! "$response" =~ ^[Yy]$ ]] && { log "${RED}✖ Aborted by user.${NC}"; exit 0; }
fi
# ─── Step 3: Database Backup ────────────────────────────────────────────────
if ! $SKIP_BACKUP && ! $DRY_RUN; then
separator
BACKUP_FILE="db-backup-before-${TARGET_YEAR}-cleanup-$(date +%Y%m%d-%H%M%S).sql"
log "${BLUE}► Step 3: Backing up database... please wait${NC}"
if $WP_CMD db export "$BACKUP_FILE" 2>/dev/null; then
log "${GREEN}✔ Backup complete ($(du -h "$BACKUP_FILE" | cut -f1)).${NC}"
else
log "${RED}✖ Database backup failed! Aborting for safety.${NC}"; exit 1
fi
else
log "${YELLOW}► Step 3: Skipping backup.${NC}"
fi
# ─── Step 4: Delete Attachments ─────────────────────────────────────────────
separator
log "${BLUE}► Step 4: Deleting media attachments...${NC}"
batch_delete UNIQUE_ATTACHMENT_IDS "Media " "attachment"
DELETED_ATTACHMENTS=$_BD_DELETED
FAILED_ATTACHMENTS=$_BD_FAILED
log "${GREEN}✔ Media cleanup done. Deleted: ${DELETED_ATTACHMENTS}, Failed: ${FAILED_ATTACHMENTS}${NC}"
# ─── Step 5: Delete Posts ───────────────────────────────────────────────────
separator
log "${BLUE}► Step 5: Deleting posts...${NC}"
batch_delete POST_IDS "Posts " "post"
DELETED_POSTS=$_BD_DELETED
FAILED_POSTS=$_BD_FAILED
log "${GREEN}✔ Post cleanup done. Deleted: ${DELETED_POSTS}, Failed: ${FAILED_POSTS}${NC}"
# ─── Step 6: Flush Caches ──────────────────────────────────────────────────
separator
if ! $DRY_RUN; then
log "${BLUE}► Step 6: Flushing caches...${NC}"
flush_cmd cache flush
flush_cmd transient delete --all
flush_cmd rewrite flush
else
log "${YELLOW}► Step 6: Skipping cache flush (dry run).${NC}"
fi
# ─── Final Report ───────────────────────────────────────────────────────────
separator
echo ""
echo -e "${BOLD}${GREEN}╔═══════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${GREEN}║ CLEANUP COMPLETE ║${NC}"
echo -e "${BOLD}${GREEN}╚═══════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e " ${BOLD}Results:${NC}"
echo -e " ├── Posts deleted: ${BOLD}${DELETED_POSTS}${NC} / ${TOTAL_POSTS}"
echo -e " ├── Posts failed: ${BOLD}${FAILED_POSTS}${NC}"
echo -e " ├── Media deleted: ${BOLD}${DELETED_ATTACHMENTS}${NC} / ${TOTAL_ATTACHMENTS}"
echo -e " ├── Media failed: ${BOLD}${FAILED_ATTACHMENTS}${NC}"
echo -e " ├── Mode: ${BOLD}$(if $DRY_RUN; then echo 'DRY RUN (nothing deleted)'; else echo 'LIVE'; fi)${NC}"
echo -e " └── Log: ${BOLD}${LOG_FILE}${NC}"
echo ""
$DRY_RUN && echo -e " ${YELLOW}💡 This was a dry run. Run without --dry-run to actually delete.${NC}\n"
log "${GREEN}Script finished at $(date).${NC}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment