Renaming by pattern instead of one by one

Most people arrive at a batch rename mac terminal search after the Finder dialog has already let them down. The Find and Replace fields are there, the text goes in, and nothing matches, because those fields look for the literal characters typed into them. Swapping the two halves of a name around a hyphen, padding a counter to three digits, stripping a prefix only from files that have one: none of that can be expressed in that window. The terminal can express all of it. The question is which of the three available routes to use, and which safety step to keep, because the terminal has no undo.

What the Finder dialog can and cannot say

Selecting several items in Finder, Control-clicking one of them, and choosing Rename opens a panel with exactly three modes. Replace Text swaps one literal string for another. Add Text puts a fixed string before or after every name. Format appends an index, a counter, or a date.

In the pop-up menu below Rename Finder Items, choose to replace text in the names, add text to the names, or change the name format. Source: support.apple.com

An asterisk typed into the Find field looks for an asterisk. A parenthesis looks for a parenthesis. There is no wildcard, no capture, no branching. The dialog covers one substitution applied uniformly plus mechanical numbering, and it covers those two things well. Everything past that boundary needs a different tool, and the boundary is a design decision rather than a missing feature.

The practical test is simple. If every selected file needs the same edit, Finder is faster and it can be undone with Command+Z. If the edit depends on something inside each individual name, the dialog cannot see that something, and no amount of retyping will make it work.

It is worth knowing where that line falls before reaching for a terminal at all. Stripping a shared prefix from two hundred exports, appending a client code to a folder of invoices, numbering a set of photos: all three stay inside the dialog. Turning invoice-acme-2026.pdf into 2026-acme-invoice.pdf, padding an existing counter, or removing a prefix only from the files that carry it: all three fall outside it. The second group is what the rest of this article covers.

Three routes, and the one macOS does not ship

Route Install needed Dry run flag Best at
zmv from zsh none, bundled with the shell -n capturing parts of a name and rearranging them
for loop with sed none, BSD sed is standard replace mv with echo building a name conditionally
rename via Homebrew yes -n reusing Perl substitution expressions

The third row is where most tutorials go wrong. Articles written for Linux open with rename 's/old/new/' *.txt, and that command does not exist on macOS. Typing which rename returns nothing on a stock system. Homebrew can install a Perl version, but installing anything for this job is optional, because the first row is already on the machine.

zmv ships inside zsh, which has been the default shell on macOS since Catalina in 2019. It is not loaded by default, so one line turns it on: autoload -U zmv. Put that line in a shell startup file and it is available in every new window. Nothing is downloaded, nothing is added to the system, and the same command works on a machine that has never had Homebrew on it.

How zmv turns parentheses into variables

A zmv command has two halves. The left half is a pattern that selects files, and each pair of parentheses in it captures a piece of the name. The right half is the new name, where those captures come back as $1, $2, and so on.

autoload -U zmv
zmv -n '(*).txt' '$1.md'

That changes the extension and leaves the rest alone. Two sets of parentheses allow rearranging.

zmv -n '(*)-(*).png' '$2-$1.png'
zmv -n '([0-9][0-9][0-9][0-9])(*).pdf' '$1.pdf'
zmv -n '(*)' '${1:l}'

The first swaps the halves around a hyphen. The second keeps only a leading four digit year and discards everything after it. The third uses a zsh modifier, :l, to force the captured text to lowercase, and :u does the same in the other direction. A date written as 2026-09-03 can be compacted by capturing three groups and writing $1$2$3.

The -n flag is what makes this safe. With it, nothing moves. The shell prints the operations it would have performed, one line per file, in the form mv -- a.txt a.md. Read that list, confirm both the set of files and the shape of the results, then run the same command without -n. Two passes over the same command is the single habit that separates a clean rename from a bad afternoon.

What sits inside those parentheses is a shell pattern rather than a full regular expression. An asterisk matches any run of characters, a question mark matches one, and square brackets match a range. That vocabulary handles the large majority of real renaming jobs. When a job genuinely needs alternation or backreferences, the answer is not to force zmv into a longer line, it is to move to the next route.

Building names with sed, and what BSD does differently

For names that have to be assembled rather than rearranged, a loop with sed gives full control. The trap here has nothing to do with regular expressions and everything to do with which sed is installed. macOS ships the BSD version, while most examples online assume the GNU one.

The differences that actually bite:

  • -E enables extended regular expressions, and that flag works on both versions
  • \d does not exist. Digits are written [0-9]
  • GNU shorthands such as \+ and \? are not accepted
  • there is no lazy quantifier. Bound the match with an explicit delimiter instead
  • sed -i requires an argument, so editing in place is written sed -i ''

Reducing a screenshot name to a bare date looks like this:

for f in "Screen Shot"*.png; do
  n=$(printf '%s' "$f" | sed -E 's/^Screen Shot ([0-9]{4})-([0-9]{2})-([0-9]{2}).*/\1\2\3/')
  echo mv -n -- "$f" "${n}.png"
done

The leading echo is the dry run. It prints the commands instead of running them, which is the same protection -n gives zmv. Redirect that output to a file, count the lines, and compare the count against the number of files in the folder before removing echo. A pattern that is one character too greedy will happily produce four hundred identical names, and the count is where that shows up first.

Numbering, padding, and reaching into subfolders

Two jobs come up often enough to be worth having ready. The first is sequential numbering with leading zeros, which matters because a plain counter sorts 10 before 2 in every file listing on the machine. Zsh can pad inside the replacement itself.

autoload -U zmv
i=0
zmv -n '(*).jpg' 'shoot-${(l:3::0:)$((++i))}.jpg'

That produces shoot-001.jpg, shoot-002.jpg, and so on. The syntax is dense, and a loop expresses the same thing in a form that is easier to come back to six months later.

i=0
for f in *.jpg; do
  i=$((i+1))
  n=$(printf '%03d' "$i")
  echo mv -n -- "$f" "shoot-${n}.jpg"
done

The order in both cases follows how the shell sorts the glob, not how a Finder window happens to be arranged at the time. Sorting by capture date needs the date read out of each file first, which is a different job from renaming and is worth doing as a separate pass.

The second job is reaching into subfolders. Finder cannot rename across a folder tree in one action, but a recursive glob can. The pattern (**/)(*) captures the directory part and the filename part separately, so the file stays where it is while its name changes.

zmv -n '(**/)(*).txt' '$1$2.md'

Run that from the top of the tree and every matching file at every depth is listed. The dry run matters more here than anywhere else, because the number of affected files is no longer something a folder window can show at a glance.

When two files want the same name

The most expensive failure in a pattern based rename is not a syntax error. It is a collision. When a pattern is written too broadly, several files resolve to the same new name, and mv overwrites without a prompt, a warning, or a trip to the Trash. Three files collapsing into one leaves one file.

There are three ways to handle it, and only one of them is really reliable:

  • mv -n refuses to overwrite an existing destination. It also says nothing, so the files that did not move stay behind unnoticed until later
  • mv -i asks before each overwrite, which stops being usable somewhere around twenty files
  • piping the proposed new names through sort | uniq -d during the dry run lists every duplicate before anything moves

macOS adds a second trap here. The default APFS volume is formatted case insensitive. In a folder that already holds Ab.txt, renaming another file to ab.txt counts as a collision and destroys the existing file, and the surviving name keeps the original capitalisation rather than the one just requested. Any job that lowercases a whole folder needs the duplicate check above, run against the lowercased names.

Names the pattern quietly skips

A pattern can be correct and still miss files. Three causes account for almost all of it.

Accented and Japanese characters can be stored in two different ways. A character such as may live as one code point or as a base character plus a combining mark, and both look identical on screen. Files inherited from older HFS Plus volumes or restored from old backups often use the decomposed form. The same visible name can be nine characters in one file and eleven in another, and a pattern typed on the keyboard matches only one of the two. When a folder holds two matching files and grep reports one, this is why.

Quoting is the second cause. mv $f $n splits at every space, so a name like Q3 report.pdf arrives as two arguments. Always write mv -- "$f" "$n". The -- matters separately: without it, a file whose name starts with a hyphen is read as a set of options.

The third is looping over ls. for f in $(ls) breaks on spaces and newlines just as badly. Glob directly with for f in *.png, and when the file list comes from find, pass it as find . -name '*.png' -print0 | xargs -0 -n1 so the separator is a null byte. There are also characters no rename can produce: a colon is not allowed in a filename, and a name cannot begin with a period. A substitution that generates either will fail on exactly the rows that hit it.

What to change first

Run every rename twice: once with -n or echo, once for real, with a sort | uniq -d check on the proposed names in between. That single habit prevents the only failure in this article that destroys data.

If the dry run itself is the slow part, because it means switching between a folder window and a terminal window for every attempt, the problem has moved from syntax to layout. A file manager with a built-in terminal keeps the listing and the command line on the same working directory, which is what Atriens is built around, and the differences against two pane and terminal-first tools are laid out side by side on the comparison page.

Frequently asked questions

Can regular expressions be used in the Finder rename dialog?

No. The Replace Text field searches for the literal characters entered into it, so an asterisk matches an asterisk and nothing more. Pattern based renaming requires either zmv, which is bundled with zsh, or a loop built around sed. Neither one needs anything installed.

Why does the rename command not exist on macOS?

macOS does not ship the rename utility that most Linux distributions include, so the widely copied rename 's/old/new/' *.txt line fails immediately. Homebrew can install a Perl based version, but zmv covers the same ground and is already present on every Mac running Catalina or later.

Is there a way to undo a rename done in the terminal?

No. Files moved with mv do not pass through the Trash, and an overwritten file is gone. Recovery means going to Time Machine or to the version history of a sync service. A Finder rename can be reversed with Command+Z, which is a genuine argument for using the dialog whenever it is capable of the job.

Why do some files get skipped even though the pattern looks right?

The two usual causes are unquoted variables and character normalization. An unquoted $f splits a name at every space, so files with spaces fail while others succeed. Names containing accented or Japanese characters may be stored in a decomposed form that does not match a pattern typed in the composed form, which produces a lower match count than expected.

How can a dry run be checked for collisions before running the rename?

Capture the dry run output to a file, extract the proposed destination names, and pipe them through sort | uniq -d. Any line printed there is a name that two or more files are competing for, and mv resolves that competition by overwriting silently.

Back to all posts