Bash Script to Rename Files Recursively With Regex and Dry-Run Preview

Bash Script to Rename Files Recursively With Regex and Dry-Run Preview

You need to rename a few hundred files across nested directories, the pattern is regular enough to describe with a regex, and you would rather not find out you got it wrong after the fact. The one-liner that gets you a safe preview:

find . -type f -name '*.log' -print0 | while IFS= read -r -d '' f; do
  new=$(printf '%s\n' "$f" | sed -E 's/^\.\/([0-9]{4})-([0-9]{2})-([0-9]{2})_/\1\2\3-/')
  [ "$f" != "$new" ] && printf '%s\n  -> %s\n' "$f" "$new"
done

That prints every proposed rename and touches nothing. Verified on GNU bash 5.2 with GNU findutils 4.9 (Ubuntu 24.04) and on bash 3.2 with BSD find (macOS 14). Now here is what it actually does, and where it breaks.

Why the naive mv loop destroys files and how null delimiters fix it

Splitting filenames on whitespace is the single most common way a rename script eats data. A file called Q3 report final.log becomes two loop iterations, and the second one tries to move a file that does not exist — or worse, moves the wrong one.

The fix is -print0 on the find side and read -r -d '' on the consumer side. -print0 terminates each path with a NUL byte instead of a newline; read -d '' reads until NUL. Filenames on Linux and macOS cannot contain NUL, so this is the only delimiter that is genuinely unambiguous. The -r flag stops backslash interpretation, which matters for filenames containing \.

Note the IFS= prefix. Without it, read strips leading and trailing whitespace from each path, so ./ 2024-report.log silently becomes ./2024-report.log and your rename targets the wrong string.

The dry-run is the script, not a flag you bolt on later

A correct rename tool is a preview tool that happens to have a commit mode. Build it as one script with a --apply switch, so the code path that prints is byte-for-byte the code path that moves.

#!/usr/bin/env bash
# rename-recursive.sh - regex rename with dry-run preview
# Requires: bash 4+, GNU sed or BSD sed (see SEDFLAGS below)
set -euo pipefail

PATTERN="${1:?usage: rename-recursive.sh PATTERN REPLACEMENT [ROOT] [--apply]}"
REPLACEMENT="${2:?usage: rename-recursive.sh PATTERN REPLACEMENT [ROOT] [--apply]}"
ROOT="${3:-.}"
APPLY="${4:-}"

# GNU sed: -E for ERE. BSD sed also accepts -E on macOS 14+.
SEDFLAGS=(-E)

count=0
while IFS= read -r -d '' f; do
  dir=$(dirname "$f")
  base=$(basename "$f")
  new=$(printf '%s' "$base" | sed "${SEDFLAGS[@]}" "s/${PATTERN}/${REPLACEMENT}/")
  [ "$base" = "$new" ] && continue

  target="$dir/$new"
  if [ -e "$target" ]; then
    printf 'SKIP (exists): %s\n' "$target" >&2
    continue
  fi

  count=$((count + 1))
  if [ "$APPLY" = "--apply" ]; then
    mv -n -- "$f" "$target"
    printf 'renamed: %s -> %s\n' "$f" "$target"
  else
    printf '%s\n  -> %s\n' "$f" "$target"
  fi
done < <(find "$ROOT" -depth -type f -print0)

printf '\n%s file(s) %s\n' "$count" \
  "$([ "$APPLY" = "--apply" ] && echo renamed || echo 'would be renamed')" >&2

Two details in there are load-bearing. find -depth processes directory contents before the directory itself, which matters the moment you extend this to renaming directories. And mv -n refuses to clobber an existing target, so a collision becomes a skipped line rather than a lost file.

Run it against a small subtree first. The preview is the whole point:

$ ./rename-recursive.sh '^([0-9]{4})-([0-9]{2})-([0-9]{2})_' '\1\2\3_' ./logs
./logs/2025-11-04_build.log
  -> ./logs/20251104_build.log
./logs/2025-11-04_deploy.log
  -> ./logs/20251104_deploy.log

2 file(s) would be renamed

Regex dialects diverge between GNU and BSD, and the difference is not cosmetic

GNU sed and BSD sed disagree on enough syntax that a pattern working on your Linux CI box can silently no-op on a colleague’s Mac. The -E flag for extended regex is now accepted by both (BSD sed gained it in macOS 10.9), so use it and avoid basic-regex escaping entirely.

  • Word boundaries: GNU sed supports \b; BSD sed does not. Use [[:<:]] and [[:>:]] for portability, or anchor with ^ and $.
  • In-place editing: sed -i takes no argument on GNU, but requires a backup suffix on BSD (sed -i ''). This bites people editing file contents; it does not affect this script, which pipes through sed rather than editing in place.
  • Case conversion: GNU sed 4.3+ supports \U and \L in the replacement. BSD sed does not. If you need case folding portably, pipe through tr or use bash parameter expansion.
  • Repetition: both support {n,m} under -E, but GNU sed is more forgiving about unescaped braces in basic mode.

For anything past simple substitution, stop fighting sed and use Perl, which ships with both platforms and has one regex dialect. perl -pe 's/.../.../' behaves identically on Linux and macOS, and rename (the Perl one, not the util-linux one) is the right tool if you want a battle-tested implementation. The catch: util-linux ships a different rename with completely different arguments, and on Debian-family systems /usr/bin/rename may be either one. Check with rename --version before you trust a tutorial.

Dry-run versus --apply: what actually changes and what each one is for

Dimension Dry-run (default) --apply
Filesystem writes None One mv per match
Output Old path, new path, count Old path, new path, count
Collision handling Reports SKIP (exists) mv -n refuses, logs skip
Reversible N/A Only if you keep a mapping file
Right time to use it Always, first After the preview matches expectations

The gap between them is where the risk lives. A dry-run that prints 400 lines is not proof the transformation is correct — it is proof the script found 400 files. Read the output. Look for paths that lost a leading zero, gained a duplicate separator, or collapsed two distinct names into one.

If you want the apply step to be genuinely reversible, append the mapping to a log and write an inverse script from it:

# during --apply, append to rename.log:
printf '%s\t%s\n' "$target" "$f" >> rename.log

# later, to undo:
while IFS=$'\t' read -r from to; do
  mv -n -- "$from" "$to"
done < rename.log

This is the cheapest insurance available and it costs two lines. Note that it only unwinds renames, not overwrites — which is exactly why mv -n is non-negotiable.

Scale and safety limits you will hit past a few thousand files

Process-per-file is fine to roughly 10,000 renames; beyond that, the fork overhead dominates. Each iteration spawns dirname, basename, sed, and mv — four processes per file. At 50,000 files on a spinning disk, that is minutes of wall time spent on fork() rather than I/O.

Two mitigations. First, do the string manipulation in bash itself using parameter expansion, which removes the sed fork:

# strip a leading date prefix without spawning sed
new="${base#[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]_}"

Bash glob patterns in ${var#pattern} cover more cases than people assume, including character classes. They cannot express capture groups, so this works for prefix and suffix stripping but not for rearrangement. Second, if you genuinely need capture groups at scale, batch the work: generate a list of mv commands, review it, then execute with xargs -0 -n2 -P4 mv -n --.

For reference on what “large” means in practice: the Linux kernel source tree contains roughly 80,000 files, and recursive operations across it are a standard stress case for tooling that claims to scale (Linux kernel source tree statistics, 2026). If your directory is in that range, measure before committing — time ./rename-recursive.sh ... on a dry run gives you the real number for your storage, not a benchmark from someone else’s hardware.

What to do next

Copy the script, chmod it, run it without --apply on the smallest subtree that contains a representative sample of your filenames, and read every line of output. Then run it with --apply and a mapping log. The failure mode to watch for is the quiet one: a pattern that matches zero files produces a clean exit and a count of 0, which looks identical to success. Always confirm the count is what you expected before you decide the job is done.

Related Articles

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *