Build the mental model
A backup script is only half the job — without a rotation policy, backups accumulate forever until a disk fills up at the worst possible moment, usually right when a restore is needed. This project builds rotation as a self-contained function, rotate_backups(), and combines several earlier concepts to do it safely. mapfile -t reads the output of ls, one filename per array element, into the backups array — safer than a naive for f in $(ls ...) word-splitting loop because filenames are captured as whole elements even if they contained spaces. The listing runs inside a subshell, (cd "$dir" && ls ...), so the parent script's own working directory is never changed as a side effect, and the filenames come back without a directory prefix. Array length, ${#backups[@]}, and arithmetic comparison, (( total <= keep )), decide whether there is anything to do at all — a conditional guard that makes the function a safe no-op when the backup count hasn't exceeded the keep limit. When rotation is needed, a C-style for loop indexes into the array (backups[$i]) to delete exactly the oldest delete_count entries, leaving the rest untouched. All of this only works correctly because filenames encode a YYYYMMDD-HHMMSS timestamp: sorted as plain strings, that format happens to sort chronologically too, so no date parsing is required anywhere in the script — a small design choice that eliminates an entire category of bugs.
Connect it to a real scenario
A nightly cron job that backs up a database or project directory is one of the most common real-world uses of shell scripting, but a surprising number of first attempts forget the rotation step entirely — until a server runs out of disk space months later because thousands of old tarballs quietly piled up, sometimes taking down the very service the backups were meant to protect. Trace what this script does: since a real backup run would call tar -czf on a schedule, the setup here instead seeds five fake, pre-timestamped files with touch so the rotation logic runs against deterministic, known input rather than whatever happens to exist on a real disk. rotate_backups is called with the directory and KEEP=3. Inside, it lists and sorts the backup filenames, prints the count and names it found, then checks total <= keep — with 5 found and 3 to keep, rotation is needed. It computes delete_count as 2, loops over indices 0 and 1 (the two oldest, because the array is sorted oldest-first), deletes each with rm -f, and finally re-lists what remains to confirm the newest three survived. In production, swapping the fake seed step for a real tar -czf call is all that's needed to turn this into a working nightly backup job — the rotation logic underneath does not need to change at all, because it only ever depends on filenames, not on how they were created.
Try the working example
#!/usr/bin/env bash
set -euo pipefail
# --- Setup: simulate a backup directory that already has several backups ---
# In a real backup script these would be created by:
# tar -czf "backup-$(date +%Y%m%d-%H%M%S).tar.gz" /path/to/data
# Here we seed fixed, fake-timestamped files so the rotation logic below
# is 100% deterministic to demonstrate and verify.
BACKUP_DIR="demo_backups"
rm -rf "$BACKUP_DIR"
mkdir -p "$BACKUP_DIR"
for ts in 20260101-000000 20260102-000000 20260103-000000 20260104-000000 20260105-000000; do
touch "$BACKUP_DIR/backup-${ts}.tar.gz"
done
KEEP=3
rotate_backups() {
local dir="$1"
local keep="$2"
# List backup files oldest-first (names sort correctly because the
# timestamp format is YYYYMMDD-HHMMSS, which sorts lexically = chronologically)
local -a backups
mapfile -t backups < <(cd "$dir" && ls backup-*.tar.gz | sort)
local total=${#backups[@]}
echo "Found $total backup(s) in $dir:"
for f in "${backups[@]}"; do
echo " $f"
done
echo ""
if (( total <= keep )); then
echo "Nothing to rotate: $total backup(s) <= keep limit of $keep."
return
fi
local delete_count=$(( total - keep ))
echo "Keeping newest $keep, deleting $delete_count oldest backup(s):"
for (( i=0; i<delete_count; i++ )); do
echo " DELETE ${backups[$i]}"
rm -f "$dir/${backups[$i]}"
done
echo ""
echo "Remaining backups after rotation:"
(cd "$dir" && ls backup-*.tar.gz | sort)
}
rotate_backups "$BACKUP_DIR" "$KEEP"Found 5 backup(s) in demo_backups:
backup-20260101-000000.tar.gz
backup-20260102-000000.tar.gz
backup-20260103-000000.tar.gz
backup-20260104-000000.tar.gz
backup-20260105-000000.tar.gz
Keeping newest 3, deleting 2 oldest backup(s):
DELETE backup-20260101-000000.tar.gz
DELETE backup-20260102-000000.tar.gz
Remaining backups after rotation:
backup-20260103-000000.tar.gz
backup-20260104-000000.tar.gz
backup-20260105-000000.tar.gz5-minute try-it
Add a second rotation mode that deletes backups older than N days instead of keeping a fixed count, using find's -mtime flag, and let the caller choose between the two modes with a command-line flag.
One important caution
Deleting files with a wildcard or loop before double-checking the keep count math (off-by-one errors) can silently delete the newest backup instead of the oldest — always verify with a dry-run echo before wiring up real rm calls.
Relying on file modification time (mtime) instead of the timestamp encoded in the filename breaks the moment a backup is copied, restored, or touched by another process, since copying resets mtime but not the name.
GNU tar Manual — Bash / Shell Scripting