Bash Script to Batch Rename Files With Spaces to Underscores Recursively (2026)
Bash Script to Batch Rename Files With Spaces to Underscores Recursively (2026)
Filenames with spaces break more automation than almost anything else: xargs splits them, for f in $(ls) tears them apart, and half your CI pipeline quietly does the wrong thing. The fix is one recursive pass that swaps every space for an underscore, and it takes about six lines of Bash.
The one-liner, if you want it right now and you are already inside the directory you want to clean:
find . -depth -name '* *' -print0 | while IFS= read -r -d '' f; do
dir=$(dirname "$f")
base=$(basename "$f")
mv -- "$f" "$dir/${base// /_}"
done
That is known to work on GNU Bash 5.2 and GNU coreutils 9.4 (Ubuntu 24.04, Debian 13, and WSL2). Now here is what it actually does, why each flag is there, and the failure modes that cost people hours.
Why the naive find -exec mv approach destroys your files
The most common broken version of this script renames files with spaces into paths that no longer exist, because it renames a parent directory before it renames the files inside it.
Consider a tree like ./my project/notes 2026/draft final.txt. A top-down find visits ./my project first, renames it to ./my_project, and then tries to mv "./my project/notes 2026/draft final.txt" — a path that is now stale. You get No such file or directory for every file underneath, and if you suppressed errors, you may not notice until your build fails next week.
The fix is -depth: process the deepest entries first, so files are renamed before their containing directories. This is the single most important flag in the script and the one people drop when they copy a snippet from a forum post.
Do not do this:
find . -name '* *' -exec mv {} $(echo {} | tr ' ' '_') \;
That version is wrong three ways: no -depth, unquoted command substitution that re-splits on whitespace, and {} substituted inside a shell expansion that runs before find even hands over the path. Do this instead — the -print0 / read -d '' pairing above, which handles spaces, newlines, and glob characters in filenames.
The full script, with a dry run and a log file
A production-safe version prints what it would do before it does it, and writes a rename log you can reverse. Here is the complete script, tested against GNU Bash 5.2:
#!/usr/bin/env bash
set -euo pipefail
TARGET="${1:-.}"
DRY_RUN="${DRY_RUN:-1}"
LOG="${LOG:-rename-$(date +%Y%m%d-%H%M%S).log}"
if [[ ! -d "$TARGET" ]]; then
echo "Not a directory: $TARGET" >&2
exit 1
fi
find "$TARGET" -depth -name '* *' -print0 |
while IFS= read -r -d '' f; do
dir=$(dirname -- "$f")
base=$(basename -- "$f")
new="$dir/${base// /_}"
[[ "$f" == "$new" ]] && continue
if [[ "$DRY_RUN" == "1" ]]; then
printf 'WOULD RENAME: %q -> %q\n' "$f" "$new"
else
mv -- "$f" "$new"
printf '%q\t%q\n' "$f" "$new" >> "$LOG"
fi
done
echo "Done. DRY_RUN=$DRY_RUN LOG=$LOG"
Run it with DRY_RUN=1 bash rename-spaces.sh /path/to/tree first. When the output looks right, run DRY_RUN=0 bash rename-spaces.sh /path/to/tree. The log file gives you a reversible record — feed it back through a small loop to undo.
Two details worth calling out. First, ${base// /_} is Bash parameter expansion, not sed — it replaces all occurrences, not just the first, and it never touches the directory portion of the path. Second, printf '%q' shell-quotes the output, so paths containing quotes, tabs, or newlines stay readable and copy-pasteable.
How to handle the edge cases that actually show up in real trees
Spaces are the headline problem, but real repositories also contain tabs, leading dashes, and duplicate targets — and a rename script that ignores them will either fail or silently overwrite a file.
- Tabs and multiple whitespace characters. Extend the pattern to
${base//[[:space:]]/_}to collapse any whitespace class character into an underscore. This is POSIX character-class syntax supported by Bash 5.x. - Filenames starting with a dash. Always use
mv -- "$f" "$new". Without the--, a file named-notes.txtis interpreted as an option. - Collisions.
report final.txtandreport_final.txtboth map toreport_final.txt. Add a check before moving:[[ -e "$new" ]] && { echo "COLLISION: $new"; continue; }. - Symlinks and special files.
findwill happily rename symlinks. If you want to skip them, add-type f -o -type dto the expression, or-not -type l.
For the collision check, the safe pattern looks like this:
if [[ -e "$new" && "$f" != "$new" ]]; then
echo "SKIP (target exists): $f -> $new" >&2
continue
fi
Where the time actually goes on large trees
Rename throughput is dominated by filesystem metadata operations, not by the script itself. On a typical ext4 volume, a rename is an inode operation that does not move data blocks, so a tree of 50,000 files with spaces can complete in seconds — the bottleneck is the find traversal and the per-file mv fork.
If you need to measure your own tree, time the dry run first:
time DRY_RUN=1 bash rename-spaces.sh /data/archive
That gives you the traversal cost without any mutation. If the dry run takes longer than you are willing to wait, the fork-per-file mv is your cost, and you should batch the renames rather than trying to micro-optimize the Bash loop.
For context on why this matters at scale: the Linux kernel’s own documentation for rename(2) notes that renaming is atomic within a filesystem, which is why the mv-per-file approach is safe to interrupt — you never get a half-renamed file, only a partially-processed tree. That property is worth preserving; do not replace mv with a copy-and-delete loop.
What to check before you point this at a git repository
Renaming tracked files with spaces will show up as a delete plus an add in git status unless you use git mv. For a working tree, run the script, then stage with git add -A and commit — Git’s rename detection handles the rest at diff time.
The failure mode to watch for: running the script on a tree that is currently being written to by another process. If a build tool, sync client, or editor is creating files while find is walking, you can rename a file out from under an open handle. On Linux the open handle survives the rename, but the writer’s next open() by the old path fails. Quiesce the tree first — stop the sync client, close the editor — then run the dry run, then run for real.
And keep the log file. It is the only thing standing between a botched rename and a restore from backup.