Bulk Rename File Extensions in Linux Using Shell Commands

Updated

Linux terminal running file management commands

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.

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 rename installs /usr/bin/file-rename and /usr/bin/prename; there is no /usr/bin/rename in the package itself, which is wired up afterwards through update-alternatives. util-linux’s version lives separately as rename.ul.
  • Arch — pacman -S perl-rename (1.16.3-1) installs /usr/bin/perl-rename and /usr/bin/vendor_perl/rename. The command you type is perl-rename; plain rename on Arch is the util-linux one.
  • RHEL/Fedora — /usr/bin/rename is util-linux and has no regex support. The Perl tool is the separate prename package, 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.

Photo of author
Sudhir P. founded HeatWare.com in 1999 and has built and operated it full-stack ever since; it is now used by more than 88,000 people. He writes here about the PostgreSQL, MySQL, Linux and DevOps work that keeps it running. Articles are rewritten only after the commands in them have actually been run, and the verification scripts are published alongside them so anyone can check the claims. Reach him at blog@heatware.net.