Fix: Matplotlib Not Working — Plots Not Showing, Blank Output, and Figure Layout Problems
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 blankOr the layout looks wrong — titles cut off, labels overlapping the axes, subplots squashed together:
UserWarning: tight_layout: falling back to Axes positioning using bbox_artistOr Matplotlib crashes the moment you import it in a thread or server process:
RuntimeError: main thread is not in main loopMatplotlib 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 happensIn 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 windowIn 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 variableThis 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 neededOr set it via environment variable before the script starts:
export MPLBACKEND=Agg
python train.pyAvailable backends:
| Backend | Use case |
|---|---|
Agg | Headless/server — PNG output only, no GUI |
TkAgg | Tkinter GUI (default on many Linux systems) |
Qt5Agg / Qt6Agg | Qt GUI — requires PyQt5/PyQt6 |
MacOSX | Native macOS GUI |
WebAgg | Browser-based interactive |
inline | Jupyter Notebook (set via %matplotlib inline) |
widget | JupyterLab interactive (requires ipympl) |
Check and set the current backend:
import matplotlib
print(matplotlib.get_backend()) # See current backend
matplotlib.use('Agg') # Change before pyplot importFix 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 statebbox_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 loopsWithout 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_artistSubplots 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 suptitlegridspec 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 automaticallyconstrained_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 threadMatplotlib’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 contextsFix 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 legendCommon 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 defaultNote: 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 numbersA 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 memoryFor Streamlit caching of Matplotlib figures and other display patterns, see Streamlit not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Jupyter Notebook Not Working — Kernel Dead, Module Not Found, and Widget Errors
How to fix Jupyter errors — kernel fails to start or dies, ModuleNotFoundError despite pip install, matplotlib plots not showing, ipywidgets not rendering in JupyterLab, port already in use, and jupyter command not found.
Fix: LightGBM Not Working — Installation Errors, Categorical Features, and Training Issues
How to fix LightGBM errors — ImportError libomp libgomp not found, do not support special JSON characters in feature name, categorical feature index out of range, num_leaves vs max_depth overfitting, early stopping callback changes, and GPU build errors.
Fix: NumPy Not Working — Broadcasting Error, dtype Mismatch, and Array Shape Problems
How to fix NumPy errors — ValueError operands could not be broadcast together, setting an array element with a sequence, integer overflow, axis confusion, view vs copy bugs, NaN handling, and NumPy 1.24+ removed type aliases.
Fix: Polars Not Working — AttributeError, InvalidOperationError, and ShapeError
How to fix Polars errors — AttributeError groupby not found, InvalidOperationError from Python lambdas, ShapeError broadcasting mismatch, lazy vs eager collect confusion, type casting failures, and ColumnNotFoundError in with_columns.