Repository navigation
v1.3 serialization regression #465
Description
TL;DR
Static image export regressed in Kaleido 1.3.0 for Plotly figures containing scalar non-JSON-native objects that Plotly’s PlotlyJSONEncoder can serialize, such as pandas.Timestamp. The raw figure spec may legitimately contain such objects after fig.to_dict(). Plotly’s JSON encoder accepts them, but Kaleido 1.3.0 serializes the spec with orjson.dumps(...) and a narrow fallback that only handles .tolist(). This breaks fig.to_image() / fig.write_image() for figures that worked with Plotly 5 + Kaleido 0.2.1.
Related: appears that same root cause is responsible for #453
I have been using Plotly+Kaleido for image export quite some time. And after migrating to Plotly 6 recently (skipped earlier minor versions), I noticed that some plots are not getting exported into image format, causing this error:
.../lib/python3.13/site-packages/kaleido/_kaleido_tab/_tab.py", line 151, in _calc_fig
spec_str = orjson.dumps(
~~~~~~~~~~~~^
spec,
^^^^^
default=_orjson_default,
^^^^^^^^^^^^^^^^^^^^^^^^
option=orjson.OPT_SERIALIZE_NUMPY,
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
).decode()
^
TypeError: Type is not JSON serializable: Timestamp
Despite json type error being as always not very informative, I figured the only candidate is pandas.Timestamp that are present in the dataframe I feed to Plotly express.
So I went onto a bit of a journey, with some help of LLM (disclaimer: I ran all the code it gave and reviewed what it said).
Here's a code that tries a few different Figures to reveal possible rootcause:
import json
import sys
from importlib.metadata import PackageNotFoundError, version
from typing import Any
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from plotly.utils import PlotlyJSONEncoder
JsonPath = tuple[str | int, ...]
def package_version(package_name: str) -> str:
"""Return the installed package version, or '<not installed>'."""
try:
return version(package_name)
except PackageNotFoundError:
return "<not installed>"
def print_versions() -> None:
"""Print the interpreter and relevant package versions."""
versions = {
"python": sys.version.split()[0],
"plotly": package_version("plotly"),
"kaleido": package_version("kaleido"),
"orjson": package_version("orjson"),
"pandas": package_version("pandas"),
}
print("versions:", versions)
def timestamp_paths(obj: Any, path: JsonPath = ()) -> list[JsonPath]:
"""Return paths where pandas Timestamp objects occur inside a nested object."""
if isinstance(obj, pd.Timestamp):
return [path]
if isinstance(obj, dict):
paths: list[JsonPath] = []
for key, value in obj.items():
paths.extend(timestamp_paths(value, (*path, key)))
return paths
if isinstance(obj, list | tuple):
paths = []
for index, value in enumerate(obj):
paths.extend(timestamp_paths(value, (*path, index)))
return paths
return []
def probe_orjson_if_available(spec: dict[str, Any]) -> str:
"""Probe Kaleido-1.3-style orjson serialization if orjson is installed."""
try:
import orjson
except ImportError:
return "SKIP: orjson is not installed"
def default(obj: Any) -> Any:
if hasattr(obj, "tolist"):
return obj.tolist()
raise TypeError(f"Type is not JSON serializable: {type(obj).__name__}")
try:
orjson.dumps(
spec,
default=default,
option=orjson.OPT_SERIALIZE_NUMPY,
)
except Exception as exc:
return f"FAIL: {type(exc).__name__}: {exc}"
return "OK"
def probe_figure(name: str, fig: go.Figure) -> None:
"""Print serialization and image-export diagnostics for one figure."""
spec = fig.to_dict()
print(f"\n{name}")
print("-" * len(name))
paths = timestamp_paths(spec)
print("pd.Timestamp paths:", paths or "<none>")
try:
json.dumps(spec, cls=PlotlyJSONEncoder)
except Exception as exc:
print(f"PlotlyJSONEncoder: FAIL: {type(exc).__name__}: {exc}")
else:
print("PlotlyJSONEncoder: OK")
print(f"Kaleido-1.3-style orjson: {probe_orjson_if_available(spec)}")
try:
fig.to_image(format="svg")
except Exception as exc:
print(f"fig.to_image(svg): FAIL: {type(exc).__name__}: {exc}")
else:
print("fig.to_image(svg): OK")
def main() -> None:
"""Run Plotly/Kaleido Timestamp serialization probes."""
print_versions()
probe_figure(
"scalar Timestamp in trace list",
go.Figure(
go.Scatter(
x=[pd.Timestamp("2024-01-01")],
y=[1],
)
),
)
frame = pd.DataFrame(
{
"time": pd.to_datetime(["2024-01-01", "2024-01-02"]),
"value": [1, 2],
}
)
probe_figure(
"datetime64 DataFrame column via Plotly Express",
px.line(frame, x="time", y="value"),
)
fig = px.line(frame, x="time", y="value")
fig.add_vline(x=pd.Timestamp("2024-01-02"))
probe_figure(
"scalar Timestamp in layout shape",
fig,
)
if __name__ == "__main__":
main()
I have two environments, one still on Plotly 5, and a newer one with Plotly 6 and orjson, below is output of the script from running in them:
versions: {'python': '3.13.2', 'plotly': '5.24.1', 'kaleido': '0.2.1', 'orjson': '<not installed>', 'pandas': '2.2.3'}
scalar Timestamp in trace list
------------------------------
pd.Timestamp paths: [('data', 0, 'x', 0)]
PlotlyJSONEncoder: OK
Kaleido-1.3-style orjson: SKIP: orjson is not installed
fig.to_image(svg): OK
datetime64 DataFrame column via Plotly Express
----------------------------------------------
pd.Timestamp paths: <none>
PlotlyJSONEncoder: OK
Kaleido-1.3-style orjson: SKIP: orjson is not installed
fig.to_image(svg): OK
scalar Timestamp in layout shape
--------------------------------
pd.Timestamp paths: [('layout', 'shapes', 0, 'x0'), ('layout', 'shapes', 0, 'x1')]
PlotlyJSONEncoder: OK
Kaleido-1.3-style orjson: SKIP: orjson is not installed
fig.to_image(svg): OK
and
versions: {'python': '3.13.13', 'plotly': '6.8.0', 'kaleido': '1.3.0', 'orjson': '3.11.9', 'pandas': '2.3.3'}
scalar Timestamp in trace list
------------------------------
pd.Timestamp paths: [('data', 0, 'x', 0)]
PlotlyJSONEncoder: OK
Kaleido-1.3-style orjson: FAIL: TypeError: Type is not JSON serializable: Timestamp
fig.to_image(svg): FAIL: TypeError: Type is not JSON serializable: Timestamp
datetime64 DataFrame column via Plotly Express
----------------------------------------------
pd.Timestamp paths: <none>
PlotlyJSONEncoder: OK
Kaleido-1.3-style orjson: OK
fig.to_image(svg): OK
scalar Timestamp in layout shape
--------------------------------
pd.Timestamp paths: [('layout', 'shapes', 0, 'x0'), ('layout', 'shapes', 0, 'x1')]
PlotlyJSONEncoder: OK
Kaleido-1.3-style orjson: FAIL: TypeError: Type is not JSON serializable: Timestamp
fig.to_image(svg): FAIL: TypeError: Type is not JSON serializable: Timestamp
Despite this being result of interplay between Plotly, kaleido and orjson, it seems most reasonable to create issue here.
Yes, I am aware that a the end of day, encountering this can be avoided on user side by tending to DataFrame or cleaning Figure before export.
Activity
awiol commentedon Jul 5, 2026
It appears that most straightforward fix would be to include pandas.Timestamp in the default function here
kaleido._kaleido_tab._tab._orjson_default( https://github.com/plotly/Kaleido/blob/master/src/py/kaleido/_kaleido_tab/_tab.py#L29 ), and include it in some tests.But I wonder how this layer of compatibility got lost.
awiol commentedon Jul 7, 2026
Plotly has
plotly.io.json.clean_to_json_compatiblemethod ( https://github.com/plotly/plotly.py/blob/main/plotly/io/_json.py#L478 ) that should handle all edgecases, without needing to duplicate logic inside_orjson_default.Or perhaps plotly should clean object before passing it to
kaleidowhich will use serialization the way it wants to do it?[-]v1 lost ability to export pandas.Timestamp[/-][+]v1.3 serialization regression[/+]robertclaus commentedon Jul 8, 2026
@camdecoster will #461 fix this as well?
awiol commentedon Jul 9, 2026
It will in the literal sense of handling
pandas.Timestamp. But larger issue is that many other cases that were handled byplotly.io.json.clean_to_json_compatible(and thus byplotly.io.json.to_json) are still going to be missing.Besides, calling
.isoformat()on individual objects is likely less effective then strategy used insideplotly.io.json.clean_to_json_compatible.I ask to treat this not as issue about just
pandas.Timestamp, but address missing serialization/cleaning logic.camdecoster commentedon Jul 21, 2026
@awiol you're right that there's a regression. In #442, preserializing with
orjsononly had the unintended consequence of removing the plotly.py cleaning path that had been used previously. I just merged #461 as an interim solution, but we'll need to rework things to fix the regression.robertclaus commentedon Jul 23, 2026
We're looking at a larger change to move the work from #442 into Choreographer. Doing that should fix the serialization issue here.
camdecoster commentedon Oct 6, 2026
@awiol could you try v1.5.0 and let me know if that fixes your issues?
awiol commentedon Oct 7, 2026
Thanks! My checks report it's good now.