Bash Script to Rename Files in Bulk With Regex and a Dry-Run Preview
Bash Script to Rename Files in Bulk With Regex and a Dry-Run Preview
You have 4,000 files named like IMG_20260314_final_v2.jpg and you need them to be 2026-03-14-final-v2.jpg. The one-liner that does the work without touching a single file is the dry run:
find . -maxdepth 1 -type f -name '*.jpg' -print0 \
| while IFS= read -r -d '' f; do
new=$(printf '%s' "$f" | sed -E 's#\./(IMG_)([0-9]{4})([0-9]{2})([0-9]{2})_(.*)#./\2-\3-\4-\5#')
[ "$f" != "$new" ] && printf '%s\n -> %s\n' "$f" "$new"
done
Now here is what it actually does, and why the -print0 and IFS= read -r -d '' matter more than the regex does.
This is the failure mode that costs people hours: a filename with a space, a newline, or a leading dash goes through a naive for f in *.jpg loop and the rename either silently skips files or writes to the wrong target. NUL-delimited iteration is the fix, and it costs you eleven extra characters.
Why a dry-run pass is not optional when you rename in bulk
A dry run is the only cheap way to catch a regex that matches more than you intended, and it costs one extra pipeline stage. Renaming is destructive in the sense that a bad pattern applied to 4,000 files produces 4,000 broken references in your build, your database, or your CDN cache before you notice.
The pattern above prints the old path and the proposed new path. Nothing is written. You read the output, you count the lines, you spot the file that became ./.jpg because the capture group was empty, and you fix the pattern before it touches disk.
Concrete numbers worth knowing: Linux NAME_MAX is 255 bytes on ext4 and XFS, so a rename that grows the basename past that limit fails with ENAMETOOLONG — not a warning, a hard error, and it will abort a loop mid-run. rename(2) is atomic within a filesystem, which means a crash mid-loop leaves you with some files renamed and some not, never with a half-written name. That partial state is why you log every operation.
The four approaches, and which one to pick
1. Pure Bash parameter expansion — best when the pattern is simple
Bash parameter expansion handles prefix, suffix, and substring replacement with zero external processes, which matters when you are renaming tens of thousands of files. Bash 4.0 and later (released 2009, standard on every current distro) supports ${var//pattern/replacement} and ${var^^} for case conversion.
#!/usr/bin/env bash
set -euo pipefail
DRY_RUN=${DRY_RUN:-1}
shopt -s nullglob
for f in *.JPG *.JPEG; do
base=${f%.*}
ext=${f##*.}
new="${base,,}.${ext,,}" # lowercase both parts
new="${new// /_}" # spaces to underscores
[[ $f == "$new" ]] && continue
if (( DRY_RUN )); then
printf '%s\n -> %s\n' "$f" "$new"
else
mv -n -- "$f" "$new"
fi
done
The mv -n flag refuses to overwrite an existing target. Do not do this without it: a case-insensitive filesystem (macOS APFS default, Windows NTFS) will let Photo.JPG and photo.jpg collide in ways that behave differently from your Linux CI box.
2. sed with extended regex — best when the pattern is positional
GNU sed with -E is the right tool when the rename depends on capturing groups in fixed positions, like a date embedded in the middle of a filename. The script below is known to work on GNU sed 4.8 (Ubuntu 24.04 default) and BSD sed on macOS 14+, with one caveat noted after the code.
#!/usr/bin/env bash
set -euo pipefail
DRY_RUN=${DRY_RUN:-1}
PATTERN='s#^\./(IMG_)?([0-9]{4})([0-9]{2})([0-9]{2})[_-](.+)$#./\2-\3-\4-\5#'
find . -maxdepth 1 -type f -name '*.jpg' -print0 |
while IFS= read -r -d '' f; do
new=$(printf '%s' "$f" | sed -E "$PATTERN")
[[ $f == "$new" ]] && continue
if (( DRY_RUN )); then
printf '%s\n -> %s\n' "$f" "$new"
else
mv -n -- "$f" "$new" || printf 'FAILED: %s\n' "$f" >&2
fi
done
Two things to know. First, BSD sed does not support \d — use [0-9] or POSIX classes like [[:digit:]] if the script has to run on macOS. Second, the # delimiter is deliberate: filenames contain slashes, so / as a sed delimiter forces you to escape every path separator.
3. The util-linux rename command — best for one-line batch jobs
The rename from util-linux (version 2.37+ ships on Ubuntu 22.04 and later) takes a Perl-style substitution and a file list, and it has a built-in dry run via -n. This is the shortest path when the transform is expressible as a single substitution.
# Preview — writes nothing
rename -n 's/^IMG_(\d{4})(\d{2})(\d{2})_/$1-$2-$3-/' *.jpg
# Apply
rename 's/^IMG_(\d{4})(\d{2})(\d{2})_/$1-$2-$3-/' *.jpg
Be careful about which rename you have. Debian and Ubuntu historically shipped a different, incompatible rename from the rename package that uses s/old/new/ without regex captures. Run rename --version first; if it does not mention util-linux, the syntax above will not work.
4. GNU find -execdir with sh -c — best for deep trees
When files are nested and you want the rename applied relative to each file’s own directory, -execdir is safer than -exec because it runs the command with the file’s directory as the working directory, which sidesteps a whole class of path-injection problems.
find . -type f -name '*.log' -execdir bash -c '
for f; do
new="${f%.log}.log.2026"
printf "%s -> %s\n" "$f" "$new"
# mv -n -- "$f" "$new"
done
' _ {} +
Note the _ placeholder — it becomes $0 inside the subshell, and "$@" holds the file list. Leaving it out is a classic bug where the first filename gets swallowed as the script name.
Which one to pick
Pick pure Bash parameter expansion if the rename is a case change, a space-to-underscore swap, or a prefix/suffix strip, because it spawns no subprocesses. Pick sed if the transform needs positional capture groups. Pick util-linux rename if you want the shortest possible command and you have confirmed the version. Pick find -execdir if the files are in more than one directory.
In all four cases, the workflow is identical: run with DRY_RUN=1 (or -n), pipe the output through wc -l to confirm the count matches your expectation, grep the preview for -> ./\. or empty targets, then flip to apply mode.
DRY_RUN=1 ./rename.sh | tee preview.txt
wc -l preview.txt
grep -E -- '-> \./?[^/]*$' preview.txt # flag suspicious targets
DRY_RUN=0 ./rename.sh
What to watch for after the rename completes
The failure mode that bites after a successful run is stale references, not bad filenames. A bulk rename does not update your database rows, your srcset attributes, your S3 keys, or your .gitignore patterns. Git will show every file as a delete-plus-add rather than a rename until you stage both sides with git add -A, because rename detection is a heuristic on content similarity, not a recorded fact.
If the files are tracked, do the rename inside a branch, run git status --short | wc -l and compare it against twice your file count. Twice is what you expect when Git has not yet paired the deletes with the adds. If you see a number that is not roughly double, some files were renamed to a target that already existed and mv -n silently skipped them.
That silent skip is the one to guard against. Log every skipped file, and treat a non-zero skip count as a failed run.