Batch renaming from a list instead of a pattern
Every guide to a batch rename command starts from the same assumption: one rule applies to every file in the selection. Strip a prefix, swap the halves around a hyphen, lowercase the extension. That assumption holds often enough that it goes unstated, and it collapses the moment the new name depends on something the old name does not contain. A shoot log, a client code list, a chapter order that lives in a document. At that point the answer is not a cleverer regular expression. The answer is to stop deriving names and start supplying them, one line at a time, from a file.
The test that decides which approach applies
There is a single question worth asking before writing anything: can the rename be stated in one sentence with no exceptions?
"Remove IMG_ from the front of every file" passes. So does "pad the counter to three digits" and "replace the underscore with a hyphen." Each of these looks only at the string it is given, applies the same transformation, and produces the answer. zmv, a for loop with sed, or the Finder rename panel can all express them.
"Name each photo after the room it was taken in" fails. So does "apply the client code that the invoice spreadsheet assigns to each project" and "use the heading on page one as the filename." The information needed to build the new name is not present in the old name, so no pattern can reach it. Attempts to force the case turn into a chain of conditionals, one per file, which is a mapping file written in the least convenient syntax available.
That is the whole decision. Material inside the name means a pattern. Material outside the name means a list. Recognising which situation is on the screen saves more time than any syntax reference, because the two approaches fail in completely different ways and the debugging effort does not transfer.
Three stages, each one inspectable
A list-driven rename splits into three stages that can be checked independently.
| Stage | Output | Tool |
|---|---|---|
| Capture | current names, one per line | find redirected to a file |
| Compose | a second column of new names | spreadsheet, editor, or script |
| Apply | the rename itself | while read and mv |
The advantage over a single-line pattern is visibility. A pattern shows its result only when it runs, which is why every guide insists on a dry run. A mapping file shows every before and after pair as ordinary text, sortable, searchable, and reviewable by somebody who has never opened a terminal. On a set of four hundred files that difference decides whether an error is caught during review or discovered three weeks later.
The second advantage is that the stages can be split between people. Whoever knows what the files contain writes the second column. Whoever has the machine runs the third stage. The handoff is a text file, which needs no shared tooling and no shared shell configuration.
Capturing the current names without gaps
The capture stage fails quietly. A listing that looks complete on screen can be missing hidden files, files in subdirectories, and files whose extension is uppercase.
cd ~/Pictures/site-a
find . -maxdepth 1 -type f -iname '*.jpg' -print0 | sort -z > names.txt
Two details matter here. -iname instead of -name catches IMG_0001.JPG alongside img_0002.jpg, which is the single most common reason a count comes up short. -print0 paired with sort -z separates entries with a null byte rather than a newline, so a filename containing a space, a newline, or a quotation mark stays intact as one record. Splitting on newlines turns one such file into two broken records, and the breakage only becomes visible after the rename has run.
Count the result and compare it against the count Finder reports for the same selection. A mismatch at this stage costs one command to fix. The same mismatch discovered after the third stage means sorting renamed files from unrenamed ones by hand.
Validating the new column before anything moves
Names composed in a spreadsheet look fine and still fail, because macOS refuses three shapes.
A colon cannot appear in a filename, which catches anyone formatting a timestamp as 10:30. A name beginning with a period becomes invisible in Finder. And a single name is capped at 255 characters, counted as characters rather than bytes, so a Japanese name of 255 characters is accepted and 256 is rejected exactly like an ASCII one. Concatenating a client, a project, a document type, and a date approaches that ceiling faster than expected, and only the longest names fail, leaving a partially renamed directory.
Checking the mapping file itself takes one command.
awk -F'\t' '{
if ($2 ~ /:/) print "colon: " $2
if ($2 ~ /^\./) print "leading dot: " $2
if (length($2) > 255) print "too long: " $2
if (seen[$2]++) print "duplicate target: " $2
}' map.tsv
The fourth test is the important one. Two rows sharing a target name means the second mv silently replaces the first file, and the loss shows up as a file count that dropped by three. Renumbering a column in a spreadsheet produces exactly this, and no dry run of the rename itself will flag it, because each individual move is legal.
Applying the map
while IFS=$'\t' read -r old new; do
[ -z "$old" ] && continue
mv -n -- "$old" "$new"
done < map.tsv
Setting IFS to a tab for the duration of read keeps names with spaces in one piece. The -n flag on mv tells it not to overwrite an existing file, which is documented in the manual page shipped with macOS and is the cheapest protection available against the duplicate-target case above. The -i flag prompts instead, which is unusable past a few dozen rows.
The -- separator matters more than it looks. A file called -2026-01.pdf is a valid name and an invalid argument, and without the separator mv reads it as a set of flags.
For a dry run, change mv to echo mv and run the loop once. Every line of output is a complete statement of what will happen, and it can be saved, diffed, or pasted into a review. Working in a file manager with a built-in terminal keeps that output next to the folder it describes, so the check does not require moving between windows and losing the scroll position each time.
Subfolders, and the column that has to stay relative
Dropping -maxdepth 1 from the capture command pulls in everything below the starting directory, and that changes what the second column has to contain. Each row now holds a path, not a bare name, and the rename has to preserve the directory part while replacing only the last component.
Two habits keep this from going wrong. Run the loop from the same directory the listing was captured in, so the relative paths in column one still resolve. And build column two by editing only the text after the final slash, which a spreadsheet can do with a formula that splits on the separator, or a script can do with os.path.dirname and os.path.basename.
while IFS=$'\t' read -r old new; do
mkdir -p -- "$(dirname "$new")"
mv -n -- "$old" "$new"
done < map.tsv
The mkdir -p line covers the case where the new column also moves files into a different folder, which is a natural extension once the map exists. A rename and a reorganisation are the same operation at this level, and treating them as one pass avoids a second round of listing and checking.
What this does not cover is a rename of the folders themselves. Renaming a directory partway through a batch invalidates every remaining row underneath it, and the loop then reports missing files for reasons that have nothing to do with encoding. Do folders in a separate pass, after the files, with their own map.
When the map does not match the files
A mapping file that produces "No such file or directory" on rows that visibly exist usually has a Unicode normalisation problem, and on a Mac it appears with any accented or voiced character.
The same visible character can be stored as one code point or as a base character plus a combining mark. APFS treats both forms as the same name, so mv finds the file whichever form is supplied, and Finder shows no difference at all. The mismatch appears only when two pieces of text are compared: grep, diff, a spreadsheet lookup, a Python dictionary key. Names captured from the filesystem and names retyped by hand can differ byte for byte while rendering identically.
Normalise before comparing, in whichever direction, as long as both sides get the same treatment.
iconv -f UTF-8 -t UTF-8-MAC < names.txt > names-decomposed.txt
python3 -c 'import sys,unicodedata; sys.stdout.write(unicodedata.normalize("NFC", sys.stdin.read()))' < names.txt
Git has carried a setting for this since long before APFS existed, and it is switched on in repositories created on a Mac.
This option is only used by Mac OS implementation of Git. When core.precomposeUnicode=true, Git reverts the unicode decomposition of filenames done by Mac OS. This is useful when sharing a repository between Mac OS and Linux or Windows. Source: git-scm.com
Anyone whose rename targets files tracked in a repository shared with Windows machines is dealing with the same question one layer down.
Archives that arrive with broken names
Sometimes the names to be fixed are not wrong, they are misread. A .zip produced on a Windows machine stores non-ASCII filenames in a legacy code page, and the unzip that ships with macOS is a build of Info-ZIP 6.00 with no option for specifying an encoding. Running unzip -hh lists no such flag, ditto -x -k behaves the same way, and double-clicking in Finder produces the same mangled result. Apple's own guidance for a .zip that will not open properly is to ask for it again.
Note: If you can't open the .zip file, make sure you have enough space on your Mac for the unzipped item. If you received the .zip file from someone else, there might be a problem with the file. Ask them to zip the file again and resend it. Source: support.apple.com
When resending is not an option, redo the extraction with the encoding named explicitly rather than renaming the damage afterwards.
import zipfile, os, unicodedata
with zipfile.ZipFile('archive.zip') as z:
for i in z.infolist():
name = i.filename if (i.flag_bits & 0x800) else i.filename.encode('cp437').decode('cp932')
name = unicodedata.normalize('NFC', name)
dst = os.path.join('out', name)
os.makedirs(os.path.dirname(dst), exist_ok=True)
with z.open(i) as src, open(dst, 'wb') as f:
f.write(src.read())
Change the second decode to match the origin of the archive. Fixing the extraction recovers the real names. Renaming after a bad extraction cannot, because characters that failed to round-trip are gone.
Keeping the reverse map
Command line renames are outside the reach of Finder's undo. Whether the operation is reversible is decided before it runs, not after.
awk -F'\t' 'BEGIN{OFS="\t"}{print $2, $1}' map.tsv > map-undo.tsv
That one line turns the undo into a rerun of the third stage. Save it beside the original map with the date in the filename and the question "which convention was this batch under" has an answer months later.
Reversal gets used less often for mistakes than for changed decisions. A client rebrands, a version scheme gets standardised, a naming convention is revised after the first hundred files. Each of those is a second rename, and starting it from the previous map is faster than deriving the names again from scratch.
What to change first
Look at where the three stages currently live. The listing comes from a terminal, the new names come from a spreadsheet or a document, and the verification happens in a folder window. Reviewing four hundred rows across those three surfaces is where the hours go, not in the mv loop.
Count the switches for one real batch before changing tools. If the number is large, the useful comparison is not which application has more rename options but which arrangement removes the round trip, which is the axis used in the comparison with other file managers and described in more detail under Features. Atriens is built around that single window.
Frequently asked questions
Is a mapping file better than zmv?
Neither replaces the other. A pattern is faster when the new name can be derived from the old one, and it stays readable as a single line. A mapping file is for the case where the new names come from outside the filesystem, such as a shoot log or an invoice list. Most people need both, and knowing which situation is in front of them is the part that saves time.
What format should the mapping file use?
Tab separated values, exported straight from a spreadsheet, read back with IFS=$'\t'. Commas are a poor separator because filenames frequently contain them, which shifts the columns without any visible error. Pick a delimiter that does not occur in filenames and the parsing problem disappears.
Can a command line rename be undone?
Not through Finder. The practical approach is to write the reverse mapping before running the rename, then rerun the apply stage with the columns swapped. Without that file, recovery means restoring from Time Machine or another backup, which returns the files at the timestamp of the last snapshot rather than the moment before the rename.
Why does the rename report that a file does not exist when it clearly does?
Almost always Unicode normalisation. The same character can be stored in two byte sequences that render identically, and while the filesystem treats them as one name, text comparison does not. Normalise both the captured listing and the composed column to the same form before matching them, and the phantom failures stop.