Skip to content

Fix: Matplotlib Not Working — Plots Not Showing, Blank Output, and Figure Layout Problems

FixDevs ·

Quick Answer

How to fix Matplotlib errors — plot not displaying, blank figure, RuntimeError main thread not in main loop, tight_layout UserWarning, overlapping subplots, savefig saving blank image, backend errors, and figure/axes confusion.

The Error

You call plt.show() and nothing appears. Or a figure opens but it’s blank. Or you save a figure and get an empty white image:

plt.savefig("plot.png")
# Saves fine — but the file is blank

Or the layout looks wrong — titles cut off, labels overlapping the axes, subplots squashed together:

UserWarning: tight_layout: falling back to Axes positioning using bbox_artist

Or Matplotlib crashes the moment you import it in a thread or server process:

RuntimeError: main thread is not in main loop

Matplotlib separates the rendering pipeline (backends) from the drawing API (Figure, Axes, Artists). When those two layers are misconfigured or misused — wrong backend for the environment, figures created without axes, plt.show() called at the wrong point — you get silent failures or rendering errors that are hard to trace.

Why This Happens

Matplotlib has two interfaces: the pyplot state machine (plt.plot(), plt.show()) and the object-oriented API (fig, ax = plt.subplots()). The pyplot interface keeps a reference to the “current figure” and “current axes” — which becomes undefined when you have multiple figures, call plt.close(), or work in a non-interactive environment. The object-oriented API avoids all of this by giving you explicit handles.

Most display and layout problems trace back to: wrong backend for the environment (GUI vs. non-GUI), calling plt.show() after plt.close() in a loop, or mixing the two interfaces in a way that loses track of which axes is active.

Fix 1: Plot Not Displaying — Backend and plt.show()

import matplotlib.pyplot as plt
plt.plot([1, 2, 3], [4, 5, 6])
# Nothing happens

In a terminal script, plt.show() is required to display the figure. Without it, the script finishes and the figure is destroyed before you see it:

import matplotlib.pyplot as plt

plt.plot([1, 2, 3], [4, 5, 6])
plt.title("My Plot")
plt.show()   # Blocks until you close the window

In Jupyter, plt.show() is usually wrong — it flushes and clears the figure before the cell captures it. Use the magic command instead:

%matplotlib inline   # Add to the first cell of the notebook

import matplotlib.pyplot as plt
plt.plot([1, 2, 3], [4, 5, 6])
# Cell ends — figure renders automatically. No plt.show() needed.

Backend errors prevent the GUI window from opening:

RuntimeError: Invalid DISPLAY variable
_tkinter.TclError: no display name and no $DISPLAY environment variable

This happens when running on a headless server (SSH without X forwarding, Docker, CI). The fix is to switch to a non-interactive backend:

import matplotlib
matplotlib.use('Agg')   # Must be called BEFORE importing pyplot

import matplotlib.pyplot as plt
plt.plot([1, 2, 3])
plt.savefig("plot.png")   # Works on headless server — no GUI needed

Or set it via environment variable before the script starts:

export MPLBACKEND=Agg
python train.py

Available backends:

BackendUse case
AggHeadless/server — PNG output only, no GUI
TkAggTkinter GUI (default on many Linux systems)
Qt5Agg / Qt6AggQt GUI — requires PyQt5/PyQt6
MacOSXNative macOS GUI
WebAggBrowser-based interactive
inlineJupyter Notebook (set via %matplotlib inline)
widgetJupyterLab interactive (requires ipympl)

Check and set the current backend:

import matplotlib
print(matplotlib.get_backend())    # See current backend
matplotlib.use('Agg')              # Change before pyplot import

Fix 2: savefig() Saves a Blank Image

plt.plot([1, 2, 3])
plt.show()           # Opens the window and clears the figure
plt.savefig("plot.png")   # Saves after show() — blank!

plt.show() renders and then clears the current figure. Calling savefig() after show() saves an empty canvas.

Save before showing:

import matplotlib.pyplot as plt

plt.plot([1, 2, 3], [4, 5, 6])
plt.title("Sales Over Time")
plt.savefig("plot.png", dpi=150, bbox_inches='tight')   # Save first
plt.show()   # Then display (optional)

The object-oriented approach eliminates this ambiguity — you always know which figure you’re saving:

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot([1, 2, 3], [4, 5, 6], linewidth=2, color='steelblue')
ax.set_title("Sales Over Time")
ax.set_xlabel("Month")
ax.set_ylabel("Revenue ($)")

fig.savefig("plot.png", dpi=150, bbox_inches='tight')
plt.show()
# fig.savefig() always saves this specific figure regardless of pyplot state

bbox_inches='tight' — include this almost always. It adjusts the bounding box to include all text labels, titles, and legends that extend outside the default axes boundary. Without it, labels near the edges get cut off.

plt.close() in loops — if you’re saving multiple figures in a loop, close each one after saving to free memory:

import matplotlib.pyplot as plt
import numpy as np

categories = ['A', 'B', 'C', 'D']
data = np.random.rand(4, 10)

for i, (cat, values) in enumerate(zip(categories, data)):
    fig, ax = plt.subplots()
    ax.hist(values, bins=5, color='steelblue', edgecolor='white')
    ax.set_title(f"Category {cat}")
    fig.savefig(f"hist_{cat}.png", bbox_inches='tight')
    plt.close(fig)   # Free memory — critical in long loops

Without plt.close(fig), each iteration adds a figure to memory. With 1000 iterations, you’ll hit a “too many open figures” warning and eventually run out of RAM.

Fix 3: Figure and Axes Confusion — Object-Oriented vs. pyplot

The most common source of “wrong plot” and “wrong axes” bugs: accidentally mixing plt.* calls (which target the current axes) with explicit ax.* calls on a specific axes.

import matplotlib.pyplot as plt
import numpy as np

# WRONG — which axes does plt.title() refer to?
fig, (ax1, ax2) = plt.subplots(1, 2)
ax1.plot([1, 2, 3], [4, 5, 6])
ax2.plot([1, 2, 3], [6, 5, 4])
plt.title("This goes on ax2 only — the 'current' axes")   # Confusing

# CORRECT — always use ax.set_*() in multi-axes figures
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))

ax1.plot([1, 2, 3], [4, 5, 6], color='steelblue', label='Ascending')
ax1.set_title("Ascending Trend")
ax1.set_xlabel("X")
ax1.set_ylabel("Y")
ax1.legend()

ax2.plot([1, 2, 3], [6, 5, 4], color='coral', label='Descending')
ax2.set_title("Descending Trend")
ax2.set_xlabel("X")
ax2.legend()

fig.suptitle("Comparison", fontsize=16, fontweight='bold')   # Figure-level title
fig.tight_layout()

Common plt.* vs ax.* equivalents:

plt.* (state machine)ax.* (object-oriented)
plt.title("x")ax.set_title("x")
plt.xlabel("x")ax.set_xlabel("x")
plt.ylabel("y")ax.set_ylabel("y")
plt.xlim(0, 10)ax.set_xlim(0, 10)
plt.ylim(0, 10)ax.set_ylim(0, 10)
plt.legend()ax.legend()
plt.grid(True)ax.grid(True)
plt.xticks(...)ax.set_xticks(...)

Pro Tip: Use plt.* only for single-figure, single-axes scripts (quick data exploration). Use fig, ax = plt.subplots() for everything else — functions that return plots, class methods, multi-panel figures, or any code that runs more than once. The object-oriented API makes the code’s intent explicit and eliminates “which axes is current” ambiguity entirely.

Fix 4: Subplot Layout — Overlapping Labels and Titles

UserWarning: tight_layout: falling back to Axes positioning using bbox_artist

Subplots overlap, x-labels get cut off at the bottom, or fig.suptitle() overlaps with subplot titles.

fig.tight_layout() adjusts spacing between subplots automatically:

import matplotlib.pyplot as plt
import numpy as np

fig, axes = plt.subplots(2, 3, figsize=(15, 8))

for i, ax in enumerate(axes.flat):
    x = np.linspace(0, 2 * np.pi, 100)
    ax.plot(x, np.sin(x + i * 0.5))
    ax.set_title(f"Phase = {i * 0.5:.1f}")
    ax.set_xlabel("x")
    ax.set_ylabel("sin(x)")

fig.tight_layout()   # Fix overlapping labels
fig.savefig("subplots.png", bbox_inches='tight')

fig.tight_layout(rect=...) with a suptitle — reserve space at the top for the figure title:

fig, axes = plt.subplots(2, 2, figsize=(12, 10))
# ... populate axes ...

fig.suptitle("Model Comparison", fontsize=18, fontweight='bold')
fig.tight_layout(rect=[0, 0, 1, 0.96])   # Leave 4% at top for suptitle

gridspec for complex layouts — unequal subplot sizes:

import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import numpy as np

fig = plt.figure(figsize=(14, 8))
gs = gridspec.GridSpec(2, 3, figure=fig, hspace=0.4, wspace=0.3)

ax_main = fig.add_subplot(gs[0, :2])     # Spans 2 columns in row 0
ax_side = fig.add_subplot(gs[0, 2])      # Single cell, row 0 col 2
ax_bot1 = fig.add_subplot(gs[1, 0])      # Row 1, col 0
ax_bot2 = fig.add_subplot(gs[1, 1])      # Row 1, col 1
ax_bot3 = fig.add_subplot(gs[1, 2])      # Row 1, col 2

x = np.linspace(0, 10, 200)
ax_main.plot(x, np.sin(x), color='steelblue')
ax_main.set_title("Main Signal (Wide)")

ax_side.hist(np.random.randn(500), bins=20, color='coral')
ax_side.set_title("Distribution")

for ax, label in zip([ax_bot1, ax_bot2, ax_bot3], ['A', 'B', 'C']):
    ax.bar(['x', 'y', 'z'], np.random.rand(3))
    ax.set_title(f"Metric {label}")

fig.savefig("complex_layout.png", bbox_inches='tight', dpi=150)

constrained_layout=True is the modern alternative to tight_layout() — set it at figure creation:

fig, axes = plt.subplots(2, 2, figsize=(12, 10), constrained_layout=True)
# No need to call tight_layout() — layout is managed automatically

constrained_layout handles colorbars, legends, and figure titles better than tight_layout. Use it for new code.

Fix 5: RuntimeError: main thread is not in main loop

RuntimeError: main thread is not in main loop
RuntimeError: Tcl_AsyncDelete: async handler deleted by the wrong thread

Matplotlib’s GUI backends (Tk, Qt, wx) must run on the main thread. Calling plt.show() from a background thread, a web server worker, or any threaded context crashes.

Fix 1 — use the Agg backend (no GUI, file output only):

import matplotlib
matplotlib.use('Agg')   # Before importing pyplot
import matplotlib.pyplot as plt

def generate_plot_in_thread(data):
    fig, ax = plt.subplots()
    ax.plot(data)
    fig.savefig("result.png")
    plt.close(fig)   # Always close in threaded contexts

Fix 2 — use Figure directly without pyplot (cleaner for server/thread contexts):

from matplotlib.figure import Figure
from matplotlib.backends.backend_agg import FigureCanvasAgg
import numpy as np

def create_plot(data: list) -> bytes:
    """Generate plot in any thread, return PNG bytes."""
    fig = Figure(figsize=(8, 5))
    canvas = FigureCanvasAgg(fig)
    ax = fig.add_subplot(111)
    
    ax.plot(data, color='steelblue', linewidth=2)
    ax.set_title("Signal")
    ax.grid(True, alpha=0.3)
    
    fig.tight_layout()
    canvas.draw()
    
    import io
    buf = io.BytesIO()
    fig.savefig(buf, format='png', dpi=150)
    buf.seek(0)
    return buf.read()   # PNG bytes — serve directly in HTTP response

# In a FastAPI or Flask endpoint:
# return Response(content=create_plot(data), media_type="image/png")

This approach uses the Agg renderer directly without touching the pyplot state machine — completely thread-safe.

Fix 3 — for Flask/FastAPI: generate the plot per-request and return it as bytes. Never store matplotlib figures as module-level state between requests.

Fix 6: Axes Configuration — Ticks, Scales, and Formatting

Log scale — both axes or one:

import matplotlib.pyplot as plt
import numpy as np

fig, axes = plt.subplots(1, 3, figsize=(15, 4))

x = np.logspace(0, 4, 100)
y = x ** 2

axes[0].plot(x, y)
axes[0].set_title("Linear scale")

axes[1].semilogy(x, y)      # Log y-axis
axes[1].set_title("Log y")

axes[2].loglog(x, y)        # Log both axes
axes[2].set_title("Log-log")

fig.tight_layout()

Date formatting on x-axis:

import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
import numpy as np

dates = pd.date_range("2024-01-01", periods=12, freq="MS")
values = np.random.rand(12) * 100

fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(dates, values, marker='o', linewidth=2)

ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
ax.xaxis.set_major_locator(mdates.MonthLocator())
fig.autofmt_xdate(rotation=45)   # Rotate labels to avoid overlap

ax.set_title("Monthly Revenue")
ax.set_ylabel("Revenue ($K)")
fig.tight_layout()

Custom tick labels:

import matplotlib.pyplot as plt
import numpy as np

categories = ['Q1 2023', 'Q2 2023', 'Q3 2023', 'Q4 2023', 'Q1 2024']
values = [42, 55, 48, 67, 71]

fig, ax = plt.subplots(figsize=(10, 5))
bars = ax.bar(range(len(categories)), values, color='steelblue', edgecolor='white')

ax.set_xticks(range(len(categories)))
ax.set_xticklabels(categories, rotation=30, ha='right')

# Add value labels on top of bars
for bar, val in zip(bars, values):
    ax.text(
        bar.get_x() + bar.get_width() / 2,
        bar.get_height() + 0.5,
        str(val),
        ha='center', va='bottom', fontsize=10
    )

ax.set_ylabel("Revenue ($M)")
ax.set_title("Quarterly Revenue")
ax.spines[['top', 'right']].set_visible(False)   # Clean look
fig.tight_layout()

Fix 7: Colormaps, Legends, and Colorbars

Scatter plot with colormap and colorbar:

import matplotlib.pyplot as plt
import numpy as np

np.random.seed(42)
x = np.random.randn(500)
y = np.random.randn(500)
z = np.sqrt(x**2 + y**2)   # Color by distance from origin

fig, ax = plt.subplots(figsize=(8, 6))
scatter = ax.scatter(x, y, c=z, cmap='viridis', alpha=0.7, s=30)

cbar = fig.colorbar(scatter, ax=ax)
cbar.set_label("Distance from origin", fontsize=12)

ax.set_title("Scatter with Colormap")
ax.set_xlabel("X")
ax.set_ylabel("Y")
fig.tight_layout()

Legend outside the axes — use bbox_to_anchor:

import matplotlib.pyplot as plt
import numpy as np

fig, ax = plt.subplots(figsize=(10, 6))

x = np.linspace(0, 10, 100)
for i in range(5):
    ax.plot(x, np.sin(x + i), label=f"Model {i+1} (seed={i})")

# Place legend outside the plot area to avoid covering data
ax.legend(
    loc='upper left',
    bbox_to_anchor=(1, 1),   # (x, y) relative to axes — 1,1 = top-right outside
    borderaxespad=0,
)
fig.tight_layout(rect=[0, 0, 0.85, 1])   # Leave 15% on right for legend
# Or: fig.savefig("plot.png", bbox_inches='tight') — includes the legend

Common Mistake: Saving a figure with a legend outside the axes using fig.savefig("plot.png") without bbox_inches='tight' clips the legend. Always use bbox_inches='tight' when the legend or any label extends beyond the axes boundary.

Fix 8: Style, Theme, and Appearance

Using built-in styles:

import matplotlib.pyplot as plt
import numpy as np

# List available styles
print(plt.style.available)

# Apply a style
with plt.style.context('seaborn-v0_8-darkgrid'):   # Note: 'seaborn-v0_8' prefix in matplotlib 3.6+
    fig, ax = plt.subplots()
    ax.plot([1, 2, 3, 4], [1, 4, 2, 3])
    fig.savefig("styled.png")

# Apply globally for the session
plt.style.use('ggplot')
plt.style.use('default')   # Reset to default

Note: In Matplotlib 3.6+, the seaborn-* style names were renamed to seaborn-v0_8-* to reflect that they ship the v0.8 Seaborn aesthetics regardless of whether Seaborn is installed. If you get a style not found error, add the v0_8 prefix.

Custom rcParams for consistent styling across all plots in a script:

import matplotlib.pyplot as plt

plt.rcParams.update({
    'figure.figsize': (10, 6),
    'figure.dpi': 150,
    'axes.titlesize': 14,
    'axes.labelsize': 12,
    'xtick.labelsize': 10,
    'ytick.labelsize': 10,
    'font.family': 'sans-serif',
    'axes.spines.top': False,
    'axes.spines.right': False,
    'axes.grid': True,
    'grid.alpha': 0.3,
})

# All subsequent plots use these settings
fig, ax = plt.subplots()
ax.plot([1, 2, 3])

High-DPI figures for publications:

fig.savefig(
    "figure_1.pdf",       # PDF for vector graphics (no DPI limit)
    bbox_inches='tight',
    format='pdf',
)

fig.savefig(
    "figure_1.png",       # PNG for raster graphics
    dpi=300,              # 300 DPI = publication quality
    bbox_inches='tight',
)

Still Not Working?

Memory Leak in Long-Running Scripts

Matplotlib figures accumulate in memory if you don’t close them. In scripts that create hundreds of figures (batch processing, hyperparameter sweeps), always close each figure after saving:

# Garbage-collect all figures
plt.close('all')   # Close every open figure

# Close a specific figure
plt.close(fig)

# Check how many figures are currently open
print(plt.get_fignums())   # List of open figure numbers

A warning appears at 20 open figures:

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.

Rendering in Jupyter vs. Script Behaves Differently

Code that works in Jupyter may not work in a script because Jupyter uses the inline backend (which auto-displays figures when a cell ends) while scripts don’t. The fix is to always save with fig.savefig() in scripts, and use plt.show() only when you need interactive display. For Jupyter-specific display issues like %matplotlib inline vs %matplotlib widget, see Jupyter not working.

Plots With NumPy and Pandas Data

Matplotlib integrates directly with NumPy arrays and Pandas DataFrames. When shapes don’t match — trying to plot a 2D array where a 1D array is expected — you get a ValueError from Matplotlib. For NumPy shape and broadcasting issues that manifest as plotting errors, see NumPy not working. For Pandas DataFrame slicing that produces unexpected shapes before plotting, see pandas SettingWithCopyWarning.

Using Matplotlib in Streamlit

In Streamlit, use st.pyplot(fig) to display a Matplotlib figure — not plt.show(). Passing fig explicitly is required in Streamlit 1.21+ to avoid the deprecation warning:

import streamlit as st
import matplotlib.pyplot as plt

fig, ax = plt.subplots()
ax.plot([1, 2, 3], [4, 5, 6])
ax.set_title("My Plot")
st.pyplot(fig)   # Pass the figure explicitly
plt.close(fig)   # Close to free memory

For Streamlit caching of Matplotlib figures and other display patterns, see Streamlit not working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles