Download the complete example ZIP
Bulk renaming should start with a visible plan. Before changing a folder, print the old and new names, look for collisions and confirm that the selected files are the intended ones. This Python script is deliberately limited to one folder: it does not recurse into subfolders or follow file symlinks.
The default command is a preview. Adding an explicit apply flag starts the changes. Use a disposable copy of a sample folder and close programs that may modify it. The example prevents replacing an existing destination, but a sequence of filesystem operations is not a transaction and does not provide an automatic undo.
Prepare a small sample
Extract the script outside the folder you want to rename. Create a separate samples directory with a.txt containing alpha and b.txt containing beta. With Python 3.10 or later, run python rename_files.py ./samples. This first command prints a plan without changing either file.
a.txt -> item-001.txt
b.txt -> item-002.txt
Preview: 2 rename(s). No files changed. Add --apply to apply.rename_files.py
"""Preview a one-folder rename plan; apply only with --apply (Python 3.10+)."""
import argparse
from pathlib import Path
def make_plan(folder: Path, prefix: str):
if not folder.is_dir():
raise ValueError("Choose an existing folder.")
if not prefix or any(c not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" for c in prefix):
raise ValueError("Prefix may contain only ASCII letters, digits, _ and -.")
sources = sorted((p for p in folder.iterdir() if p.is_file() and not p.is_symlink()), key=lambda p: p.name)
plan = [(source, folder / f"{prefix}{number:03d}{source.suffix}") for number, source in enumerate(sources, 1)]
for source, target in plan:
if source != target and (target.exists() or target.is_symlink()):
raise ValueError(f"Target already exists: {target.name}")
return [(source, target) for source, target in plan if source != target]
def apply_plan(plan):
for source, target in plan:
if source.is_symlink() or not source.is_file():
raise ValueError(f"Source is no longer a regular file: {source.name}")
# Creating a hard link refuses an existing destination, even after preview.
target.hardlink_to(source)
# If unlink fails, both names remain; do not silently remove either copy.
source.unlink()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("folder", type=Path)
parser.add_argument("--prefix", default="item-")
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
try:
folder = args.folder.resolve(strict=True)
plan = make_plan(folder, args.prefix)
for source, target in plan:
print(f"{source.name} -> {target.name}")
if args.apply:
apply_plan(plan)
print(f"Applied {len(plan)} rename(s).")
else:
print(f"Preview: {len(plan)} rename(s). No files changed. Add --apply to apply.")
except (ValueError, OSError) as error:
parser.exit(1, f"Stopped: {error}\nEarlier operations may have completed; inspect the folder before retrying.\n")
if __name__ == "__main__":
main()
Understand what the plan includes
The script selects immediate regular files and skips symlinks and subdirectories. It sorts names before assigning numbers, so the mapping can be inspected. Sorting is case-sensitive, and the numbering is based on that order rather than a file's modification time. Each target retains the final suffix: a file named archive.tar.gz retains .gz, not a special interpretation of the entire compound extension.
A prefix can contain ASCII letters, numbers, underscores and hyphens. Rejecting slashes prevents the prefix from turning into a path outside the chosen folder. Files that already match their target name are omitted from the change list. If a target belongs to another existing file, the whole plan is rejected before application starts.
Apply only after reviewing the copy
Run python rename_files.py ./samples --apply after checking the preview. With the sample above, the new names contain the original alpha and beta text. Keep the original folder elsewhere until the result is reviewed. A prefix option such as --prefix report- changes the naming pattern, so always preview again after changing it.
The apply step creates a hard link with the target name and then unlinks the original name. Creating the new link refuses an existing destination, including one created after preview. This requires a filesystem that supports hard links. It may fail on some network shares or other storage systems; the script stops rather than silently switching to a replacement operation. See Path.hardlink_to and Path.unlink in Python's documentation.
Know the failure boundaries
Do not run this while another process is changing the same sources. A preflight check cannot freeze a live directory. Operations happen one at a time: if the third operation fails, the first two may already be complete. If the new link is created but removing the original name fails, both names can remain and refer to the same underlying file.
The script intentionally does not attempt a blind rollback. Inspect the folder after an error, compare it with the preview and recover from the untouched original copy when needed. The hard-link approach prevents a destination overwrite; it does not turn a learning script into a concurrent file-management service.
What was checked
Checks ran with Python 3.14 on Windows using temporary folders. Preview left files unchanged. Applying preserved the sample contents. Existing targets and path-like prefixes were rejected. A target deliberately created after planning caused FileExistsError without overwriting either file. Running the same prefix against the already renamed two-file sample produced an empty plan.
Before extending the program with recursive paths or a different filename scheme, write down the new collision rules and recovery procedure. Keep preview as the default and make the selected scope visible. Those choices make an automation example easier to review and substantially easier to debug when a folder contains an unexpected file.
Comments (0)
Join the discussion