RuntimeWarning: More than 20 figures have been opened"This one usually surfaces in a batch script, not an interactive notebook: a loop that generates one chart per row of a dataframe, one per model checkpoint, or one per report section, and somewhere past the twentieth iteration Matplotlib starts printing a warning about retained figures. It's easy to dismiss as noise since the script keeps running and nothing crashes, but it's describing a real, measurable memory leak. Below is the reproduction (tested against Matplotlib 3.11.0), why pyplot behaves this way, the actual fix, and why the tempting one-line workaround doesn't do what it looks like it does.
The most direct trigger is a loop that creates figures through plt.subplots() without ever closing them, the pattern that shows up naturally in report-generation code:
import matplotlib
matplotlib.use("Agg") # non-interactive backend, same behavior as an interactive one
import matplotlib.pyplot as plt
for i in range(25):
fig, ax = plt.subplots()
ax.plot([1, 2, 3], [1, 2, 3])
fig.savefig(f"chart_{i}.png")
# no plt.close(fig) here
# RuntimeWarning: More than 20 figures have been opened. Figures created
# through the pyplot interface (`matplotlib.pyplot.figure`) are retained
# until explicitly closed and may consume too much memory. (To control
# this warning, see the rcParam `figure.max_open_warning`). Consider
# using `matplotlib.pyplot.close()`.
print(len(plt.get_fignums())) # 25
Note that the figure gets saved to disk correctly. This isn't a bug in the output, the chart files are fine. The problem is entirely about what happens to the in-memory Figure object after savefig() returns: nothing, by default. plt.get_fignums() confirms all 25 are still tracked and retained after the loop finishes.
Matplotlib has two interfaces layered on top of the same drawing engine. The pyplot interface (plt.figure(), plt.subplots(), plt.plot()) is a stateful convenience layer, originally modeled on MATLAB, that keeps a global registry of every figure it creates so that later calls like plt.plot() without an explicit target know which figure to draw on. That registry is exactly what's leaking: a figure only leaves it when something explicitly removes it, either plt.close(fig), plt.close(fig_number), or plt.close("all"). Going out of scope, reassigning the variable, or the loop simply ending does not remove it, because the registry itself, not your local variable, is what's holding a live reference.
len(plt.get_fignums()) keeps growing instead of returning to a small number, this is very likely why, even before the warning threshold at 20 is reached.The direct fix is one line per figure, calling plt.close(fig) right after the figure has been saved or otherwise consumed:
for i in range(25):
fig, ax = plt.subplots()
ax.plot([1, 2, 3], [1, 2, 3])
fig.savefig(f"chart_{i}.png")
plt.close(fig)
print(len(plt.get_fignums())) # 0, verified directly, no warning raised
plt.close(fig) removes that specific figure from the registry and releases its resources. plt.close("all") is the equivalent for clearing everything at once, useful right before a batch job starts if there's a chance stale figures already exist from earlier in the same process (a REPL session, a Jupyter kernel that's been running for a while, or a Flask/FastAPI worker that reuses the same Python process across requests).
For scripts that never need pyplot's interactive convenience, batch report generation being the clearest case, the more robust fix is to avoid the global registry altogether by using Matplotlib's object-oriented interface directly:
from matplotlib.figure import Figure
for i in range(25):
fig = Figure()
ax = fig.add_subplot(111)
ax.plot([1, 2, 3], [1, 2, 3])
fig.savefig(f"chart_{i}.png")
# no plt.close() needed: this fig was never registered with pyplot
print(len(plt.get_fignums())) # 0, verified directly, zero warnings the entire loop
Since Figure() was created without going through plt.figure()/plt.subplots(), pyplot's registry never knew it existed. It behaves like any other Python object: garbage-collected once the loop iteration ends and nothing references it anymore, no explicit close call required. This is also the safer default in any code that runs inside a web server or worker process, where import matplotlib.pyplot as plt's global state can leak across requests in ways that are easy to miss during local testing with a single script run.
The warning message itself points at figure.max_open_warning, which makes disabling it look like a legitimate fix. It isn't:
import matplotlib.pyplot as plt
plt.rcParams["figure.max_open_warning"] = 0 # or any number higher than the loop count
for i in range(25):
fig, ax = plt.subplots()
ax.plot([1, 2, 3], [1, 2, 3])
# still no plt.close(fig)
print(len(plt.get_fignums())) # 25, verified directly: still fully leaked, just silent now
0, the exact same loop produces zero warnings, but plt.get_fignums() still reports all 25 figures retained in memory. Every figure is exactly as leaked as before, the only thing that changed is that the one signal warning you about it is now gone. In a long-running batch job or service process, this trades a visible warning for an eventual, harder-to-diagnose out-of-memory crash.| Situation | Fix |
|---|---|
| Occasional pyplot usage, a handful of figures per script | plt.close(fig) after each figure is saved/used, or plt.close("all") as a periodic sweep |
| Batch/report-generation scripts that create many figures programmatically, no interactive display needed | Use matplotlib.figure.Figure() directly instead of plt.subplots(), bypassing pyplot's registry entirely |
| Long-running server/worker process that occasionally plots | Same as above, the OOP interface, since pyplot's global state is shared across every request handled by that process |
Raising or zeroing figure.max_open_warning | Not a fix, verified to leave every figure retained, only removes the warning that would have surfaced the leak |