フォルダ内のファイル名がバラバラで管理がつらい…そんなときに頼れるのが Pythonによる一括リネーム。この記事では中級エンジニア向けに、実務で役立つリネームパターン、安全対策(dry-run / バックアップ / 衝突回避)、コマンドラインツール化の方法まで、実用的な手順とサンプルコードを丁寧に解説します。
想定読者
- Python(pathlib, re, argparse)が使える中級者
- 業務で大量のログ・請求書・画像などファイル整理を自動化したい人
- WordPressサイトなどでファイル名規則を統一したい人
1. 準備と基本方針
- 標準ライブラリの
pathlib
,re
,argparse
,shutil
,logging
を使う(外部依存は最小限に)。 - まずは dry-run(実行確認)を必須にする。実際に書き換えるのは
--apply
フラグが渡されたときだけ。 - ファイルのタイムスタンプは原則維持する(もし変更するならオプションで)。
- 名前衝突(同名ファイルが既に存在する)に対する回避ルールを実装する(番号付け、スキップ、上書き選択)。
2. よくあるリネームパターン
- 一括プレフィックス/サフィックス追加
例)report_
をすべてのファイルへ追加 - 文字列置換(単純な検索 → 置換)
例)draft
→final
- 正規表現でのマッチ→置換(グループを利用)
例)^(\d{4})(\d{2})(\d{2})_
→\1-\2-\3_
- シーケンス番号付与(ゼロパディング)
例)img_0001.jpg
形式 - 日付情報をファイル名に付与(ファイルの最終更新日時を利用)
例)2025-08-31_invoice.pdf
- 拡張子の統一(大文字→小文字)
例).JPG
→.jpg
3. 安全に実行するための設計
- dry-run(デフォルト)で「変更前 → 変更後」のプレビューを出す。
- ログを残す(成功/失敗/スキップ理由)。
- バックアップオプション:失敗時に元に戻せるよう
--backup-folder
を作る。 - 衝突解決:
--on-conflict
オプション(skip
/overwrite
/number
)を用意。 - テスト対象のファイル拡張子を絞る(例:
.pdf, .jpg
)ことで誤適用を防ぐ。 - Gitなどで管理されているファイルは慎重に扱う(.gitignore 等を確認)。
4. 実践:汎用コマンドラインスクリプト(完全版)
以下は、上記の安全対策・機能を備えた汎用スクリプトのサンプルです。rename_tool.py
として保存して使えます。
#!/usr/bin/env python3
"""
rename_tool.py
汎用ファイル一括リネームツール(dry-runがデフォルト)
使い方例:
python rename_tool.py ./target_dir --pattern "draft" --replace "final" --ext ".pdf,.docx" --apply
python rename_tool.py ./images --regex "^IMG_(\d+)\.JPG$" --format "img_{num:04d}.jpg" --apply
"""
import argparse
from pathlib import Path
import re
import shutil
import logging
from datetime import datetime
logging.basicConfig(level=logging.INFO, format='%(message)s')
logger = logging.getLogger("rename_tool")
def parse_args():
p = argparse.ArgumentParser(description="汎用ファイル一括リネームツール")
p.add_argument("target", type=str, help="対象フォルダ(非再帰にするなら --recursive をオフに)")
p.add_argument("--recursive", action="store_true", help="サブフォルダも再帰的に処理する")
p.add_argument("--ext", type=str, default="", help="処理対象の拡張子をカンマ区切りで指定(例: .pdf,.jpg)")
p.add_argument("--pattern", type=str, help="単純置換の検索パターン(正規表現ではない)")
p.add_argument("--replace", type=str, help="単純置換の置換文字列")
p.add_argument("--regex", type=str, help="正規表現パターン(matchで使う)")
p.add_argument("--format", type=str, help="正規表現のグループや番号を使ったフォーマット。例: 'img_{num:04d}.jpg' or '{g1}_{g2}.txt'")
p.add_argument("--prefix", type=str, help="全ファイルに付けるプレフィックス")
p.add_argument("--suffix", type=str, help="全ファイルに付けるサフィックス(拡張子の前)")
p.add_argument("--date-from", choices=['mtime','ctime','atime'], default=None, help="ファイル日付を名前に使う(mtimeなど)")
p.add_argument("--apply", action="store_true", help="実際にファイル名を変更するフラグ(指定がないとdry-run)")
p.add_argument("--backup-folder", type=str, default="", help="適用時にバックアップを保存するフォルダ")
p.add_argument("--on-conflict", choices=['skip','overwrite','number'], default='number', help="名前衝突時の動作")
p.add_argument("--lower-ext", action="store_true", help="拡張子を小文字にする")
return p.parse_args()
def get_candidates(target: Path, recursive: bool, exts):
if recursive:
files = target.rglob('*')
else:
files = target.iterdir()
for f in files:
if not f.is_file():
continue
if exts:
if f.suffix.lower() not in exts:
continue
yield f
def build_new_name(file: Path, args, regex_compiled=None, seq_num=None):
name = file.stem
ext = file.suffix
# date
if args.date_from:
ts = getattr(file.stat(), {
'mtime': 'st_mtime',
'ctime': 'st_ctime',
'atime': 'st_atime'
}[args.date_from])
date_str = datetime.fromtimestamp(ts).strftime('%Y-%m-%d')
else:
date_str = None
# regex
if regex_compiled:
m = regex_compiled.match(file.name)
if m:
groups = m.groups()
# If format contains {num} use seq_num
fmt = args.format or ""
if '{num' in (fmt or "") and seq_num is not None:
new_stem = fmt.format(num=seq_num, *groups, **{f"g{i+1}": g for i,g in enumerate(groups)})
else:
# allow {g1} {g2} ... or positional
try:
new_stem = fmt.format(*groups, **{f"g{i+1}": g for i,g in enumerate(groups)})
except Exception:
new_stem = name
else:
new_stem = name
elif args.pattern:
if args.replace is None:
new_stem = name
else:
new_stem = name.replace(args.pattern, args.replace)
else:
new_stem = name
if args.prefix:
new_stem = f"{args.prefix}{new_stem}"
if args.suffix:
new_stem = f"{new_stem}{args.suffix}"
if date_str:
new_stem = f"{date_str}_{new_stem}"
new_ext = ext
if args.lower_ext:
new_ext = ext.lower()
return f"{new_stem}{new_ext}"
def resolve_conflict(target_path: Path, on_conflict: str):
if not target_path.exists():
return target_path
if on_conflict == 'overwrite':
return target_path
if on_conflict == 'skip':
return None
# number
parent = target_path.parent
stem = target_path.stem
ext = target_path.suffix
i = 1
while True:
candidate = parent / f"{stem}_{i}{ext}"
if not candidate.exists():
return candidate
i += 1
def main():
args = parse_args()
target = Path(args.target)
if not target.exists() or not target.is_dir():
logger.error("ターゲットが存在しないかディレクトリではありません: %s", args.target)
return
exts = {e.strip().lower() for e in args.ext.split(',') if e.strip()} if args.ext else set()
regex_compiled = re.compile(args.regex) if args.regex else None
files = list(get_candidates(target, args.recursive, exts))
if not files:
logger.info("処理対象ファイルが見つかりません。")
return
# Prepare backup
backup_folder = Path(args.backup_folder) if args.backup_folder else None
if args.apply and backup_folder:
backup_folder.mkdir(parents=True, exist_ok=True)
planned = []
seq = 1
for f in files:
new_name = build_new_name(f, args, regex_compiled, seq_num=seq)
if new_name == f.name:
logger.debug("変更なし: %s", f)
seq += 1
continue
planned.append((f, f.parent / new_name))
seq += 1
# Show preview
logger.info("== プレビュー(%s件) ==", len(planned))
for src, dst in planned:
logger.info("%s -> %s", src.name, dst.name)
if not args.apply:
logger.info("dry-runモードです。実際に変更するには --apply を付けてください。")
return
# Apply
for src, dst in planned:
resolved = resolve_conflict(dst, args.on_conflict)
if resolved is None:
logger.info("スキップ(衝突): %s", dst.name)
continue
try:
if backup_folder:
shutil.copy2(src, backup_folder / src.name)
src.rename(resolved)
logger.info("リネーム: %s -> %s", src.name, resolved.name)
except Exception as e:
logger.error("失敗: %s (%s)", src, e)
if __name__ == "__main__":
main()
5. 使い方例(実行例)
- プレフィックス追加(dry-run)
python rename_tool.py ./invoices --prefix "2025_" --ext ".pdf"
- 実際に適用(バックアップを取る)
python rename_tool.py ./invoices --prefix "2025_" --ext ".pdf" --backup-folder ./backup_invoices --apply
- 正規表現で連番に(例:
IMG_1234.JPG
→img_0001.jpg
形式)
python rename_tool.py ./photos --regex "^IMG_(\d+)\.JPG$" --format "img_{num:04d}.jpg" --ext ".JPG" --lower-ext --apply
- ファイルの最終更新日を先頭に付ける
python rename_tool.py ./reports --date-from mtime --ext ".xlsx,.pdf" --apply
6. よくあるトラブルと対処法
- 意図しないファイルも変わってしまった
→--ext
で拡張子を限定、--recursive
を外して検証。まずは dry-run でプレビュー必須。 - 名前衝突で上書きされたくない
→--on-conflict number
(デフォルト)で_1
のように回避。 - ファイルのタイムスタンプが変わる
→shutil.copy2
でコピー → 元ファイル削除のフローはタイムスタンプを維持しない場合がある。rename()
はタイムスタンプを保持する。 - Windows のパス長問題
→ 長いパス名が問題なら短いフォルダに移して実行するか、UNC パス等の対応を検討。 - 画像の撮影日時などEXIFをファイル名に使いたい
→Pillow
やexifread
を使ってEXIFのDateTimeOriginal
を取得しbuild_new_name
に組み込む。
7. まとめ&チェックリスト
- dry-runでまずプレビューを行ったか
- 拡張子や処理対象を限定しているか(誤適用防止)
- バックアップを取るオプションを用意しているか
- 名前衝突の方針(skip/overwrite/number)を決めているか
- 実行ログを残し、後で復元できるようにしているか