結論:各回でFigureを作り、保存後に必ずcloseする
試料ごとに図を作るループでは、fig, ax = plt.subplots()で対象を明示し、fig.savefig(...)で保存し、finallyの中でplt.close(fig)を実行する。pyplotは作ったFigureへの参照を保持するため、保存しただけでは登録が解除されない。大量に作る処理では開いた図が蓄積し、想定以上のメモリを使う原因になる。
「保存したファイルがあるから図は不要になったはず」と考えると見落としやすい。画像ファイルへ書き出すことと、Python側の図の後始末は別の操作である。また、毎回同じ現在のAxesへ描画すると前の試料の線が混ざることがある。図の作成、データの追加、保存、閉じる操作を一回分のまとまりにすると、表示の混入と管理漏れを防ぎやすい。
そのまま動かせる例
この例では、8枚を閉じずに保持した場合と、各回で閉じた場合のpyplot登録数を比べる。比較用に開いた8枚も測定後には全て閉じるので、そのまま実行できる。後半では各試料に1本ずつ線を描き、最後の試料だけをsample-7.pngへ保存する。さらに登録数の推移をfigure-lifecycle.pngへ出力する。
NumPyとMatplotlibを使い、同名ファイルのない作業フォルダーで実行する。掲載図は開いているFigureの数を示したもので、メモリ使用量やRSSの測定結果ではない。8回に限定した小さな実験でも、作った図が登録されたまま残ることを確認できる。多数の巨大画像をわざと作ってメモリを圧迫する必要はない。
import numpy as np
import matplotlib.pyplot as plt
baseline = set(plt.get_fignums())
open_counts = []
kept = []
for i in range(8):
fig, ax = plt.subplots()
ax.plot([0, 1], [i, i + 1])
kept.append(fig)
open_counts.append(len(set(plt.get_fignums()) - baseline))
for fig in kept:
plt.close(fig)
kept.clear()
del fig, ax
closed_counts = []
x = np.linspace(0, 2 * np.pi, 100)
for i in range(8):
fig, ax = plt.subplots(figsize=(5.5, 3.3), layout="constrained")
try:
ax.plot(x, np.sin(x + i / 3))
ax.set(xlabel="Phase (rad)", ylabel="Signal", title=f"Sample {i}")
if i == 7:
fig.savefig("sample-7.png", dpi=160)
assert len(ax.lines) == 1
finally:
plt.close(fig)
closed_counts.append(len(set(plt.get_fignums()) - baseline))
assert open_counts == list(range(1, 9))
assert closed_counts == [0] * 8
fig, ax = plt.subplots(figsize=(6.3, 3.6), layout="constrained")
try:
ax.plot(range(1, 9), open_counts, "o-", label="Kept open")
ax.plot(range(1, 9), closed_counts, "o-", label="Closed each time")
ax.set(xlabel="Completed iteration", ylabel="New registered figures",
title="Figure lifecycle, not an RSS measurement", yticks=range(9))
ax.legend()
ax.grid(alpha=0.25)
fig.savefig("figure-lifecycle.png", dpi=160)
finally:
plt.close(fig)
assert set(plt.get_fignums()) == baseline
print("kept open:", open_counts)
print("closed each time:", closed_counts)
print("registered figures returned to baseline: True")
実行結果
kept open: [1, 2, 3, 4, 5, 6, 7, 8]
closed each time: [0, 0, 0, 0, 0, 0, 0, 0]
registered figures returned to baseline: True


closeとclearは役割が違う
出力では閉じない場合の登録数が1から8へ増え、各回で閉じる場合は0を保つ。既存の図を壊さないよう、処理開始時のFigure番号を基準に差分だけを数えている。最終的に登録が基準へ戻ったことも確認した。各Axesの線が1本であることを確かめているため、前の試料の線が蓄積していないことも分かる。
ax.clear()やfig.clear()は描画内容を消す操作であり、pyplotからFigureを登録解除するcloseとは異なる。図を再利用して高速化したい場合はclearが役に立つが、ラベルや範囲など再設定が必要な項目も多い。まずは一回ごとに作って閉じる分かりやすい構成で正しさを確かめ、必要になってから再利用へ進むとよい。
例外が起きても片付ける
保存先が存在しない、権限がない、データ形状が合わないといった理由で例外が起きても、finallyなら図を閉じられる。exceptで何でも握りつぶす必要はない。エラーを呼び出し元へ伝えながら資源だけを片付ける形にする。複数の図を扱う処理では引数なしのcloseで現在の図へ頼るより、作ったfigを指定する方が意図が明確である。
ノートブックの最後のセルで表示される図と、バッチ処理で保存だけをする図ではライフサイクルが異なる。対話的なshowの後に図が閉じられる場合があっても、ファイル保存だけの処理へ同じ期待を持ち込まないこと。別セルや他の関数が使っている図まで閉じるclose("all")も、共有の作業環境では不用意に使わないようにしたい。
closeしてもRSSがすぐ下がるとは限らない
図をリストや辞書へ保存していると、close後も自分の変数から参照できる。この例でも比較用のリストを空にしている。コールバックやキャッシュが参照を保持する場合もある。不要になった大きな入力配列を保持していないかも確認しよう。pyplotの登録数が0だからプログラム全体のメモリ問題が解決した、とまでは言えない。
Pythonや描画ライブラリのメモリ管理、画像バッファ、OSの割り当て方により、解放した分が直ちにプロセスのRSSへ反映されるとは限らない。メモリが増え続けるなら、同じ大きさの処理を繰り返したときに増加が収まるか、参照が残っていないかを別途測る。登録数の確認は原因を絞る手掛かりであり、メモリ量そのものを代用しているわけではない。
確認環境と参考資料
例はLinux・CPython 3.12.14・NumPy 2.3.5・Matplotlib 3.10.8(Agg)で実行した。掲載した出力はこの環境での結果である。公式資料のstable版や最新版は更新されるため、手元のバージョンと対応する仕様も確認してほしい。
関連項目:欠測区間を線でつながないグラフにする / 重すぎる論文用PDF図を軽くする / tracemallocでメモリが増えた場所を調べる
