Bash rename files from a CSV list
Last updated .
Piping a CSV into mv looks simple until two rows collide, a name contains a space, or one row
renames a file that a later row still needs. This page gives a complete bash script that previews by
default, checks the whole batch before touching anything, and renames through a temporary folder. It runs
on the bash that ships with macOS and on Linux.
The CSV the script expects
Two columns, old and new, with an optional header row. The old name is the file as
it exists in the folder; the new name is the name you want. A matching extension in the new column is
ignored, because the extension of the file on disk always wins.
old,new
IMG_0001.jpg,SKU-1001
IMG_0002.jpg,SKU-1002
with space.txt,report final.txt
The script
#!/usr/bin/env bash
# Rename files from a two-column CSV. Dry run by default; pass --apply to rename.
set -u
usage() {
echo "usage: $0 mapping.csv folder [--apply]" >&2
exit 2
}
[ $# -eq 2 ] || [ $# -eq 3 ] || usage
map=$1
apply=${3:-}
[ -f "$map" ] || { echo "no such CSV: $map" >&2; exit 1; }
[ -d "$2" ] || { echo "no such folder: $2" >&2; exit 1; }
dir=$(cd "$2" && pwd) || exit 1
[ -z "$apply" ] || [ "$apply" = "--apply" ] || usage
trim() { printf '%s' "$1" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'; }
plan=$(mktemp "${TMPDIR:-/tmp}/rename-plan.XXXXXX") || exit 1
errors=$(mktemp "${TMPDIR:-/tmp}/rename-err.XXXXXX") || exit 1
sources=$(mktemp "${TMPDIR:-/tmp}/rename-src.XXXXXX") || exit 1
targets=$(mktemp "${TMPDIR:-/tmp}/rename-tgt.XXXXXX") || exit 1
moves=$(mktemp "${TMPDIR:-/tmp}/rename-mv.XXXXXX") || exit 1
trap 'rm -f "$plan" "$errors" "$sources" "$targets" "$moves"' EXIT
line_no=0
while IFS=, read -r old new rest || [ -n "$old" ]; do
line_no=$((line_no + 1))
old=${old%$'\r'}; new=${new%$'\r'}
old=${old#\"}; old=${old%\"}; new=${new#\"}; new=${new%\"}
old=$(trim "$old"); new=$(trim "$new")
if [ "$line_no" -eq 1 ]; then
case "$(printf '%s,%s' "$old" "$new" | tr '[:upper:]' '[:lower:]')" in
old,new) continue ;;
esac
fi
[ -z "$old$new" ] && continue
if [ -n "$rest" ]; then
echo "line $line_no: expected two columns, found more (commas inside names are not supported)" >> "$errors"
continue
fi
if [ -z "$old" ] || [ -z "$new" ]; then
echo "line $line_no: old and new names are both required" >> "$errors"
continue
fi
case "$old" in
*/*|*\\*) echo "line $line_no: old name must be a file in the folder, not a path" >> "$errors"; continue ;;
*$'\t'*) echo "line $line_no: tabs are not supported in file names" >> "$errors"; continue ;;
esac
src="$dir/$old"
if [ ! -f "$src" ]; then
echo "line $line_no: no file named '$old'" >> "$errors"
continue
fi
case "$new" in
*'<'*|*'>'*|*':'*|*'"'*|*'/'*|*'\'*|*'|'*|*'?'*|*'*'*)
echo "line $line_no: '$new' is not a safe file name" >> "$errors"; continue ;;
*[.\ ]) echo "line $line_no: '$new' ends with a dot or space" >> "$errors"; continue ;;
*$'\t'*) echo "line $line_no: tabs are not supported in file names" >> "$errors"; continue ;;
esac
case "$old" in
.*.*) ext=".${old##*.}" ;;
.*) ext="" ;;
*.*) ext=".${old##*.}" ;;
*) ext="" ;;
esac
lower_new=$(printf '%s' "$new" | tr '[:upper:]' '[:lower:]')
lower_ext=$(printf '%s' "$ext" | tr '[:upper:]' '[:lower:]')
if [ -n "$ext" ] && [ "${lower_new%$lower_ext}" != "$lower_new" ]; then
base=${new:0:$((${#new} - ${#ext}))}
else
base=$new
fi
target="$dir/$base$ext"
if [ "$base$ext" = ".rename-tmp" ]; then
echo "line $line_no: .rename-tmp is reserved for the temporary folder" >> "$errors"
continue
fi
if grep -Fxiq "$base$ext" "$targets"; then
echo "line $line_no: two rows produce the same name '$base$ext'" >> "$errors"
continue
fi
printf '%s\n' "$base$ext" >> "$targets"
printf '%s\t%s\n' "$src" "$target" >> "$plan"
done < "$map"
if [ -s "$errors" ]; then
cat "$errors" >&2
echo "stopped: problems found; nothing was renamed" >&2
exit 1
fi
cut -f1 "$plan" > "$sources"
while IFS=$'\t' read -r src target; do
if [ -e "$target" ] && ! grep -Fxq "$target" "$sources"; then
echo "stopped: '$target' already exists; nothing was renamed" >&2
exit 1
fi
done < "$plan"
count=0
while IFS=$'\t' read -r src target; do
printf '%s -> %s\n' "${src##*/}" "${target##*/}"
count=$((count + 1))
done < "$plan"
if [ "$apply" != "--apply" ]; then
printf '\nDry run: %s file(s) would be renamed. Re-run with --apply.\n' "$count"
exit 0
fi
temp="$dir/.rename-tmp"
if [ -e "$temp" ]; then
echo "stopped: $temp exists; remove it after checking for leftovers" >&2
exit 1
fi
mkdir "$temp" || exit 1
rollback() {
while IFS=$'\t' read -r tmp src target; do
if [ -e "$tmp" ]; then
mv "$tmp" "$src"
elif [ -e "$target" ] && [ ! -e "$src" ]; then
mv "$target" "$src"
fi
done < <(tail -r "$moves" 2>/dev/null || tac "$moves" 2>/dev/null)
rmdir "$temp" 2>/dev/null || true
}
i=0
while IFS=$'\t' read -r src target; do
if ! mv "$src" "$temp/$i"; then
echo "stopped: could not move '$src'; attempting to put every file back" >&2
rollback
exit 1
fi
printf '%s\t%s\t%s\n' "$temp/$i" "$src" "$target" >> "$moves"
i=$((i + 1))
done < "$plan"
i=0
while IFS=$'\t' read -r src target; do
if ! mv "$temp/$i" "$target"; then
echo "stopped: could not rename to '$target'; attempting to put every file back" >&2
rollback
exit 1
fi
i=$((i + 1))
done < "$plan"
rmdir "$temp"
printf '\nRenamed %s file(s).\n' "$count"
Run it
Preview first:
bash rename_from_csv.sh names.csv ./photos
When every pair looks right, run the same command with --apply:
bash rename_from_csv.sh names.csv ./photos --apply
The script is written for bash 3.2, the version macOS still ships, so it also runs on Linux without changes. On Windows, run it under WSL or Git Bash.
What it refuses to do
- Rename a row whose old name does not exist in the folder.
- Let two rows produce the same target name, including names that differ only by case.
- Overwrite a file that is not part of the mapping.
- Write a separator, an illegal character, or a trailing dot or space into a name.
- Take the extension from the spreadsheet instead of the file on disk.
One limit worth knowing
This script reads a simple two-column CSV. A comma inside a file name, a tab, or a newline is rejected rather than guessed. If your names contain commas, use the Python version, which parses quoted CSV properly, or paste the table into Mass Rename.
When a script is the wrong tool
A script is fine when you are comfortable editing one and running it again next month. If you would rather not, Mass Rename does the same lookup in a browser tab: drop the CSV and the files, check the preview, download the renamed ZIP. The files are never uploaded, and the free tier covers 50 files per batch. The PowerShell and Python guides cover the same job in other shells.
Questions about renaming with bash
Does it run on macOS?
Yes. It is written for the bash that ships with macOS (3.2) and also runs on Linux and on Windows with WSL or Git Bash. It does not need Python or any extra package.
Does it overwrite files?
No. It checks every row before touching anything, refuses a batch with a missing source, a duplicate target or an existing file that is not in the mapping, and moves files through a temporary folder so swaps and chains do not clobber each other.
What should the CSV look like?
Two columns, old and new, with an optional header row. Commas inside file names are not supported by this script; use the Python version or Mass Rename if your names contain commas.
How do I preview before renaming?
Run it without --apply. It prints every old to new pair and exits without touching the files. Add --apply only when the list looks right.
Why does it use a temporary folder?
Because renames can depend on each other. If one row turns a into b while another turns b into c, renaming top to bottom would destroy the file the second row still needs. Moving everything aside first makes the order irrelevant.
What if two rows ask for the same name?
The script stops before renaming anything and names the duplicate. Fix the spreadsheet and run it again; it will not guess which file should win.
Can I use this instead of installing Mass Rename?
Yes. If you would rather not run a script, Mass Rename does the same lookup in a browser tab: drop the CSV and the files, check the preview, download the renamed ZIP. Files are never uploaded.