Bulk Rename Files in a Folder Script
A Python script that bulk renames files in a folder. Append a sequence number, YYYYMMDD date, or random string as either a prefix or suffix, and verify every rename in preview mode before committing. Recursive subfolder traversal and automatic skip on name collisions make the script safe to rerun on shared drives.
Collapse chores like "renumber scanned PDFs with today's date" or "apply a naming convention to 100 deliverables" into a single run. Append a sequence number, YYYYMMDD date, or random string as prefix or suffix, and sanity-check every rename in preview mode before committing.
What this script can do
- Three suffix modes: sequence number, YYYYMMDD date, or random 6-char string
- Append as either prefix or suffix
- Preview mode lists the full rename plan without touching any file
- Recursive subfolder traversal
- One-pass removal of half-width and full-width whitespace
Import the .pybes file into Pybes and the script — along with its config fields — loads automatically.
Config fields
These are the config fields this script uses. Enter values through the Pybes GUI at runtime.
target_dir Folder Required Target folder
Folder containing the files to rename
base_name Text Required Base name
New base name when "Keep original filename" is off (e.g., invoice)
keep_original Checkbox Required Keep original filename
On: keep the original stem and only add a suffix. Off: fully replace with base name + sequence.
Default: false
position Dropdown Required Append position
Whether to place the suffix at the start (prefix) or end (suffix) of the filename
suffix_type Dropdown Required Suffix type
Choose between today's date, sequence number, or random string
trim_space Checkbox Required Remove whitespace
Strip both half-width and full-width spaces from the base name
Default: true
preview_mode Checkbox Required Preview mode (log only)
When enabled, the script lists planned renames without actually renaming anything
Default: true
recursive Checkbox Required Include subfolders
Recursively include files in subfolders of the target folder
Default: false
Code walkthrough
import sys
import json
import os
import random
import string
from datetime import datetime
with open(sys.argv[1], encoding="utf-8") as f:
inputs = json.load(f)
target_dir = inputs["target_dir"]
base_name = inputs["base_name"]
keep_original = inputs["keep_original"] == "true"
position = inputs["position"]
suffix_type = inputs["suffix_type"]
trim_space = inputs["trim_space"] == "true"
preview_mode = inputs["preview_mode"] == "true"
recursive = inputs["recursive"] == "true"
# Assumption: the separator between base name and suffix is "_"
# Assumption: zero-padding width is auto-adjusted based on the file count
# Assumption: when keep_original is false, an index is appended to base_name to guarantee uniqueness
if recursive:
file_paths = []
for dirpath, _, filenames in os.walk(target_dir):
for f in filenames:
file_paths.append(os.path.join(dirpath, f))
else:
file_paths = [
os.path.join(target_dir, f)
for f in os.listdir(target_dir)
if os.path.isfile(os.path.join(target_dir, f))
]
total = len(file_paths)
if total == 0:
print("No target files found.")
sys.exit(0)
print(f"Target files: {total}")
if preview_mode:
print("\n[Preview mode] No files will be renamed\n")
digits = len(str(total))
today_str = datetime.now().strftime("%Y%m%d")
success = 0
skip = 0
for i, filepath in enumerate(sorted(file_paths)):
try:
dirpath = os.path.dirname(filepath)
filename = os.path.basename(filepath)
stem, ext = os.path.splitext(filename)
if keep_original:
base = stem
else:
base = f"{base_name}_{str(i + 1).zfill(digits)}"
if trim_space:
base = base.replace(" ", "").replace(" ", "")
if suffix_type == "yyyymmdd (date)":
suffix_text = today_str
elif suffix_type == "001 (sequence)":
suffix_text = str(i + 1).zfill(digits)
else:
suffix_text = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
if position == "Prefix":
new_stem = f"{suffix_text}_{base}"
else:
new_stem = f"{base}_{suffix_text}"
new_filename = new_stem + ext
dst = os.path.join(dirpath, new_filename)
if preview_mode:
if os.path.exists(dst) and filepath != dst:
print(f"[Conflict] {filepath} → {new_filename}")
else:
print(f"[Planned] {filepath} → {new_filename}")
success += 1
continue
if os.path.exists(dst) and filepath != dst:
print(f"[Skipped] A file with the same name already exists: {filepath}", file=sys.stderr)
skip += 1
continue
os.rename(filepath, dst)
print(f"[{i+1}/{total}] {filepath} → {new_filename}")
success += 1
except Exception as e:
print(f"[Error] {filepath}: {e}", file=sys.stderr)
skip += 1
if preview_mode:
print(f"\nPreview complete: {success} planned rename(s) listed")
print("Uncheck \"Preview mode\" and run again to actually rename the files")
else:
print(f"\nDone: {success} renamed / {skip} skipped") After importing standard-library modules (sys / json / os / random / string / datetime), the script reads the JSON config Pybes passes in sys.argv[1] into an inputs dictionary. Pybes delivers checkbox values as the strings "true" / "false", so each boolean flag is converted by comparing to "true" explicitly. All inputs keys are ASCII snake_case and must match the field names defined in the Pybes app.
The collection strategy forks on the recursive flag. When true, os.walk walks the entire subtree and appends every file's absolute path; when false, a list comprehension over os.listdir + os.path.isfile keeps only direct children. os.walk is the standard Python idiom for recursion — unreadable folders are silently skipped internally, so one bad permission does not break the whole walk.
The main loop peels off the extension with os.path.splitext, then rebuilds the base name. If keep_original is true the original stem is reused; otherwise a fresh base_name_001-style name is generated. digits = len(str(total)) scales the zero-padding automatically — 15 files → 2 digits, 150 files → 3 digits. The suffix is either the date, the sequence, or a random 6-char string built from random.choices(string.ascii_lowercase + string.digits, k=6).
In preview mode the script never calls os.rename — it only logs [Planned] / [Conflict] and moves on. In real runs, a destination collision triggers [Skipped] on sys.stderr and the loop continues rather than aborting. The per-iteration try / except Exception block makes sure one locked file can't derail the remaining ninety.
How it works
Collecting the target file list
When "Include subfolders" is on, os.walk traverses the tree recursively; otherwise os.listdir + os.path.isfile keeps only direct children. Either path produces the same file_paths list of absolute paths, so the rest of the loop works regardless of the collection strategy. sorted() guarantees a stable, filename-ordered sequence so the numbering is deterministic.
Building the new filename
New names follow a simple rule: base + "_" + suffix. The base is either the original stem (when keep_original is on) or base_name + sequence (when off). The suffix is today's date, a zero-padded index, or a random 6-char string, placed at the start or end based on position. The original extension (.pdf, .xlsx, etc.) is always reattached at the end, so file types are never altered.
Preview mode and collision handling
In preview mode os.rename is never called — the script just logs what it would do. In real runs, if the target filename already exists, the entry is marked [Skipped] on sys.stderr instead of raising. The per-file try / except keeps a single locked file from breaking the rest of the batch.
Customization
Filter by specific extensions
Add a one-line filter right after for i, filepath in enumerate(sorted(file_paths)):, such as if not filepath.lower().endswith((".pdf", ".xlsx")): continue. Lowercasing the path before comparison makes sure .PDF-style uppercase extensions are not silently skipped.
Change the separator character
Swap the _ inside f"{base_name}_{str(i + 1).zfill(digits)}" and f"{suffix_text}_{base}" / f"{base}_{suffix_text}" with - or . to change the separator. Avoid reserved characters (/, \\, :, *, ?, ", <, >, |) that Windows rejects in filenames.
Fix the zero-padding width
Replace digits = len(str(total)) with a constant such as digits = 4 to force four-digit padding (0001, 0002, ...) regardless of file count. Handy when the output must match an existing naming convention.
Troubleshooting
PermissionError aborts the script
The file is likely open in another app (Excel, PDF viewer, editor), which holds a Windows lock and causes os.rename to fail. Close the file and rerun. An Explorer preview pane showing the file also counts — close the preview pane or select a different file first.
Skipped with "a file with the same name already exists"
The generated new name collided with an existing file in the folder. With keep_original off, uniqueness is guaranteed by the appended sequence — but with keep_original on, re-running with a date suffix regenerates the same name and collides. Switch to the random-string suffix, or use preview mode to spot conflicts before committing.
"No target files found." exits immediately
The folder is empty, or "Include subfolders" is off while the files actually live inside subfolders. Turn on recursive and rerun, and double-check that the folder path is correct.
FAQ
Can I undo the rename?
Not with this script alone — os.rename does not record the original names. If you need a rollback path, copy the preview-mode output (old → new name mapping) beforehand. For critical folders, zip a backup before running.
How is the zero-padding width determined?
It's auto-calculated from the file count via digits = len(str(total)). 15 files → 2 digits (01–15), 150 files → 3 digits (001–150). See the customization section if you prefer a fixed width.
How are compound extensions like .tar.gz handled?
os.path.splitext only peels off the last extension, so report.tar.gz is treated as base report.tar + extension .gz, producing names like report.tar_001.gz. If you want .tar.gz preserved as a unit, add a check after stem, ext = os.path.splitext(filename) that strips another .tar off the stem.
If a file was skipped, will rerunning rename it?
Yes. Most skips come from either collisions or locked files — close the locking app and rerun, or switch to the random-string suffix to avoid collisions. Only the files that were not renamed remain, so the second pass finishes much faster than the first.
Import the .pybes file into Pybes and the script — along with its config fields — loads automatically.