openpyxlでExcelを自動更新したら、関数42本とグラフ6枚が消えた金曜の夜

正直に書くと、月初に走らせる予定だった自動更新スクリプトを金曜の夜にテストしたら、上司が3年かけて整えたExcelの関数42本とグラフ6枚が、全部ただの数字とのっぺりした白い枠に化けていました。

しかも先に成功ログだけSlackに流していたので、私は5分間「終わった、帰れる」と思っていました。

地方の静かな部屋で、同居の猫がストーブ前に丸まる深夜23時でした。ストーブの音と冷蔵庫の唸り以外は何も聞こえません。私はコーヒーを淹れ直し、椅子に座り直して、もう1回ファイルを開きました。今度はちゃんと見ました。グラフが全部消えていました。

目次

やりたかったことは1行で書ける

毎月、社内のExcelテンプレートを開いて、特定のシートにその月の数字を100行ほど貼り付ける。ピボット更新とPDF書き出しは人間がExcelで開いてやる前提で、今回はraw貼り付けだけをPython化したかったのです。

前提環境はWindows 11、Python 3.11、openpyxl 3.1.2です。手元で再現するなら、Excelで新規ブックを作り、シートを3枚にリネームして用意します。

  • raw: 1行目に date / type / sales / cost の見出し、2行目以降は空
  • summary: A2 セルに =SUMIFS(raw!C:C, raw!B:B, "A") のような数式を30本以上
  • dashboard: raw の値を参照する折れ線グラフを5枚以上
pip install openpyxl==3.1.2

最初のスクリプトはこれだけのつもりでした。new_rows は呼び出し元から渡すリストです。

from openpyxl import load_workbook

new_rows = [
    ["2026-05-01", "A", 1200, 980],
    ["2026-05-02", "B", 1500, 1180],
]

wb = load_workbook("monthly_report.xlsx")
ws = wb["raw"]

for row_idx, row in enumerate(new_rows, start=2):
    for col_idx, value in enumerate(row, start=1):
        ws.cell(row=row_idx, column=col_idx, value=value)

wb.save("monthly_report.xlsx")

時系列を正確に書きます。金曜23時の初回実行で消えたのはグラフ6枚の中身だけ、SUMIFS42本はまだ残っていました。エラーは出ず、セルにも数字が入っています。SUMIFS42本が値化したのは翌日の土曜午前、私が「data_only=True を付ければ直る」と思い込んだテストが原因です(次節)。

最初はエラーが出たほうがマシだった

最初はエラーが出たほうがマシでした。私の場合は、ノーエラーで内容が壊れたほうが10倍怖かったです。

backupを取っていたので元には戻せましたが、もしテンプレートしかなかったら、上司の3年分のチューニングが一晩で消えていました。私はその夜、テンプレ系のファイルは触る前に必ず別名コピーを取る癖をつけました。

import shutil
from datetime import datetime
from pathlib import Path

src = Path("monthly_report.xlsx")
backup_dir = Path("backup")
backup_dir.mkdir(exist_ok=True)

stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = backup_dir / f"{src.stem}_{stamp}{src.suffix}"
shutil.copy2(src, backup_path)

この6行を最初に置いておけば、深夜にひっくり返ってもファイルだけは戻せます。私はこれを書く前に1回やらかしました。今は何があってもまずバックアップを取ってから読み込みます。

原因は、私が「全部読めるツール」だと思っていたこと

3時間調べてわかったのは、openpyxlは数式の「結果の値」を読み込んだり書き戻したりするのが得意ではない、ということです。

私の環境はWindows 11、Python 3.11、openpyxl 3.1.2でした。3つの破壊が同時に起きていたので、原因を分けて書きます。

1つ目、数式42本が値化した直接原因は、私が翌日のテスト中に data_only=True を付け直して書き戻したことでした。詳しくは次節で書きますが、これは数式を完全に消すモードです。

2つ目、グラフ6枚の中身(プロットされる系列の線・棒)が消えた原因は、load_workbookwb.save の往復で、openpyxlがグラフのキャッシュ値を再シリアライズし切れず、 の中身を空にしてしまったことでした。軸・凡例・タイトルといった外枠は残っているのに、データだけが空になります。私の場合は折れ線グラフ6枚すべてで起きていました。

3つ目、ピボットテーブルは触っていないつもりでもキャッシュが再計算されず、開き直したときに古い値が表示される、または空になります。

load_workbook("file.xlsx") をそのまま呼ぶだけなら、数式の文字列自体は保持されます。問題が起きるのは、書き込みオプション、グラフの再シリアライズ、ピボットのキャッシュ、画像の再配置という4方向です。openpyxlで安全に触れるのは「セルの値の読み書き」までで、グラフ・ピボット・画像はExcel本体に任せたほうが安全でした。

最初に試した、間違った修正

最初に思いついた修正は、data_only=True を付けることでした。

wb = load_workbook("monthly_report.xlsx", data_only=True)

これは逆方向の悪手でした。data_only=True は「数式を捨てて、最後にExcelが計算した結果の値だけ読む」モードです。書き戻すと、数式が完全に消えます。

私は最初これを5回試して、5回ともSUMIFSが消えました。読むだけならいいのですが、書き戻すなら絶対に付けてはいけませんでした。

ここで完全に頭が冷えて、冷めたコーヒーの横で、私はノートに方針を書き出しました。

- 数式は触らない
- 書式は触らない
- グラフは触らない
- 触るのは「raw」シートのデータ行だけ
- 保存はファイル全体ではなく、最後に1回だけ

書き出して気づきました。私が壊した本当の原因は、コードではなく方針が無かったことでした。

最終的に落ち着いた最小構成

3時間溶かして、最終的にこの形に落ち着きました。グラフ付きテンプレで完全に安全とは言いません。私の環境(折れ線6枚・ピボット0個・マクロなし)でrawシートだけ更新する運用に限定したら壊れなかった、という話です。

方針: openpyxlはraw貼り付け係に限定する

openpyxlは「rawデータの貼り付け係」に限定して、関数と書式とグラフはExcel本体に任せる。役割を狭くしただけで、急に安定しました。

コード本体: バックアップ + 値クリア方式

行削除は使わず「既存セルの値だけクリア」する方式にしています。delete_rows はテーブル範囲・名前付き範囲・グラフの参照範囲まで巻き込むことがあったので、私は怖くて使うのをやめました。

import shutil
from datetime import datetime
from pathlib import Path
from openpyxl import load_workbook


TEMPLATE = Path("monthly_report.xlsx")
BACKUP_DIR = Path("backup")
RAW_SHEET = "raw"


def _backup() -> Path:
    BACKUP_DIR.mkdir(exist_ok=True)
    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    dst = BACKUP_DIR / f"{TEMPLATE.stem}_{stamp}{TEMPLATE.suffix}"
    shutil.copy2(TEMPLATE, dst)
    return dst


def update_raw(rows: list[list[float | str]]) -> Path:
    if not TEMPLATE.exists():
        raise FileNotFoundError(f"template not found: {TEMPLATE}")

    backup_path = _backup()

    wb = load_workbook(TEMPLATE)  # data_only は付けない
    if RAW_SHEET not in wb.sheetnames:
        raise ValueError(f"sheet not found: {RAW_SHEET}")

    ws = wb[RAW_SHEET]

    # 行は削除しない。2行目以降の既存セルの値だけをクリアする。
    # こうすると、テーブル範囲・名前付き範囲・グラフの参照が崩れない。
    if ws.max_row >= 2:
        for row in ws.iter_rows(min_row=2, max_row=ws.max_row,
                                min_col=1, max_col=ws.max_column):
            for cell in row:
                cell.value = None

    for r_offset, row in enumerate(rows, start=2):
        for c_offset, value in enumerate(row, start=1):
            ws.cell(row=r_offset, column=c_offset, value=value)

    wb.save(TEMPLATE)
    return backup_path


if __name__ == "__main__":
    sample = [
        ["2026-05-01", "A", 1200, 980],
        ["2026-05-02", "B", 1500, 1180],
        ["2026-05-03", "A", 900, 720],
    ]
    update_raw(sample)

実行と注意点

実行はこの1コマンドだけです。

python update_raw.py

注意点。rawシート自体に数式列がある場合、上のクリア処理が数式まで消します。そのときは min_colmax_col を「データ列だけ」に絞ってください。1〜4列目だけがデータなら min_col=1, max_col=4 に固定します。私はこれを忘れて、テスト用テンプレで自分の数式を消しました。

守れる4つのポイント

ポイントは4つです。

1つ目、data_only は絶対に付けないこと。読むだけのスクリプトと、書き戻すスクリプトは別ファイルに分けます。 2つ目、触るシートと触らないシートをはっきり分けること。私は raw タブだけ書き換えて、集計タブとグラフタブには絶対に触りません。 3つ目、wb.save() の回数を1回に絞ること。途中で何度も保存すると、その度にopenpyxlの再シリアライズが走り、壊れる確率が上がります。 4つ目、行を消すのではなくセルの値だけ消すこと。delete_rows は早い代わりに、テーブル・名前付き範囲・グラフ参照を巻き込みます。cell.value = None も万能ではありませんが、私の環境ではテーブル範囲を維持したまま中身だけ入れ替えるのに必要十分でした。

このスクリプトに切り替えてから、5ヶ月で1度もグラフが消えていません。前は数式の保護を頑張ろうとしていましたが、結局は「触らないのが一番守られる」でした。

確認手順をテストにした

このスクリプトを夜中の0時半に書き終えた直後、私は翌朝起きたとき関数42本とグラフ6枚が本当に生きているか、目視で全部確認するつもりでいました。実際にやってみて、5分でうんざりしました。人間の目視確認は、必ず忘れます。私は3回目に同じ事故を起こしかけて、最終確認を自動化しました。

from openpyxl import load_workbook


def assert_template_alive(path: str) -> None:
    wb = load_workbook(path)

    # 1) 集計シートの式が生きているか
    summary = wb["summary"]
    formula_cells = [
        c for row in summary.iter_rows()
        for c in row
        if isinstance(c.value, str) and c.value.startswith("=")
    ]
    assert len(formula_cells) >= 30, f"formula count too low: {len(formula_cells)}"

    # 2) グラフが残っているか + 各グラフのデータ系列が空でないか
    # 注: _charts は openpyxl の内部属性で公式APIではないため、
    #     バージョン更新で壊れる可能性があることは承知の上で使っている
    chart_sheet = wb["dashboard"]
    charts = chart_sheet._charts
    assert len(charts) >= 5, f"chart count too low: {len(charts)}"
    for i, chart in enumerate(charts):
        series_count = len(chart.series)
        assert series_count >= 1, f"chart {i} has no series"
        for j, s in enumerate(chart.series):
            ref = getattr(getattr(s, "val", None), "numRef", None)
            assert ref is not None and ref.f, f"chart {i} series {j} has empty range"

    # 3) raw のデータが書き換わっているか
    raw = wb["raw"]
    assert raw.max_row >= 2, "raw is empty"


if __name__ == "__main__":
    assert_template_alive("monthly_report.xlsx")

40行ないチェッカーです。自動更新の最後にこれを呼ぶようにして、AssertionErrorが出たら処理全体を失敗扱いにしました。

何より良かったのは、心理的な安心感です。スクリプトを夜に走らせて、朝起きたときに「式が42本残っているか」を自分の目で確認しなくてよくなりました。私はこの40行が、過去のどんなライブラリ選定よりも自分の睡眠の質を上げたと感じています。

openpyxlの守備範囲と、逃がすべき範囲

openpyxlでやること: セル値の読み書き、書式に触れない1シート内貼り付け、読み取り専用の検査。

逃がすこと(pywin32か手動): ピボット再計算、複雑テンプレでのグラフ再保存、.xlsm 更新、大量の条件付き書式、PDF出力。

値クリア方式でも、グラフ付き .xlsx を openpyxl で保存し直す以上、テンプレ構造によってはグラフが壊る可能性は残ります。重要ファイルに当てる前に必ずコピーで5回往復テストしてください。それでも壊れるなら pywin32 でExcel本体を裏起動する案に逃がします。pywin32 はExcel常駐とGUI取得が前提でサーバーでは使えませんが、ローカルなら最も確実です。

まとめ

3時間で学んだことはシンプルでした。data_only=True は読むときだけ。触るシートと触らないシートを物理的に分ける。wb.save() は最後の1回だけ。チェッカー40行で最終確認を自動化し、バックアップ6行を最初に取る。これだけで「グラフ6枚が真っ白」は二度と起きていません。

道具に何をやらせないかを最初に決める。openpyxlは役割を狭く与えれば5ヶ月黙って働きました。今夜も猫の隣で、この40行のチェッカー付きで月末ファイルを回しています。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

コメント

コメントする

目次