Fix Matplotlib's "RuntimeWarning: More than 20 figures have been opened"

Fix Matplotlib's "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.

Reproducing the Warning

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.

Why This Happens: Pyplot's Global Figure Registry

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.

The tell: if a script's memory usage climbs steadily over a long batch job that generates plots, and 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 Fix: Close Each Figure When You're Done With It

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).

A Better Fix for Batch Scripts: Skip Pyplot's State Machine Entirely

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 Workaround That Doesn't Work: Raising the rcParam

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
Verified directly: with the rcParam set to 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.

Summary: Which Approach to Use

SituationFix
Occasional pyplot usage, a handful of figures per scriptplt.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 neededUse matplotlib.figure.Figure() directly instead of plt.subplots(), bypassing pyplot's registry entirely
Long-running server/worker process that occasionally plotsSame 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_warningNot a fix, verified to leave every figure retained, only removes the warning that would have surfaced the leak