Skip to content

v1.3 serialization regression #465

Description

@awiol

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

  1. awiol commented on Jul 5, 2026

    @awiol
    Author

    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.

  2. awiol commented on Jul 7, 2026

    @awiol
    Author

    Plotly has plotly.io.json.clean_to_json_compatible method ( 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 kaleido which will use serialization the way it wants to do it?

  3. changed the title [-]v1 lost ability to export pandas.Timestamp[/-] [+]v1.3 serialization regression[/+] on Jul 7, 2026
  4. robertclaus commented on Jul 8, 2026

    @robertclaus

    @camdecoster will #461 fix this as well?

  5. awiol commented on Jul 9, 2026

    @awiol
    Author

    @camdecoster will #461 fix this as well?

    It will in the literal sense of handling pandas.Timestamp. But larger issue is that many other cases that were handled by plotly.io.json.clean_to_json_compatible (and thus by plotly.io.json.to_json) are still going to be missing.

    Besides, calling .isoformat() on individual objects is likely less effective then strategy used inside plotly.io.json.clean_to_json_compatible.

    I ask to treat this not as issue about just pandas.Timestamp, but address missing serialization/cleaning logic.

  6. camdecoster commented on Jul 21, 2026

    @camdecoster
    Contributor

    @awiol you're right that there's a regression. In #442, preserializing with orjson only 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.

  7. robertclaus commented on Jul 23, 2026

    @robertclaus

    We're looking at a larger change to move the work from #442 into Choreographer. Doing that should fix the serialization issue here.

  8. camdecoster commented on Oct 6, 2026

    @camdecoster
    Contributor

    @awiol could you try v1.5.0 and let me know if that fixes your issues?

  9. awiol commented on Oct 7, 2026

    @awiol
    Author

    Thanks! My checks report it's good now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1needs immediate attentionbugsomething brokensize: 5

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions