mv patterns, rename, find sorting and bulk renaming in Bash
File management tasks are part of everyday automation: sorting hundreds of log files by date, renaming export files to match a naming convention, cleaning up temporary build artifacts. With the right mv patterns, rename for bulk renaming and find for sorting and filtering, these tasks can be solved safely, traceably and reversibly.
Table of Contents
- 1. Using mv safely: overwrite protection and backups
- 2. Renaming with Bash parameter expansion
- 3. rename for bulk renaming with regex
- 4. find for sorting by date, size and type
- 5. Organizing files into directory structures
- 6. The dry run pattern for safe bulk operations
- 7. Cleaning up: finding and removing old files
- 8. Renaming tools compared
- 9. The undo pattern for bulk file operations
- 10. Summary
- 11. FAQ
1. Using mv safely: overwrite protection and backups
The mv command is the most basic tool for moving and renaming files in the shell. Without extra flags, mv overwrites the target file without warning and without any way to undo it. In automation scripts that rename or move files, that is a serious risk. The -n flag (no-clobber) prevents overwriting: if the target file exists, mv -n aborts silently without an error and without taking action. The -b flag creates a backup of the target file before it is overwritten, by default as file~, configurable via --backup=numbered for numbered backups.
For critical file operations, a two step pattern is recommended: first check with -n whether the target is free, then move with an explicit backup. In scripts that move many files in a batch, it makes sense to check before every operation that the source and target differ, that the source file actually exists, and that the target path exists as a directory. These defensive checks make the script robust against incomplete preconditions and produce clear error messages instead of silent failures.
An important aspect of moving files across filesystem boundaries: on the same filesystem, mv is a pure inode operation and completes instantly. Across filesystem boundaries, mv has to copy the file and then delete the source, which takes time for large files and, if interrupted, can leave the file existing both at the source and (partially) at the destination. For moving large files across networks or separate filesystems, rsync --remove-source-files is the more robust tool.
2. Renaming with Bash parameter expansion
Bash's parameter expansion offers powerful ways to rename files without having to start external tools. The most common pattern: swapping file extensions with ${filename%.jpg}.webp or stripping prefixes with ${filename#prefix_}. These operations run as builtins with no subshell overhead and are ideal for renaming inside tight loops over many files. The // pattern in parameter expansions replaces every occurrence of a character: ${filename// /_} replaces every space with an underscore, a classic pattern for safely renaming files for Unix paths.
For more complex renaming tasks involving case changes, Bash 4+ offers the expansions ${var,,} (lowercase everything) and ${var^^} (uppercase everything). The pattern ${var,} lowercases only the first letter. These operations make external tools like tr unnecessary for simple case conversions. For bulk renaming files in loops, you can embed the Bash parameter expansion directly into the mv call without needing a temporary variable.
#!/usr/bin/env bash
# rename_patterns.sh: File renaming with Bash parameter expansion
set -euo pipefail
# Replace spaces and special characters in filenames
sanitize_names() {
local dir="${1:-.}"
local count=0
while IFS= read -r -d '' file; do
local dir_part filename new_name
dir_part="$(dirname "$file")"
filename="$(basename "$file")"
# Replace spaces, parentheses, and special chars with underscores
new_name="${filename//[[:space:]]/_}"
new_name="${new_name//[()[\]{}]/_}"
new_name="${new_name,,}" # lowercase (Bash 4+)
if [[ "$filename" != "$new_name" ]]; then
mv -n -- "$file" "${dir_part}/${new_name}" && (( count++ )) || true
echo "[RENAME] '$filename' -> '$new_name'"
fi
done < <(find "$dir" -maxdepth 1 -type f -print0)
echo "[INFO] Renamed $count files"
}
# Change file extension: .jpeg -> .jpg
rename_extension() {
local dir="${1:-.}"
local from="${2:?from-extension required}"
local to="${3:?to-extension required}"
while IFS= read -r -d '' file; do
local new="${file%.$from}.$to"
[[ "$file" != "$new" ]] && mv -n -- "$file" "$new" && echo "[EXT] $file -> $new"
done < <(find "$dir" -name "*.$from" -type f -print0)
}
# Add date prefix: report.pdf -> 2026-05-09_report.pdf
add_date_prefix() {
local dir="${1:-.}"
local date_prefix
date_prefix="$(date +%Y-%m-%d)"
while IFS= read -r -d '' file; do
local dir_part filename
dir_part="$(dirname "$file")"
filename="$(basename "$file")"
[[ "$filename" == ${date_prefix}_* ]] && continue # already prefixed
mv -n -- "$file" "${dir_part}/${date_prefix}_${filename}"
done < <(find "$dir" -maxdepth 1 -type f -print0)
}
sanitize_names "${1:-.}"
3. rename for bulk renaming with regex
The rename tool (the Perl variant, available as prename or rename.ul) allows bulk renaming of files with the full power of Perl regex in a single command. rename 's/\.jpeg$/.jpg/i' *.jpeg renames every JPEG file in one step. rename 's/ /_/g' * replaces every space with an underscore. The -n flag enables a dry run that shows what would be renamed without actually changing anything, indispensable before any bulk rename.
On Debian/Ubuntu systems, the Perl-based tool is called rename. On macOS (Homebrew: brew install rename) and on RHEL/CentOS (where the installed rename is the util-linux tool with different syntax), the version available can differ. A portable script therefore checks which variant is available. On systems without Perl rename, the same result can be achieved with a find-mv loop that uses Bash parameter expansion or sed for the name transformation. Renaming with rename is by far the most convenient approach once you are dealing with a hundred files or more.
4. find for sorting by date, size and type
The find command is the most flexible tool for sorting and filtering files by various criteria. With -mtime +7 you find every file that has not been modified for more than seven days. With -newer reference_file you find every file that is newer than a reference file. For sorting by date within find, -printf '%T@ %p\n' combined with sort -n is the standard pattern: it prints a Unix timestamp and a path, sort -n sorts by timestamp, and awk '{print $2}' extracts the path.
Combining find with -exec mv or process substitution enables directly moving found files into new structures. The pattern find /source -name "*.log" -mtime +30 -exec mv -t /archive {} + moves every log file older than 30 days into the archive directory. The + at the end of -exec passes every found file to a single mv call instead of starting a new process for each file, considerably more efficient with many files.
#!/usr/bin/env bash
# organize_by_date.sh: Sort and move files into date-based directory structure
set -euo pipefail
readonly SOURCE_DIR="${1:?Usage: $0 <source-dir> <target-dir>}"
readonly TARGET_DIR="${2:?Target directory required}"
readonly DRY_RUN="${DRY_RUN:-0}"
declare -i moved=0 skipped=0
move_or_dry() {
local src="$1" dest_dir="$2"
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "[DRY-RUN] mv '$src' -> '${dest_dir}/'"
else
mkdir -p "$dest_dir"
mv -n -- "$src" "${dest_dir}/" && (( moved++ )) || (( skipped++ ))
fi
}
# Sort files by modification date into YYYY/MM subdirectories
while IFS= read -r -d '' file; do
# Get modification year and month
mod_year=$(date -r "$file" +%Y 2>/dev/null || stat -c '%y' "$file" | cut -c1-4)
mod_month=$(date -r "$file" +%m 2>/dev/null || stat -c '%y' "$file" | cut -c6-7)
dest="${TARGET_DIR}/${mod_year}/${mod_month}"
move_or_dry "$file" "$dest"
done < <(find "$SOURCE_DIR" -maxdepth 1 -type f -print0)
if [[ "$DRY_RUN" -eq 0 ]]; then
echo "[INFO] Moved: $moved, Skipped (exists): $skipped"
else
echo "[DRY-RUN] No changes made. Set DRY_RUN=0 to execute."
fi
# Find and list the 10 largest files in a directory tree
echo "--- Top 10 largest files ---"
find "${SOURCE_DIR}" -type f -printf '%s %p\n' \
| sort -rn \
| head -10 \
| awk '{ printf "%.1f MB %s\n", $1/1024/1024, $2 }'
5. Organizing files into directory structures
Organizing files into directory structures is a common automation task: organizing photos by capture date, archiving logs by month, sorting downloads by file type. The generic pattern consists of three steps: read the file's metadata (date, type, size), compute the target directory path and move the file. With mkdir -p, target directories are created as needed without first checking whether they exist. The -p flag makes mkdir idempotent and prevents errors when the directory already exists.
For sorting files by type, file is the most reliable tool because it detects the type from the file content rather than just the extension. file --mime-type -b file.jpg returns image/jpeg regardless of the file extension. This allows robust classification even of incorrectly named files. With case statements or associative arrays, MIME types can be mapped to target directories: image/* to /media/images, video/* to /media/videos, application/pdf to /docs.
6. The dry run pattern for safe bulk operations
The dry run pattern is the most important safety measure when bulk renaming and moving files. A dry run runs the script through completely, including every calculation and validation, but replaces every destructive operation (mv, rm, cp) with echo output. The operator sees exactly what the script would do without anything actually changing. Only after manually reviewing the output is the script run again with the dry run disabled.
An elegant implementation uses a shell function run_cmd() that, depending on the $DRY_RUN variable, either executes the command or prints it prefixed with [DRY-RUN]. Every destructive operation in the script is routed through this function. That way, dry run mode can be enabled with a single environment variable, DRY_RUN=1 ./script.sh, without changing the code. This is especially important for bulk rename operations on production data, where a mistake can affect hundreds of files.
7. Cleaning up: finding and removing old files
Regularly cleaning up files is a classic cron job task: deleting old log files, cleaning up temporary build artifacts, removing expired export files. find with -mtime +N (older than N days), -size +N (larger than N) and -type f precisely finds the candidates. The subsequent -delete flag or -exec rm removes them. The order of tests in find is decisive: checks that exclude many files should come first, so that find only runs the expensive checks on the candidates that have already passed the cheap tests.
The trash pattern is a safer alternative to deleting directly: instead of rm, files are moved with mv into a trash directory. A separate cron job empties the trash directory after a defined period. That gives you a window for manual recovery in case a file was mistakenly flagged as eligible for cleanup. The pattern is especially useful for automated cleanup routines that decide based on rules, and where those rules can occasionally be wrong.
#!/usr/bin/env bash
# cleanup.sh: Safe cleanup with trash pattern and reporting
set -euo pipefail
readonly LOG_DIR="${LOG_DIR:-/var/log/app}"
readonly TRASH_DIR="${TRASH_DIR:-/var/log/app/.trash}"
readonly MAX_AGE_DAYS="${MAX_AGE_DAYS:-30}"
readonly TRASH_RETENTION_DAYS="${TRASH_RETENTION_DAYS:-7}"
readonly DRY_RUN="${DRY_RUN:-0}"
declare -i moved_to_trash=0 purged=0 total_freed_bytes=0
log() { printf '%s [%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$1" "$2" >&2; }
mkdir -p "$TRASH_DIR"
# Phase 1: Move old files to trash (soft delete)
log INFO "Phase 1: Moving files older than ${MAX_AGE_DAYS} days to trash"
while IFS= read -r -d '' file; do
local_size=$(stat -c '%s' "$file" 2>/dev/null || echo 0)
if [[ "$DRY_RUN" -eq 1 ]]; then
log INFO "[DRY] Would trash: $file ($(( local_size / 1024 )) KB)"
else
mv -- "$file" "${TRASH_DIR}/$(date +%Y%m%d%H%M%S)_$(basename "$file")"
(( moved_to_trash++ )) || true
(( total_freed_bytes += local_size )) || true
fi
done < <(find "$LOG_DIR" -maxdepth 2 -type f -name "*.log" -mtime +"$MAX_AGE_DAYS" -print0)
# Phase 2: Purge trash older than retention period (hard delete)
log INFO "Phase 2: Purging trash older than ${TRASH_RETENTION_DAYS} days"
while IFS= read -r -d '' old_trash; do
if [[ "$DRY_RUN" -eq 1 ]]; then
log INFO "[DRY] Would purge: $old_trash"
else
rm -- "$old_trash"
(( purged++ )) || true
fi
done < <(find "$TRASH_DIR" -maxdepth 1 -type f -mtime +"$TRASH_RETENTION_DAYS" -print0)
log INFO "Summary: moved_to_trash=$moved_to_trash, purged=$purged, freed=$(( total_freed_bytes / 1024 / 1024 ))MB"
8. Renaming tools compared
Several tools are available for renaming and moving files in the shell, and they differ in power, portability and learning curve.
| Tool | Strengths | Limitations | Dry run |
|---|---|---|---|
mv |
POSIX, available everywhere, -n/-b flags | No regex, no batch mode | Manual, via echo wrapper |
rename (Perl) |
Full Perl regex, bulk renaming | Not installed everywhere, Perl dependency | -n / --dry-run flag |
| Bash expansion + mv | No extra tool, very fast | Only Bash expansions, no regex | Manual, via DRY_RUN variable |
mmv |
Wildcards, interactive | Rarely preinstalled, dated syntax | -n flag available |
rsync --remove-source |
Safe across FS boundaries, checksums | No renaming, only moving | --dry-run flag built in |
For day to day work renaming files in Bash, combining Bash parameter expansion for simple cases with Perl rename for regex operations is the most productive approach. rsync --remove-source-files should always be preferred when files are moved across networks or filesystem boundaries, because it offers atomic transfer and integrity checking. mv with the -n flag should always be used in scripts whenever overwriting existing files is undesirable.
9. The undo pattern for bulk file operations
The biggest risk in bulk file operations is that a logic error affects hundreds of files before you notice it. The undo pattern addresses this risk by logging every operation performed to an undo log. The undo log is a list of mv commands that reverse the move and rename operations that were carried out. After a bulk rename, the undo log therefore contains every mv new-name old-name command in the correct order.
The implementation is simple: a wrapper function for mv performs the operation and simultaneously writes the inverse command to an undo file. The undo script is then a Bash file that can be sourced or run and that executes the operations in reverse order. For cleanup with the trash pattern, undo is even simpler: mv trash/file original-path restores the file as long as it is still in the trash directory. The undo pattern makes bulk file operations reversible and removes the fear of running automation scripts against production data.
#!/usr/bin/env bash
# undo_rename.sh: Mass rename with automatic undo log generation
set -euo pipefail
readonly SOURCE_DIR="${1:?Usage: $0 <directory> [pattern] [replacement]}"
readonly PATTERN="${2:-}"
readonly REPLACEMENT="${3:-}"
readonly UNDO_LOG="${SOURCE_DIR}/.undo_rename_$(date +%Y%m%d_%H%M%S).sh"
readonly DRY_RUN="${DRY_RUN:-0}"
declare -i renamed=0
# Write undo script header
cat > "$UNDO_LOG" << 'HEADER'
#!/usr/bin/env bash
# Auto-generated undo script: run to revert rename operations
set -euo pipefail
HEADER
echo "echo 'Reverting rename operations...'" >> "$UNDO_LOG"
safe_rename() {
local old="$1" new="$2"
[[ "$old" == "$new" ]] && return 0
if [[ -e "$new" ]]; then
echo "[SKIP] Target exists: $new" >&2
return 0
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "[DRY] mv '$old' -> '$new'"
else
mv -- "$old" "$new"
# Append reverse operation to undo log (in reverse order)
echo "mv -- '${new}' '${old}'" >> "$UNDO_LOG"
(( renamed++ )) || true
echo "[OK] '$old' -> '$new'"
fi
}
# Apply pattern-based rename to all files
while IFS= read -r -d '' file; do
dir_part="$(dirname "$file")"
filename="$(basename "$file")"
if [[ -n "$PATTERN" ]]; then
new_filename="${filename//$PATTERN/$REPLACEMENT}"
else
# Default: sanitize spaces and lowercase
new_filename="${filename//[[:space:]]/_}"
new_filename="${new_filename,,}"
fi
safe_rename "$file" "${dir_part}/${new_filename}"
done < <(find "$SOURCE_DIR" -maxdepth 1 -type f -print0)
echo "echo 'Undo complete: $renamed operations reverted'" >> "$UNDO_LOG"
chmod +x "$UNDO_LOG"
echo "[INFO] Renamed: $renamed. Undo log: $UNDO_LOG"
Mironsoft
File automation, ETL pipelines and shell infrastructure
Need safe file operations in your shell automation?
We build robust shell scripts for renaming, moving, sorting and cleaning up files, with dry run mode, undo logging, error handling and integration into your existing cron and CI infrastructure.
File automation
Bulk renaming, sorting and archiving based on configurable rules
Cleanup routines
Safe cleanup scripts with trash pattern, retention policy and reporting
Archiving pipelines
Structuring files by date, type or size and moving them into archives
10. Summary
Safely renaming, moving, sorting and cleaning up files in Bash follows a few clear principles. mv -n prevents unintended overwrites. The dry run pattern with a DRY_RUN variable makes every bulk operation reviewable before it runs. The undo log records every operation performed as a reverse mv command and makes the entire operation reversible. The trash pattern replaces direct deletion with moving files into a trash directory with delayed cleanup.
Perl rename is the most powerful tool for bulk renaming with regex support and a built in -n dry run. Bash parameter expansion is sufficient for simple cases with no external dependency. find with -printf '%T@ %p' and sort -n sorts files by timestamp for organizing them into date based archive structures. Combining these tools with careful error handling produces file operation scripts that can be trusted even on production data.
Renaming, moving and cleaning up files: the essentials at a glance
Safety
mv -n prevents overwriting. Run the dry run pattern with DRY_RUN=1 before every bulk operation. Undo log for reversibility.
Bulk renaming
Perl rename with -n for regex operations. Bash expansion for simple cases. find + mv for date based sorting.
Cleanup
Trash pattern: mv instead of rm, trash directory with retention policy. find -mtime +N for old files. -delete only after checking the dry run output.
Moving
rsync --remove-source-files for safe transfers across FS boundaries. find -exec mv -t /target {} + for batch moves into a target directory.