File Operations Intermediate Updated: 2026-04-26

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
Download file-rename.pybes

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

Options: Prefix, Suffix

suffix_type Dropdown Required

Suffix type

Choose between today's date, sequence number, or random string

Options: yyyymmdd (date), 001 (sequence), random

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")
L1–18

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.

L24–34

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.

L53–77

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).

L82–101

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 (0115), 150 files → 3 digits (001150). 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.

See more common questions →
Download file-rename.pybes

Import the .pybes file into Pybes and the script — along with its config fields — loads automatically.