Skip to content

Serialize figures without deep copying them, add a msgspec engine - #5813

Open
T4rk1n wants to merge 4 commits into
mainfrom
perf/json-skip-deepcopy
Open

T4rk1n wants to merge 4 commits into
mainfrom
perf/json-skip-deepcopy

Conversation

@T4rk1n

@T4rk1n T4rk1n commented Oct 14, 2026 •

Copy link
Copy Markdown

Link to issue

Reported in plotly/dash#4065. The Dash side is plotly/dash#4067.

Description of change

Faster JSON for figures, on every engine:

  • to_json_plotly and PlotlyJSONEncoder no longer deep copy figures. BaseFigure._to_json_dict() and BasePlotlyType._to_json_dict() return the object's data without the deepcopy that to_dict() / to_plotly_json() do. Typed arrays are converted with a new to_base64_copy, which copies only the containers it changes, so the figure is never mutated. Subclasses that override to_dict or to_plotly_json still go through their override.
  • The orjson path passes default= to orjson.dumps, so one non-native value (a go.Figure, a pd.Timestamp) no longer sends the whole payload through the Python walk. Anything that still fails takes the old path, so errors are unchanged.
  • PlotlyJSONEncoder.default uses _to_json_dict and returns isoformat() for plain datetime / date. Subclasses keep the old method order, so their encode_as_* overrides still apply.
  • New "msgspec" engine. "auto" now picks orjson, then msgspec, then json. Dash installs msgspec, so Dash users without orjson get most of the orjson speedup. Values msgspec can't take natively go through an enc_hook, and anything it fails on goes to the json engine, so errors match the json engine.

Demo

10 traces x 20k points, to_json_plotly(go.Figure), median ms:

data orjson before orjson after msgspec (new) json before json after
numpy array 5.3 5.0 7.4 13.2 12.8
list of float 36 6.0 13.9 82 53
pd.DatetimeIndex 11.6 11.0 46.7 75 71
list of datetime 179 10.2 17.6 496 125

A dict holding a go.Figure and a pd.Timestamp gives the same numbers as the figure alone after the change. Before the change, orjson took 61 ms for list of float and 304 ms for list of datetime on that payload.

Output

The orjson and json engines give byte-identical output to main on px figures, frames, templates, geojson, tuples, numpy/pandas/datetime/Decimal/NaN values, and mixed dicts, pretty and compact. Known differences, with orjson only:

  • A raw numpy datetime64 array sitting next to a value orjson can't serialize now uses orjson's form (2024-01-01T05:00:00) instead of datetime_as_string (2024-01-01T05:00:00.000000000). That is the same output you already get when the array is on its own.
  • Some payloads that used to raise TypeError: Type is not JSON serializable: NaTType now serialize, as they already did with the json engine.

I compared the msgspec engine with the json engine on about 250 values, each nested, top level and in a list, pretty and compact. Every figure, graph object, numpy, pandas and PIL case matches. The differences:

  • Non-ASCII text is written raw instead of \u escaped. It decodes to the same JSON, as with orjson.
  • Timezone-aware UTC datetime / time values are written as Z instead of +00:00. Offsets with a seconds part are truncated to minutes. plotly.js accepts Z and ignores the offset.
  • A Decimal is written exactly, not rounded to a float. A Decimal past the float range (1E+400) is written as is, where json writes null.
  • True/False dict keys are written as "1"/"0", and NaN/inf float keys as "nan"/"inf".
  • Sets, frozensets, bytes, bytearray, memoryview, timedelta, np.timedelta64, UUID, Enum, dataclasses and date/UUID/numpy-int dict keys serialize where json raised. orjson already accepts several of these.

msgspec writes non-finite Decimals and Decimal dict keys as invalid JSON. The engine checks the output's syntax (decoding to msgspec.Raw, about 1 ms per 8 MB) and falls back to the json engine when it is invalid. from_json_plotly(engine="msgspec") falls back to json.loads for the NaN and Infinity literals msgspec rejects.

Testing strategy

  • test_figure_is_not_deep_copied (all engines) fails serialization if deepcopy is called, checks the output against to_dict(), and checks that the figure's numpy arrays are left as they were.
  • test_to_base64_copy_leaves_input_untouched covers the copy-on-write helper.
  • test_subclass_overrides_are_kept and test_encoder_subclass_overrides_are_kept cover subclasses that override to_dict / to_plotly_json and the encoder's encode_as_* methods.
  • msgspec is added to the engines in test_to_from_plotly_json.py, so every existing exact-output test runs on it.
  • New tests cover auto picking msgspec without orjson, default_engine = "msgspec", datetime Series with NaT, non-finite Decimals, circular values, Decimal keys, and reading NaN literals.
  • msgspec is added to dev_optional, with the matching uv.lock entries (0.18.6 on Python 3.8, 0.20 on 3.9, 0.22 on 3.10+). The JSON tests pass with both 0.18.6 and 0.22.

tests/test_io, tests/test_core, tests/test_plotly_utils and tests/test_optional pass locally, apart from failures that also happen on main here (test_trendline_on_timeseries[pyarrow-*] and a colorscale test that depends on test order).

Guidelines

@T4rk1n T4rk1n changed the title Serialize figures without deep copying them Serialize figures without deep copying them, add a msgspec engine Oct 15, 2026

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants