2026年6月27日

2026年6月27日

Pythonでファイル操作を自動化する方法|整理・バックアップ・古いファイルの削除

Python でファイル操作を自動化するなら、標準ライブラリの pathlib と shutil だけで、振り分け・コピー・圧縮・削除のほとんどを書けます。この記事では、ダウンロードフォルダの拡張子別の整理、日付つきバックアップ、古いファイルの削除という3つの定番作業を、誤操作を防ぐ dry-run(試し実行)つきのスクリプトとして組み立てます。手作業を置き換えるときにつまずきやすい点(同名ファイルの上書き、文字コード、途中で止まったときの状態)もあわせて説明します。

使うモジュールと前提

ファイル操作でよく使うのは次の3つです。

  • pathlib:パスをオブジェクトとして扱う。結合(/ 演算子)、拡張子の取得、一覧、存在確認などをまとめて行える
  • shutil:コピー、移動、ディレクトリごとの削除、アーカイブ作成など、まとまった操作を担当する
  • os:os.replace のような低レベルの操作が必要なときに使う

文字列でパスをつなぐ os.path.join の書き方も動きますが、新しく書くなら pathlib のほうが読みやすく、Windows と Linux の区切り文字の違いも気にせず済みます。この記事のコードは Python 3.9 以降で動く書き方にしています。Python 本体のサポート期間は毎年更新されるため、これから環境を作るなら公式サイトでサポート中のバージョンを確認してください。新しく入れるなら 3.12 以降を選ぶのが無難です(2026年10月現在)。

基本操作:一覧・コピー・移動

まずは部品になる操作を確認します。

from pathlib import Path
import shutil

src = Path.home() / "Downloads"

# 直下のファイルだけを一覧(ディレクトリは除く)
for path in src.iterdir():
    if path.is_file():
        print(path.name, path.suffix, path.stat().st_size)

# パターンで探す(rglob はサブディレクトリも含めて探す)
pdfs = list(src.rglob("*.pdf"))

# コピー(copy2 は更新日時などのメタデータも保つ)
dest_dir = Path.home() / "backup"
dest_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src / "report.pdf", dest_dir / "report.pdf")

# ディレクトリごとコピー(既存のコピー先に上書きで足す)
shutil.copytree(src / "project", dest_dir / "project", dirs_exist_ok=True)

つまずきやすいのは次の点です。

  • mkdir(parents=True, exist_ok=True) を付けないと、親ディレクトリが無いときや既にあるときに例外になります。
  • shutil.copy2 や shutil.move は、コピー先に同名ファイルがあると黙って上書きする場合があります。自動化では「上書きしてよいか」を自分で判定する処理が要ります。
  • rglob は件数が多いディレクトリで時間がかかります。対象が直下だけなら glob か iterdir を使います。

作業1:ダウンロードフォルダを拡張子ごとに振り分ける

拡張子に応じて images・documents・archives などのフォルダへ移動するスクリプトです。最初は移動せずに予定だけを表示し、--run を付けたときだけ実際に動かします。

#!/usr/bin/env python3
"""フォルダ直下のファイルを拡張子ごとのサブフォルダへ振り分ける"""
import argparse
import logging
import shutil
from pathlib import Path

RULES = {
    ".jpg": "images", ".jpeg": "images", ".png": "images", ".webp": "images",
    ".pdf": "documents", ".docx": "documents", ".xlsx": "documents", ".csv": "documents",
    ".zip": "archives", ".gz": "archives",
}


def unique_path(dest: Path) -> Path:
    """同名ファイルがあれば name_1.ext, name_2.ext ... の空いている名前を返す"""
    if not dest.exists():
        return dest
    for i in range(1, 10000):
        candidate = dest.with_name(f"{dest.stem}_{i}{dest.suffix}")
        if not candidate.exists():
            return candidate
    raise RuntimeError(f"空いている名前が見つかりません: {dest}")


def organize(src: Path, dry_run: bool) -> None:
    for path in sorted(src.iterdir()):
        if not path.is_file():
            continue
        folder = RULES.get(path.suffix.lower())
        if folder is None:
            continue
        dest = unique_path(src / folder / path.name)
        if dry_run:
            logging.info("[dry-run] %s -> %s", path.name, dest.relative_to(src))
            continue
        dest.parent.mkdir(parents=True, exist_ok=True)
        shutil.move(path, dest)
        logging.info("moved %s -> %s", path.name, dest.relative_to(src))


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("src", type=Path, help="整理するフォルダ")
    parser.add_argument("--run", action="store_true", help="指定しないときは dry-run")
    args = parser.parse_args()
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
    organize(args.src.expanduser(), dry_run=not args.run)


if __name__ == "__main__":
    main()
python organize.py ~/Downloads        # 予定を表示するだけ
python organize.py ~/Downloads --run  # 実際に移動する

設計のポイントは3つです。

  1. 既定を dry-run にする:引数を付け忘れたときに何も起きない側へ倒しておくと、ルールの書き間違いで大量のファイルを動かす事故を防げます。
  2. 同名ファイルは連番で逃がす:unique_path で空いている名前を探してから移動するので、既存ファイルを上書きしません。
  3. 拡張子は小文字にそろえる:IMG_0001.JPG のように大文字の拡張子は珍しくないため、suffix.lower() で比較します。

dry-run の表示は「その時点の状態」で計算しているため、同じ名前のファイルが同時に2つ移動予定になるような場合は、本番の結果と連番がずれることがあります。結果はログで確認してください。

作業2:日付つきのバックアップを作る

ディレクトリを丸ごと圧縮して、日付入りのファイル名で保存します。shutil.make_archive は、作成したアーカイブのパスを返します。

from datetime import datetime
from pathlib import Path
import shutil


def backup(target: Path, backup_dir: Path) -> Path:
    backup_dir.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    base_name = backup_dir / f"{target.name}-{stamp}"
    archive = shutil.make_archive(str(base_name), "gztar", root_dir=target.parent, base_dir=target.name)
    return Path(archive)


if __name__ == "__main__":
    path = backup(Path.home() / "documents", Path.home() / "backup")
    print("作成:", path, path.stat().st_size, "bytes")

root_dir と base_dir を分けて指定すると、展開したときに documents/ から始まる構成になります。root_dir だけにすると中身が直下に散らばるので、展開先で混ざらないよう分けておくのがおすすめです。

バックアップは「作れた」だけでは不十分で、戻せることを確認して初めて役に立ちます。最初の数回は別の場所に展開して中身を見てください。また、同じディスクにだけ保存していると、ディスク故障でまとめて失います。重要なデータは別のディスクやクラウドストレージにも置く構成にします。

作業3:古いファイルを削除する

ログや一時ファイルを「30日より古いものは削除」のように整理するスクリプトです。削除は元に戻せないため、ここでも dry-run を既定にします。

import time
from pathlib import Path


def delete_old(target: Path, pattern: str, days: int, dry_run: bool = True) -> int:
    limit = time.time() - days * 86400
    count = 0
    for path in target.glob(pattern):
        if path.is_file() and path.stat().st_mtime < limit:
            count += 1
            if dry_run:
                print("[dry-run] delete", path)
            else:
                path.unlink()
    return count


if __name__ == "__main__":
    n = delete_old(Path("/var/tmp/myapp"), "*.log", days=30, dry_run=True)
    print(n, "件が対象です")

注意したいのは次の点です。

  • 対象ディレクトリを引数の誤りから守る:パスが空文字や / になっていないかを、処理の最初に確認すると安全です。shutil.rmtree でディレクトリごと消す処理を書く場合は、なおさら確認を厚くします。
  • 更新日時(st_mtime)で判定する:コピーや展開でファイルの日時が変わると、想定より新しく見えることがあります。日付がファイル名に入っているなら、名前から日付を読むほうが正確です。
  • 件数を記録する:何件消したかをログに残すと、ルールの誤りに後から気づけます。

途中で止まっても壊れない書き方

自動化スクリプトは、停電や強制終了で途中で止まることを前提に作ります。ファイルを書き換える処理では、一時ファイルに書いてから置き換えると、中途半端な内容のファイルが残りません。

import os
import tempfile
from pathlib import Path


def write_atomic(path: Path, text: str) -> None:
    fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=path.name, suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write(text)
        os.replace(tmp, path)  # 同じファイルシステム上なら置き換えは一度に行われる
    except BaseException:
        os.unlink(tmp)
        raise

一時ファイルを同じディレクトリに作るのは、os.replace が別のファイルシステムをまたぐと失敗するためです。

文字コードと OS の違い

テキストファイルを読み書きするときは、encoding="utf-8" を明示してください。指定しないと OS の既定の文字コードが使われ、Windows では UTF-8 以外になることがあります。将来の Python で UTF-8 を既定にする計画(PEP 686)もありますが、どのバージョンで切り替わるかに左右されないよう、明示しておくのが安全な書き方です。

Windows では、ほかのプログラムが開いているファイルを移動・削除できないことがあります。PermissionError を捕まえてログに残し、次回に回す作りにしておくと、1件の失敗で全体が止まりません。

定期実行する

スクリプトが手動で問題なく動いたら、Linux では cron や systemd のタイマー、Windows ではタスク スケジューラで定期実行します。定期実行では次の2点を押さえてください。

  • 仮想環境の Python をフルパスで指定する(cron は対話シェルと環境変数が違うため)
  • 出力をログファイルに残す(失敗に気づけるようにする)
# 毎日 3:00 にバックアップ(crontab -e で追加)
0 3 * * * /home/user/tools/.venv/bin/python /home/user/tools/backup.py >> /home/user/tools/backup.log 2>&1

まとめ

  • パス操作は pathlib、コピー・移動・圧縮は shutil を使う
  • 移動や削除のスクリプトは dry-run を既定にし、--run などを付けたときだけ実行する
  • 同名ファイルは連番で逃がし、黙って上書きしない
  • 書き換えは一時ファイル+os.replace で、途中で止まっても壊れない形にする
  • テキストは encoding="utf-8" を明示し、バックアップは別の場所に置いて戻せることを確かめる

お気軽にご相談ください

お見積りへ お問い合わせへ
▲