Skip to content

Fix: Plotly Not Working — Figure Not Showing, Kaleido Export Errors, and Subplot Issues

FixDevs · (Updated: )

Part of:  Python Errors

Quick Answer

How to fix Plotly errors — figure not rendering in Jupyter, ValueError must install Kaleido, static image export slow or crashing, subplot trace placement, update_layout not working, and offline vs online mode confusion.

The Error

You run fig.show() in Jupyter and the plot doesn’t appear — just a blank output or raw HTML:

import plotly.express as px
fig = px.scatter(df, x="x", y="y")
fig.show()
# Nothing visible. Or raw HTML dumped to output.

Or you try to save a figure as PNG and Plotly complains about Kaleido:

ValueError: 
Image export using the "kaleido" engine requires the kaleido package, 
which can be installed using pip:
    $ pip install -U kaleido

Or the PNG export takes 30+ seconds or crashes with a Chrome error:

ValueError: Transform failed with error code 525: Input/output error
RuntimeError: Failed to create transport

Or you add traces to subplots and they all end up in the first panel:

fig = make_subplots(rows=1, cols=2)
fig.add_trace(go.Scatter(...))   # Goes to row=1, col=1 by default
fig.add_trace(go.Scatter(...))   # Also goes to row=1, col=1!

Plotly is a JavaScript-based library with Python bindings — figures are HTML/JavaScript that render in a browser, not raw image files. The static export path (PNG/PDF) uses a separate engine (Kaleido) that spawns headless Chrome. Both the rendering and export paths have distinct failure modes.

Why This Happens

Plotly figures are JSON specifications that a JavaScript renderer converts to SVG in the browser. When you call fig.show(), Plotly either renders inline in Jupyter (via the notebook’s JavaScript engine) or opens a browser tab. Neither works in plain Python scripts without extra setup. Static image export requires Kaleido, which downloads a headless Chrome binary — on restricted networks or in minimal Docker images, this fails silently.

Subplots use a different API than single figures. Traces must explicitly specify which row and column they belong to, or they all stack in the first subplot.

Diagnostic Timeline: When fig.show() Returns Nothing

Your first instinct is to restart the kernel and re-run the cell. That almost never fixes it. Here is the path a senior dev walks instead.

Minute 0 — Confirm the figure object actually exists. Type fig on its own line and run it. If you get <plotly.graph_objs._figure.Figure object> instead of a chart, the figure is fine — the renderer is silent. If you get None, your data pipeline returned nothing and you have been debugging the wrong layer.

Minute 1 — Print the active renderer. Run import plotly.io as pio; print(pio.renderers.default). In JupyterLab 4, the default is often plotly_mimetype+notebook. In a fresh script it is browser. In VS Code notebooks it is vscode. If the renderer string does not match your environment, every fig.show() silently writes to a sink nobody is watching.

Minute 2 — Force a static fallback. Call fig.show("png"). If the PNG renders, the figure data is correct and the JavaScript renderer is the failure. Move to Fix 1. If even the PNG fails, the next layer up is broken — usually Kaleido (Fix 3) or a corrupt trace.

Minute 3 — Check for Dash hot-reload state. If you are inside a Dash app, app.run(debug=True) keeps the previous figure cached in the dev server. Edits to the callback do not redraw until the server actually reloads. Kill the process, restart, and reload the page.

Minute 5 — Check Kaleido for static export. If you are calling write_image instead of show, the issue is never the renderer — it is the Chrome binary Kaleido ships. python -c "import kaleido; print(kaleido.__version__)" should print a version. If it crashes with a Chrome libnss error, jump to Fix 3.

The first guess (“reload the notebook”) is wrong about nine times out of ten. The real culprit is one of: renderer not set, Dash dev-server caching the old figure, or Kaleido missing for the static export path.

Fix 1: Figure Not Showing in Jupyter

fig.show()
# Blank output or raw HTML string

Jupyter Notebook / JupyterLab — most common cause is missing JupyterLab extension:

pip install plotly>=5.0
pip install "jupyterlab>=3" "ipywidgets>=7.6"

JupyterLab 3+ auto-discovers Plotly. Earlier versions needed manual extension install:

# JupyterLab 2.x only
jupyter labextension install jupyterlab-plotly

Restart the JupyterLab server (not just the kernel) after installing. The browser frontend needs to reload.

Force a renderer explicitly:

import plotly.io as pio

# List available renderers
print(pio.renderers)
# ['notebook', 'jupyterlab', 'colab', 'browser', 'png', 'svg', 'iframe', ...]

# Set the default renderer
pio.renderers.default = "notebook"     # Classic Jupyter
pio.renderers.default = "jupyterlab"   # JupyterLab 3+
pio.renderers.default = "colab"        # Google Colab
pio.renderers.default = "browser"      # Opens default browser
pio.renderers.default = "iframe"       # Saves to file and loads iframe

In VS Code notebooks — install the Jupyter extension, which includes Plotly support:

# In VS Code, search for "Jupyter" extension and install
# Then restart VS Code

For Jupyter-specific display problems, see Jupyter not working.

Fix 2: Figure Not Showing in Scripts

Running a Python script with fig.show() opens the figure in a browser tab by default — but in headless environments (Docker, CI, SSH without X forwarding), this fails.

Solution 1: Save to HTML and open it manually:

import plotly.express as px

fig = px.scatter(df, x="x", y="y")

# Save as standalone HTML file
fig.write_html("plot.html")
# Open plot.html in a browser to view

Solution 2: Export to static image for servers/CI:

fig.write_image("plot.png")
fig.write_image("plot.pdf")
fig.write_image("plot.svg")

This requires Kaleido (covered in Fix 3).

Solution 3: In Jupyter via fig.show("png") — renders a static PNG inline, which works even without the JavaScript extension:

fig.show("png")   # Rasters the figure as PNG in the output cell

Pro Tip: When writing reusable functions that return figures, don’t call fig.show() inside them. Return the figure object and let the caller decide how to display it. This makes the same code work in Jupyter (where the caller can inline-render), in scripts (where they can save to HTML), and in Dash apps (where they assign figure=fig to dcc.Graph).

Fix 3: Kaleido Installation and Image Export

ValueError: Image export using the "kaleido" engine requires the kaleido package

Kaleido is Plotly’s static export engine — it uses a bundled headless Chrome to render figures to PNG/PDF/SVG.

Install Kaleido:

pip install -U kaleido

Basic export:

import plotly.express as px

fig = px.scatter(df, x="x", y="y")
fig.write_image("plot.png", width=1200, height=800, scale=2)
# scale=2 doubles the resolution (like Retina)

Format options:

fig.write_image("plot.png")   # Raster PNG
fig.write_image("plot.jpg")   # Raster JPEG
fig.write_image("plot.svg")   # Vector SVG
fig.write_image("plot.pdf")   # Vector PDF
fig.write_image("plot.eps")   # Vector EPS

# Export with specific dimensions and resolution
fig.write_image(
    "plot.png",
    width=1920,      # Pixels
    height=1080,
    scale=2,         # DPI multiplier
    engine="kaleido",
)

Kaleido hangs or crashes — common on Linux/Docker when Chrome dependencies are missing:

# Debian/Ubuntu — install Chrome dependencies
sudo apt update && sudo apt install -y \
    libgbm1 \
    libnss3 \
    libxkbcommon0 \
    libxcomposite1 \
    libxdamage1 \
    libxrandr2 \
    libgtk-3-0 \
    libasound2 \
    libx11-xcb1

# Docker — add to Dockerfile
RUN apt-get update && apt-get install -y \
    libnss3 libatk-bridge2.0-0 libxkbcommon0 libgbm1 \
    libxcomposite1 libxdamage1 libxrandr2 libgtk-3-0 libasound2 \
    && rm -rf /var/lib/apt/lists/*

Timeout errors on large figures:

# Kaleido has a 30-second default timeout; raise it for complex figures
import plotly.io as pio

pio.kaleido.scope.default_width = 1920
pio.kaleido.scope.default_height = 1080

# For v0.2+ of Kaleido
fig.write_image("plot.png", engine="kaleido", format="png")

Alternative: use Orca (the older engine) if Kaleido fails:

conda install -c plotly plotly-orca
fig.write_image("plot.png", engine="orca")

Orca is deprecated but still works. Prefer Kaleido for new code.

Fix 4: Subplot Traces Go to the Wrong Panel

from plotly.subplots import make_subplots
import plotly.graph_objects as go

fig = make_subplots(rows=1, cols=2)
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[1, 4, 2], name="A"))
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[3, 1, 4], name="B"))
# Both traces render in the first subplot!

add_trace requires explicit row/column arguments when the figure has subplots:

fig = make_subplots(rows=1, cols=2, subplot_titles=("Left Panel", "Right Panel"))

fig.add_trace(
    go.Scatter(x=[1, 2, 3], y=[1, 4, 2], name="A"),
    row=1, col=1,   # Explicit placement
)
fig.add_trace(
    go.Scatter(x=[1, 2, 3], y=[3, 1, 4], name="B"),
    row=1, col=2,   # Different panel
)

fig.update_layout(height=400, title_text="Two Panels")
fig.show()

Shared axes across subplots:

fig = make_subplots(
    rows=2, cols=2,
    shared_xaxes=True,
    shared_yaxes=True,
    vertical_spacing=0.1,
    horizontal_spacing=0.1,
    subplot_titles=("Q1", "Q2", "Q3", "Q4"),
)

Mixed plot types in subplots:

fig = make_subplots(
    rows=1, cols=2,
    specs=[[{"type": "scatter"}, {"type": "pie"}]],
)

fig.add_trace(go.Scatter(x=[1, 2, 3], y=[1, 4, 2]), row=1, col=1)
fig.add_trace(go.Pie(values=[30, 50, 20], labels=["A", "B", "C"]), row=1, col=2)

3D and specialized subplots need matching specs:

fig = make_subplots(
    rows=1, cols=2,
    specs=[[{"type": "scene"}, {"type": "scatter"}]],   # 3D on left
)

fig.add_trace(
    go.Scatter3d(x=[1,2,3], y=[1,2,3], z=[1,2,3]),
    row=1, col=1,
)

Updating subplot axis properties:

# Each subplot has its own axis — reference by position
fig.update_xaxes(title_text="Time", row=1, col=1)
fig.update_yaxes(title_text="Value", row=1, col=1)
fig.update_xaxes(type="log", row=1, col=2)   # Log scale on right panel only

Fix 5: update_layout vs update_traces

These do different things — confusing them causes “why isn’t my change showing up” issues.

update_layout — modifies figure-level properties (title, axes, legend, size):

fig.update_layout(
    title="My Chart",
    title_x=0.5,                   # Center title
    xaxis_title="X Axis",
    yaxis_title="Y Axis",
    width=1000,
    height=600,
    showlegend=True,
    legend=dict(x=0.01, y=0.99, bgcolor="rgba(255,255,255,0.8)"),
    margin=dict(l=50, r=50, t=80, b=50),
    paper_bgcolor="white",
    plot_bgcolor="rgba(240,240,240,0.5)",
    font=dict(family="Arial", size=14),
)

update_traces — modifies trace-level properties (line color, marker size, hover text):

fig.update_traces(
    marker=dict(size=10, color="steelblue", line=dict(width=1, color="DarkSlateGrey")),
    selector=dict(type="scatter"),   # Only apply to Scatter traces
)

fig.update_traces(
    line=dict(width=3),
    selector=dict(name="Series A"),   # Only for the trace named "Series A"
)

Selectors let you target specific traces:

# By type
fig.update_traces(marker_size=12, selector=dict(type="scatter"))

# By name
fig.update_traces(line_color="red", selector=dict(name="Predicted"))

# By mode
fig.update_traces(marker_symbol="diamond", selector=dict(mode="markers"))

Common Mistake: Using update_layout to change line colors or marker sizes. Those are trace properties — update_layout silently does nothing for them. Likewise, using update_traces to change the figure title doesn’t work — title is a layout property.

Fix 6: Plotly Express vs Graph Objects

# Plotly Express — high-level, DataFrame-oriented
import plotly.express as px
fig = px.scatter(df, x="total_bill", y="tip", color="smoker")

# Graph Objects — low-level, manual construction
import plotly.graph_objects as go
fig = go.Figure()
fig.add_trace(go.Scatter(x=df["total_bill"], y=df["tip"], mode="markers"))

When to use which:

Use Plotly ExpressUse Graph Objects
Quick explorationComplex custom layouts
Single DataFrame → standard chartCombining multiple unrelated traces
Need faceting (facet_col, facet_row)Need precise control over every trace
Want nice defaultsNeed mixed plot types

Converting Express figures to Graph Objects (Express figures are Graph Objects — you can modify them):

import plotly.express as px

# Create with Express
fig = px.scatter(df, x="x", y="y", color="category")

# Modify with Graph Objects methods
fig.add_hline(y=0, line_dash="dash", line_color="gray")
fig.update_traces(marker=dict(size=12, line=dict(width=2)))
fig.update_layout(template="plotly_dark")

Add reference lines and shapes:

import plotly.graph_objects as go

fig = px.scatter(df, x="x", y="y")

# Horizontal line
fig.add_hline(y=0, line_dash="dash", line_color="red", annotation_text="Baseline")

# Vertical line
fig.add_vline(x=5, line_color="blue")

# Rectangle region
fig.add_vrect(x0=2, x1=4, fillcolor="LightGreen", opacity=0.3, layer="below", line_width=0)

# Annotation
fig.add_annotation(
    x=5, y=10,
    text="Peak",
    showarrow=True,
    arrowhead=2,
    ax=30, ay=-30,
)

Fix 7: Hover Text, Tooltips, and Formatting

import plotly.express as px

fig = px.scatter(
    df,
    x="total_bill",
    y="tip",
    hover_data=["day", "time", "size"],   # Add these columns to hover
    hover_name="name",                      # Bold title in hover
    labels={"total_bill": "Bill ($)", "tip": "Tip ($)"},   # Rename labels
)

Custom hover templates:

import plotly.graph_objects as go

fig = go.Figure(data=go.Scatter(
    x=df["x"],
    y=df["y"],
    mode="markers",
    marker=dict(size=10, color=df["value"], colorscale="Viridis", showscale=True),
    customdata=df[["category", "region"]],
    hovertemplate=(
        "<b>%{customdata[0]}</b><br>"
        "Region: %{customdata[1]}<br>"
        "X: %{x:.2f}<br>"
        "Y: %{y:.2f}<br>"
        "<extra></extra>"   # Remove the trace name box
    ),
))

Format numbers, dates, percentages:

fig.update_layout(
    xaxis_tickformat=",",          # 1,000 instead of 1000
    yaxis_tickformat=".1%",        # 0.25 → 25.0%
    xaxis=dict(tickformat="%Y-%m-%d"),   # Date format
)

Fix 8: Exporting for Different Contexts

Standalone HTML — shareable file that works anywhere:

fig.write_html(
    "plot.html",
    include_plotlyjs="cdn",    # Load Plotly.js from CDN (smaller file)
    # include_plotlyjs=True,    # Bundle Plotly.js (~3MB but offline-capable)
    # include_plotlyjs="directory",  # Separate .js file for reuse
    full_html=True,             # Complete HTML document
    auto_open=False,
)

Embed in a web page:

# Just the <div> — paste into an existing HTML page
html_fragment = fig.to_html(
    include_plotlyjs=False,    # Assume Plotly.js is loaded elsewhere
    full_html=False,            # Only the div, no <html> wrapper
    div_id="my-plot-123",
)

JSON export for web API responses (Plotly.js consumes this directly):

import json

# To JSON string
plot_json = fig.to_json()

# To dict for FastAPI/Flask JSON response
plot_dict = fig.to_dict()
return {"plot": plot_dict}   # Frontend passes to Plotly.newPlot()

Still Not Working?

Dash Integration

When embedding Plotly in a Dash web app, the figure goes into a dcc.Graph component:

from dash import Dash, dcc, html
import plotly.express as px

app = Dash(__name__)

fig = px.scatter(df, x="x", y="y")

app.layout = html.Div([
    dcc.Graph(id="scatter", figure=fig)
])

For Dash-specific callback and layout issues, see Dash not working.

Streamlit Integration

Streamlit displays Plotly figures with st.plotly_chart:

import streamlit as st
import plotly.express as px

fig = px.scatter(df, x="x", y="y")
st.plotly_chart(fig, use_container_width=True)

For Streamlit-specific issues around figure caching and component state, see Streamlit not working.

Pandas DataFrame Integration

Plotly Express accepts a DataFrame directly and treats columns by name. Long-format DataFrames are preferred (like Seaborn):

fig = px.line(df_long, x="date", y="value", color="category", facet_col="region")

For long-format DataFrame requirements that mirror Plotly Express, see Seaborn not working.

Performance with Large Datasets

Plotly gets slow above ~50k points per trace. Strategies:

  • WebGL traces for scatter and line: go.Scattergl instead of go.Scatter (10x+ faster)
  • Downsample before plotting: df.sample(10000) for exploration
  • Aggregate: plot summary statistics instead of raw points for very large data
# WebGL scatter — handles 100k+ points smoothly
import plotly.graph_objects as go

fig = go.Figure(data=go.Scattergl(   # Note: Scattergl, not Scatter
    x=df["x"],
    y=df["y"],
    mode="markers",
    marker=dict(size=3, opacity=0.5),
))

Theme and Template System

Plotly includes several built-in templates. Apply globally or per-figure:

import plotly.io as pio

# Global default
pio.templates.default = "plotly_dark"   # or "plotly_white", "ggplot2", "seaborn", "simple_white"

# Per-figure override
fig.update_layout(template="plotly_white")

# List available templates
print(pio.templates)

Build a custom template once and reuse across reports:

import plotly.graph_objects as go

pio.templates["company_brand"] = go.layout.Template(
    layout=dict(
        font=dict(family="Inter, Arial", size=13, color="#333"),
        colorway=["#0066CC", "#FF6633", "#00AA66", "#FFAA00"],
        paper_bgcolor="white",
        plot_bgcolor="#F8F9FA",
    )
)
pio.templates.default = "company_brand"

Renderer Set Globally vs Per Figure

A common stumble: a teammate sets pio.renderers.default = "browser" in a shared module that gets imported indirectly. Every notebook downstream silently opens a new tab on fig.show() — or fails silently in Jupyter where browser launches are intercepted. Always check pio.renderers.default early in a new environment and override per-figure rather than mutating the global default in library code.

Static Export Differs from Interactive Render

A figure that looks correct in fig.show() can render incorrectly in fig.write_image(). The interactive renderer is Plotly.js running inside the browser at the viewport size. The static renderer is Kaleido at a fixed width/height (default 700x500). Long axis labels, legends, and annotations that fit the live viewport often clip in the export. Always pass width, height, and scale explicitly when exporting, and re-check label overflow at the export resolution.

Dash Hot-Reload Caching the Old Figure

In a Dash app under debug=True, edits to a callback do not always propagate. Dash’s dev server reuses Python-level closures until the file watcher fires. If your callback returns a figure built from a module-level helper, that helper is not re-imported on save — you must restart the process. The symptom is “my edit changed nothing” even though the page reloads. Kill python app.py and run it again before assuming the bug is in your code.

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