Skip to content

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

FixDevs ·

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.

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 pandas data manipulation and reshape patterns needed before plotting, see pandas SettingWithCopyWarning and Seaborn not working for similar long-format requirements.

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"
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