Home / Guides

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

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.