Bulk-renaming extensions with mv overwrites without asking. Use this form, which refuses to clobber, survives filenames that start with -, and tells you what it skipped:
for f in *.jpeg; do
[ -e "$f" ] || [ -L "$f" ] || continue # glob matched nothing
t=${f%.jpeg}.jpg
if [ -e "$t" ] || [ -L "$t" ]; then
printf 'skip (target exists): %s\n' "$f" >&2
continue
fi
mv -n -- "$f" "$t"
done
Every block below is unedited output from macOS 26.6 with BSD mv and basename, and the silent overwrite, the filename that starts with a dash, the timings and the bash and zsh glob cases can be re-run with a script that builds its own fixtures in a temporary directory and cleans up on exit. The rename section is cited from distribution package data, not run.
Table of Contents
A plain mv loop silently destroys files
The plain loop for stripping extensions, for f in *.jpg; do mv "$f" "$(basename "$f" .jpg)"; done, uses neither -n nor -i. If a file already exists at the target name, mv replaces it and says nothing. Two files, one of them the only copy of something:
--- before: photo1 contains: IRREPLACEABLE ORIGINAL
--- after running the article's loop:
photo1
--- photo1 now contains: the jpg version
IRREPLACEABLE ORIGINAL is GONE
No prompt, no error, exit status 0. With mv -n the same run leaves both files intact:
files: photo1 photo1.jpg
photo1 = IRREPLACEABLE ORIGINAL
photo1.jpg still present: yes
One catch worth knowing: mv -n skips silently and still exits 0 — mv -n rc=0 with the source untouched. That is why the loop at the top tests for the target itself and prints what it passed over, instead of trusting the exit status.
A filename starting with a dash breaks the loop, at both commands
touch -- -weird.jpeg, then run the plain loop. It fails twice — basename parses the filename as options before mv ever gets a chance to:
basename: illegal option -- w
usage: basename string [suffix]
mv: illegal option -- w
usage: mv [-f | -i | -n] [-hv] source target
rc of loop=64
files: -weird.jpeg
Adding -- to mv alone is not enough, because basename fails first. Dropping basename for the shell’s own ${f%.jpeg} suffix removal fixes half of it; you still need mv -- . With both, dashes, spaces and even newlines in filenames go through:
before: after:
-weird.jpeg -weird.jpg
has space.jpeg has space.jpg
new\nline.jpeg new\nline.jpg
${f%.jpeg} is also faster and more accurate than basename — no subprocess, and it cannot misread the filename as an argument.
File size only matters when mv crosses a filesystem
Within one filesystem a rename is a single rename(2) call on a directory entry; the file’s bytes are never touched, so size cannot matter. A 0-byte file and a 1 GiB file, same directory:
mv tiny.jpeg -> tiny.jpg : 0.002s (size 0 bytes)
mv big.jpeg -> big.jpg : 0.002s (size 1073741824 bytes)
five more renames of the 1 GiB file:
0.002 0.003 0.002 0.002 0.002
Size becomes the entire cost only when the destination is a different filesystem, where mv degrades to copy-then-unlink. Moving the same 512 MiB payload within a volume versus onto a separate mounted volume:
same-fs : 0.002s
cross-fs : 0.610s (512 MiB onto a different volume)
So the honest rule is about filesystem boundaries, not about mv versus rename. Where the loop genuinely loses is process count — one mv per file — not per-file size.
“Install rename” is three different programs
Two incompatible tools share the name rename. rename 's/\.jpeg$/.jpg/' *.jpeg is Perl rename. rename .jpeg .jpg *.jpeg is util-linux rename, documented as “rename [options] substring replacement file...” which “will rename the specified files by replacing the first occurrence of substring in their name by replacement” — no regular expressions at all, and it hits the first occurrence anywhere in the name, not the extension. Debian’s own bug tracker puts it plainly: the util-linux version “does not implement the same command line interface as the alternatives, the expression part is different in the util-linux implementation, so implementations cannot be used interchangeably.”
What you end up with, from current package file lists:
- Debian/Ubuntu —
apt install renameinstalls/usr/bin/file-renameand/usr/bin/prename; there is no/usr/bin/renamein the package itself, which is wired up afterwards through update-alternatives. util-linux’s version lives separately asrename.ul. - Arch —
pacman -S perl-rename(1.16.3-1) installs/usr/bin/perl-renameand/usr/bin/vendor_perl/rename. The command you type isperl-rename; plainrenameon Arch is the util-linux one. - RHEL/Fedora —
/usr/bin/renameis util-linux and has no regex support. The Perl tool is the separateprenamepackage, and it does not replace/usr/bin/rename.
Before using either syntax, ask the binary which one it is: rename --version. Output naming File::Rename means the Perl tool; output naming util-linux means the substring tool. Feeding Perl syntax to the util-linux binary is not an error — it treats s/\.jpeg$/.jpg/ as a literal substring that matches nothing and quietly renames nothing.
nullglob is a bash setting, and other shells differ
shopt -s nullglob is bash-only. It matters because of what happens when the glob matches nothing. Same loop, three configurations:
bash, default : loop body ran with f=[*.jpeg]
bash, shopt -s nullglob: (loop body did not run)
zsh, default : zsh:1: no matches found: *.jpeg rc=1
Default bash hands the loop the literal string *.jpeg and your mv runs against a file that does not exist. zsh is stricter — it aborts before the loop starts. And shopt is not a zsh command at all:
zsh:1: command not found: shopt
zsh’s equivalents are setopt null_glob or the per-glob qualifier *.jpeg(N), both of which produced no run and no error here. The [ -e "$f" ] || [ -L "$f" ] || continue guard in the loop at the top of this page needs neither, and works in both shells.