「納品前のファイルを上書きしてしまった…」「昨日の状態に戻したいのに、手元に残っていない…」

フリーランスや副業で案件を進めていると、こんなヒヤッとする場面、ありますよね。クラウドの同期フォルダに入れていても、上書きや削除まで同期されてしまうことがあります。

この記事では、Pythonの標準ライブラリだけで、作業フォルダを日付付きのzipにバックアップするスクリプトを作ります。.venv や node_modules のような重いフォルダを除外し、古いバックアップを自動で消す「世代管理」、最後にzipが壊れていないかの確認まで一緒に見ていきましょう!

対象は「for文と関数は書ける」くらいの初〜中級者の方です。追加のインストールは不要です😊

まずは1行でzipにする:shutil.make_archive

file backup
file backup / Photo by Arina Krasnikova via Pexels

いちばん手軽なのが shutil.make_archive() です。イメージとしては「フォルダを右クリックして圧縮」をPythonから行う感じです。

ここでは、案件フォルダ client_a_site を backups フォルダにzipで保存します。ファイル名に日時を入れておくと、いつのバックアップか一目でわかります。

import shutil
from datetime import datetime
from pathlib import Path

SRC = Path("client_a_site")      # バックアップしたいフォルダ
DEST = Path("backups")           # zipの保存先
DEST.mkdir(exist_ok=True)

# 例: client_a_site_20261023_090000
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base_name = DEST / f"{SRC.name}_{stamp}"

# 拡張子 .zip は自動で付く
zip_path = shutil.make_archive(str(base_name), "zip", root_dir=SRC)
size_kb = Path(zip_path).stat().st_size / 1024
print(f"作成: {Path(zip_path).name}({size_kb:.1f} KB)")

ポイントをまとめるとこんな感じです👇

  • 第1引数はファイル名から拡張子を除いたもの(.zip は自動で付きます)
  • root_dir に指定したフォルダの中身がzipに入ります
  • 戻り値は作成したzipのパスです

実行すると、作成: client_a_site_20261023_090115.zip(130.5 KB) と表示されました。

動きました!…が、130.5 KBもあります。中身はソースコードとメモ数個だけなのに、なぜでしょう?

答えは、.venv(仮想環境)や node_modules、__pycache__ まで丸ごと入っているからです。これらはあとから作り直せるファイルなので、バックアップに入れても容量を食うだけなんですよね。実際の案件では数百MBになることも珍しくありません⚠️

除外設定と世代管理つきのバックアップを作る

そこで、zipfile モジュールで1ファイルずつ追加しながら、不要なものを飛ばすようにします。あわせて、最新の3世代だけ残して古いものは削除する処理も入れます。

ここが重要です👇

  • EXCLUDE_DIRS:途中にこのフォルダ名があれば、その下は全部スキップ
  • EXCLUDE_FILES:fnmatch で *.tmp のようなワイルドカード指定
  • ファイル名が 名前_年月日_時分秒.zip なので、名前順に並べると古い順になる
import zipfile
from datetime import datetime
from fnmatch import fnmatch
from pathlib import Path

SRC = Path("client_a_site")
DEST = Path("backups")
KEEP = 3  # 残す世代数

# 入れないフォルダ名・ファイル名のパターン
EXCLUDE_DIRS = {".venv", "node_modules", "__pycache__", ".git"}
EXCLUDE_FILES = ["*.tmp", "*.pyc", ".DS_Store"]


def is_excluded(path: Path) -> bool:
    rel = path.relative_to(SRC)
    # 途中のフォルダに除外対象が1つでもあればスキップ
    if any(part in EXCLUDE_DIRS for part in rel.parts[:-1]):
        return True
    return any(fnmatch(path.name, pat) for pat in EXCLUDE_FILES)


def make_backup() -> Path:
    DEST.mkdir(exist_ok=True)
    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    zip_path = DEST / f"{SRC.name}_{stamp}.zip"

    added, skipped = 0, 0
    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
        for path in sorted(SRC.rglob("*")):
            if not path.is_file():
                continue
            if is_excluded(path):
                skipped += 1
                continue
            # zipの中は「client_a_site/src/app.py」のような相対パスにする
            zf.write(path, arcname=path.relative_to(SRC.parent))
            added += 1

    size_kb = zip_path.stat().st_size / 1024
    print(f"作成: {zip_path.name}({added}ファイル / 除外 {skipped} / {size_kb:.1f} KB)")
    return zip_path


def remove_old_backups() -> None:
    # 名前に日時が入っているので、名前順=古い順
    backups = sorted(DEST.glob(f"{SRC.name}_*.zip"))
    for old in backups[:-KEEP]:
        old.unlink()
        print(f"削除: {old.name}")
    print(f"残っているバックアップ: {min(len(backups), KEEP)}個")


if __name__ == "__main__":
    make_backup()
    remove_old_backups()
backup.py を4回実行したときの4回目の結果
backup.py を4回実行したときの4回目の結果

このスクリプトを4回続けて実行してみました。上の画像が4回目の結果です。

130.5 KB → 0.5 KBに減りました🚀 除外された4つは、.venv・node_modules・__pycache__ の中のファイルと cache.tmp です。

そして4つ目ができたタイミングで、いちばん古い1つ目(090115)が削除され、3世代が残っています。これなら、毎日動かしてもディスクがいっぱいになりません。

ちなみに arcname=path.relative_to(SRC.parent) としているので、zipの中は client_a_site/src/app.py のようにフォルダ名から始まる形になります。展開したときにファイルが散らばらないので、地味に便利です。

⚠️ 世代削除の注意点

  • glob のパターンは client_a_site_*.zip のように案件名まで入れて絞ること。*.zip にすると、別案件のzipまで消してしまいます
  • 同じ秒に2回実行すると同じファイル名になり上書きされます。手動で連打しない運用なら問題ありません

バックアップが本当に使えるか確かめる

バックアップは「いざ戻すときに使える」ことが一番大事です。作りっぱなしにせず、testzip() で破損チェックをして、別フォルダに展開してみましょう。

import zipfile
from pathlib import Path

DEST = Path("backups")

# いちばん新しいバックアップを調べる
latest = sorted(DEST.glob("client_a_site_*.zip"))[-1]

with zipfile.ZipFile(latest) as zf:
    bad = zf.testzip()  # 壊れたファイルがあればその名前、なければ None
    print(f"確認: {latest.name}")
    print("破損チェック:", "OK" if bad is None else f"NG({bad})")
    for info in zf.infolist():
        print(f"  {info.filename}  {info.file_size}バイト")

    # 別フォルダに展開して中身を確かめる
    zf.extractall("restore_test")
print("restore_test フォルダに展開しました")
check_backup.py の実行結果
check_backup.py の実行結果

実行結果は上の画像のとおりです。「破損チェック: OK」となり、3つのファイルが展開できました。

  • testzip() は中のファイルを全部読み、CRC(チェック用の値)が合わないファイルがあればその名前を、問題なければ None を返します
  • 一覧を見ると、除外したフォルダが入っていないことも確認できます
  • 展開先は元のフォルダとは別の場所にしましょう。元ファイルを上書きしてしまうと本末転倒です

毎日自動で動かすには

スクリプトができたら、Windowsならタスクスケジューラ、Mac・Linuxなら cron に登録すれば、毎日決まった時刻にバックアップが取れます。そのときは SRC と DEST を絶対パスにしておくのがコツです。自動実行では「今いるフォルダ」がスクリプトの場所とは限らないためです。

また、保存先は作業フォルダと別のドライブや外付けSSDにしておくと、ディスク故障にも備えられます。

より詳しい仕様は公式ドキュメントも参考にしてください。

まとめ

  • shutil.make_archive() なら1行でフォルダをzipにできる。ただし .venv なども全部入る
  • zipfile + rglob で1ファイルずつ追加すれば、除外設定ができる(今回は 130.5 KB → 0.5 KB)
  • ファイル名に日時を入れれば、名前順=古い順。新しい3世代だけ残すのも数行で書ける
  • testzip() と展開テストで、「戻せるバックアップ」になっているか確認する

「消えてから後悔」を防ぐ仕組みは、作っておくだけで気持ちがぐっとラクになります。ご自身の作業フォルダに合わせて除外リストを調整して、ぜひ試してみてください!

📚 関連商品・おすすめ書籍

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

もしも

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

初心者に定番のPython入門書

Amazonで見る
実践Claude Code入門―現場で活用するためのAIコーディングの思考法

もしも

実践Claude Code入門―現場で活用するためのAIコーディングの思考法

AIコーディングの現場活用法を学ぶ一冊

Amazonで見る
Python Web開発実践入門 ―― FastAPIによるWebAPI開発と非同期処理

もしも

Python Web開発実践入門 ―― FastAPIによるWebAPI開発と非同期処理

FastAPIでWebAPI開発を実践的に学ぶ

Amazonで見る

※本記事にはアフィリエイトリンクが含まれます。

ABOUT ME
やまちゃん
これまで学生と社会人を合わせて5000人以上にプログラミング学習を指導。 ゼロからイチをわかりやすく解説する専門家として活動しており、本業ではArduinoを用いたIoT開発とロボットプログラミングが専門。 Pythonを用いたアプリ開発、ウェブアプリケーションの開発で業務の効率化をサポートしています。