Skip to content

[BUG]: Timezone-aware pandas DatetimeIndex as x makes figure creation and to_json() ~30x slower #5864

Open

Description

@stewit

Description

Plotting a timeseries the usual way, go.Scatter(x=series.index, y=series), is very slow if the index is a
timezone-aware pd.DatetimeIndex. For 100,000 points, go.Figure(...) plus fig.to_json() takes about 2.3 s.
With the same timestamps as a timezone-aware pd.Series, or as a timezone-naive DatetimeIndex, it takes
about 0.05 to 0.08 s.

Timezone-aware indexes are common: data from databases, APIs or pd.to_datetime(..., utc=True) usually comes with
datetime64[ns, UTC] timestamps, and series.index is what most code passes as x.

#4250 reported that a pandas.Index slows down figure creation. It was closed after the switch to narwhals in
plotly 6, based on measurements with a timezone-naive index. The reporter had mentioned timezone-aware
indexes, but that case was not measured again. It is still slow in plotly 6.9.0 and 7.1.0.

Screenshots/Video

Not applicable, this is about performance.

Steps to reproduce

Run this script:

import time

import numpy as np
import pandas as pd
import plotly
import plotly.graph_objects as go
import plotly.io as pio

n = 100_000
index = pd.date_range("2024-01-01", periods=n, freq="min", tz="Europe/Berlin")
y = np.random.default_rng(0).standard_normal(n)


def bench(label, x):
    t0 = time.perf_counter()
    fig = go.Figure(go.Scatter(x=x, y=y))
    t1 = time.perf_counter()
    fig.to_json()
    t2 = time.perf_counter()
    print(f"{label:<24} x dtype: {str(fig.data[0].x.dtype):<15} Figure(): {t1 - t0:6.3f} s   to_json(): {t2 - t1:6.3f} s")


print("plotly", plotly.__version__, "| pandas", pd.__version__, "| json engine", pio.json.config.default_engine)
bench("tz-naive DatetimeIndex", index.tz_localize(None))
bench("tz-aware Series", index.to_series())
bench("tz-aware DatetimeIndex", index)

Output (Python 3.14, Linux):

plotly 6.9.0 | pandas 3.1.0 | json engine auto
tz-naive DatetimeIndex   x dtype: datetime64[us]  Figure():  0.035 s   to_json():  0.042 s
tz-aware Series          x dtype: datetime64[us]  Figure():  0.008 s   to_json():  0.039 s
tz-aware DatetimeIndex   x dtype: object          Figure():  0.902 s   to_json():  1.363 s

plotly 7.1.0 | pandas 3.1.0 | json engine auto
tz-naive DatetimeIndex   x dtype: datetime64[us]  Figure():  0.033 s   to_json():  0.042 s
tz-aware Series          x dtype: datetime64[us]  Figure():  0.008 s   to_json():  0.038 s
tz-aware DatetimeIndex   x dtype: object          Figure():  0.894 s   to_json():  1.372 s

It is the same with pandas 2.3.3 (0.42 s + 0.72 s for the timezone-aware index), and with orjson installed
(0.91 s + 1.01 s).

Notes

Cause

copy_to_readonly_numpy_array in _plotly_utils/basevalidators.py converts the values with narwhals.
For a timezone-aware datetime Series, it calls replace_time_zone(None) ("so that local time is displayed")
and gets a datetime64 array. A pd.Index is not a narwhals Series, so nw.from_native(..., pass_through=True)
returns it unchanged, and np.array(v) then gives an object array of pd.Timestamp.

That object array is handled one element at a time:

  • copy.deepcopy copies every Timestamp in BaseFigure.__init__ and again in to_dict(), which to_json() calls.
  • PlotlyJSONEncoder.default encodes every Timestamp, going through encode_as_pandas, encode_as_numpy and
    encode_as_datetime for each one.

Expected behavior

A timezone-aware DatetimeIndex should be as fast as a timezone-aware Series.

The plot would not change. Today the index is serialized as strings with UTC offset, like
"2024-01-01T00:00:00+01:00". plotly.js ignores the offset and shows the wall-clock time, which is exactly what the
Series path produces with replace_time_zone(None). Only the JSON would change, losing the offset suffix.

Possible fix

In copy_to_readonly_numpy_array, handle a timezone-aware pandas DatetimeIndex like the Series path does:

pd = get_module("pandas", should_load=False)
if pd is not None and isinstance(v, pd.DatetimeIndex) and v.tz is not None:
    # Remove time zone so that local time is displayed, like for Series
    v = v.tz_localize(None)

Placed directly before nw.from_native(...), this brings the timezone-aware index in the example above down to
0.002 s for Figure() and 0.038 s for to_json(). Alternatively, convert any pandas Index to a Series before
nw.from_native, so that indexes always take the same path as Series.

Workaround until then: pass x=series.index.tz_localize(None) or x=series.index.to_series().

Related

Disclaimer

Claude Code helped in finding, analyzing, reproducing and in writting this issue. I personally checked the reproduction code and will answer personally to questions!

Activity

  1. MarcoGorelli commented on Nov 2, 2026

    @MarcoGorelli
    Contributor

    thanks for the report! yup, object arrays are to be avoided at all costs

    it's true that Narwhals doesn't have special handling for Index objects, so your suggested fix looks reasonable. Regarding the comment though, I would suggest something like "# Remove time zone so that local time is displayed, so that converting to numpy doesn't result in object dtype"

  2. KoolADE85 commented on Nov 2, 2026

    @KoolADE85
    Contributor

    Thanks for reporting this. The team will take a look and follow up.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions