-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Description
Description
Graph object constructors (go.Scatter(), go.Bar(), etc.) and go.Figure operations (add_traces(), add_annotation(), add_vline(), etc.) are orders of magnitude slower than building equivalent plain dicts. This makes them impractical for performance-sensitive applications that create many traces, annotations, or shapes programmatically.
The proposal: add an _as_dict parameter (consistent with the existing _validate parameter) to both graph_objects constructors and go.Figure, enabling the same developer code to work identically in both default and fast modes:
import os
FAST = os.getenv("PLOTLY_FAST", "false").lower() == "true"
# Exact same code in both modes - only the flag changes:
fig = go.Figure(_as_dict=FAST)
fig.add_traces([go.Scatter(x=x, y=y, mode="lines", _as_dict=FAST) for _ in range(200)])
fig.update_layout(title="My Plot", xaxis_title="X")
fig.add_annotation(text="Peak", x=50, y=100, showarrow=True)
fig.add_vline(x=50, line_dash="dash")
fig.show() # Works in BOTH modes- Trace-level
_as_dict=True:__new__intercepts before__init__and returns a plain dict - ~300x faster than default (6 function calls vs 892) - Figure-level
_as_dict=True:go.Figurestores dicts directly inself._dataandself._layout, skippingvalidate_coerce+deepcopy. Fast paths inadd_traces,update_layout,add_annotation,add_shape,add_vline/add_hline- 26x to ~11,300x faster end-to-end
Why should this feature be added?
The performance gap is massive
I prototyped this feature and benchmarked it against the existing approaches. Results on Plotly 6.0.1, Python 3.10:
End-to-end pipeline (200 traces -> figure construction, 1000 iterations):
| Method | Time | vs default |
|---|---|---|
go.Figure + go.Scatter [default] |
87ms | 1x |
go.Figure + go.Scatter(_validate=False) |
52ms | 1.7x faster |
go.Figure + go.Scatter(_as_dict=True) [trace only] |
47ms | 1.9x faster |
go.Figure(_as_dict) + go.Scatter(_as_dict) |
3.4ms | 26x faster |
go.Figure(_as_dict).add_traces(go.Scatter(_as_dict)) |
3.5ms | 25x faster |
go.Figure(_as_dict) + Scatter(_as_dict) [direct import] |
0.21ms | ~420x faster |
dict() + dict assembly [baseline] |
0.07ms | ~1265x faster |
Key observations:
- Rows 1-3: Even with trace-level
_as_dict=True,go.Figureonly gets 1.9x faster becauseBaseFigure.__init__callsvalidate_coerce()which reconstructs full objects from dicts, thendeepcopy()copies everything again. The trace-level optimization is wasted. - Rows 4-5: With both trace-level and Figure-level
_as_dict, the full pipeline drops from 87ms to 3.4ms (26x). This is the recommended usage via thegomodule. - Row 6: The
go.Scatter(...)path includes ~106 function calls from Plotly's lazygomodule__getattr__resolution - a pre-existing overhead unrelated to_as_dict. With a direct import (from plotly.graph_objs._scatter import Scatter), it drops to 0.21ms (~420x).
Annotations, shapes, and spanning lines (200 operations per call):
| Operation | Default | _as_dict | Speedup |
|---|---|---|---|
add_annotation x200 |
2,192ms | 12ms | ~189x faster |
add_shape x200 |
2,369ms | 13ms | ~189x faster |
add_vline x200 |
6,550ms | 0.58ms | ~11,300x faster |
add_hline x200 |
6,683ms | 0.59ms | ~11,300x faster |
The annotation/shape speedup is extreme because:
- The default path is O(N^2) - each
add_annotationdoesself.layout["annotations"] += (new_obj,)which copies the entire tuple on every call (known issue: Adding N annotations takes O(N^2) time #5316, Drawing lines /add_shape()is very slow, possible quadratic Schlemiel the Painter algorithm #3620, Performance issue - add_vlines #4965) - The
_as_dictpath is O(N) - justlist.append() add_vline/add_hlinecall BOTHadd_shapeANDadd_annotation, doubling the O(N^2) cost. In_as_dictmode, the shape dict is constructed directly - no graph objects created at all
Single-trace construction benchmarks (1000 iterations, x=np.random.rand(1000))
| Method | Time/call | Function calls | vs default |
|---|---|---|---|
go.Scatter() [default] |
0.210ms | 892 | 1x |
go.Scatter(_validate=False) |
0.039ms | 293 | 5.4x faster |
go.Scatter(_as_dict=True) [via go module] |
0.016ms | 112 | 13x faster |
Scatter() [direct import] |
0.176ms | 786 | 1.2x faster |
Scatter(_validate=False) [direct import] |
0.020ms | 187 | 11x faster |
Scatter(_as_dict=True) [direct import] |
0.0007ms | 6 | ~300x faster |
dict() [baseline] |
0.0003ms | 2 | ~700x faster |
Key finding: _validate=False only gives a ~5x speedup because it still allocates the full object hierarchy (15 instance attributes), still loops through all 75 properties with arg.pop(), and still calls __setitem__ for each non-None property. The _as_dict=True flag achieves ~300x because __new__ returns a dict before __init__ ever runs.
Where the time goes
Trace-level - profiling a single go.Scatter(x=x, y=y, mode="lines", line=dict(color="red", width=1)) call (892 function calls):
Scatter.__init__(857 lines of generated code): loops through all 75 properties with thearg.pop()+self["prop"] = valuepatternBasePlotlyType.__init__+BaseTraceType.__init__: allocate 15 instance attributes per object (including 4 empty dicts + 5 empty callback lists)__setitem__->_get_validator()->validate_coerce(): for each non-None property, resolves a validator, runs type coercion, copies arrays viacopy_to_readonly_numpy_array()- Compound property handling:
_set_compound_prop()recursively creates childBasePlotlyTypeinstances (e.g.,line=dict(...)creates ascatter.Lineobject)
Figure-level - BaseFigure.__init__ and add_traces():
validate_coerce(data): pops"type"from each dict, looks up the class, instantiatesScatter(**dict_data)- reconstructs the full object from a dictdeepcopy(trace._props): copies the entire property dict for each trace intoself._data_add_annotation_like:self.layout["annotations"] += (new_obj,)- creates a new tuple on every call, copying all existing annotations (O(N^2))
The _as_dict flag sidesteps all of this at both levels.
The use case: same code, both modes
Many Plotly users construct figures server-side (in Dash callbacks, FastAPI endpoints, gRPC services, or plugin systems) where the figure is immediately serialized to JSON for the frontend. In this workflow:
- Validation is unnecessary - the data is serialized to JSON and sent to the client
- Deep copies of arrays are unnecessary - the arrays are not mutated, just serialized
- The
BasePlotlyTypewrapper objects are unnecessary - they're immediately converted back to dicts viato_plotly_json()
The full lifecycle is: Python data -> go.Scatter (validate, copy, wrap) -> go.Figure (validate, deepcopy, reparent) -> to_dict() (deepcopy again) -> JSON -> frontend. The middle steps are pure overhead when the goal is just producing JSON.
The _as_dict flag makes this easy to control:
import os
import plotly.graph_objects as go
FAST_MODE = os.getenv("PLOTLY_FAST", "false").lower() == "true"
def build_figure(data_x, data_y, annotations):
fig = go.Figure(_as_dict=FAST_MODE)
fig.add_traces([
go.Scatter(x=data_x, y=data_y, mode="lines", _as_dict=FAST_MODE),
go.Bar(x=data_x, y=data_y, _as_dict=FAST_MODE),
])
fig.update_layout(title="Dashboard", xaxis_title="Time")
for ann in annotations:
fig.add_annotation(text=ann["text"], x=ann["x"], y=ann["y"])
fig.add_hline(y=0, line_dash="dash")
return fig
# Development: PLOTLY_FAST=false → full validation, error messages, IDE support
# Production: PLOTLY_FAST=true → 26x-11,300x faster, same output
fig = build_figure(x, y, annotations)
fig.show() # Works in BOTH modesIn my plugin system - where developers write data visualization plugins - I measured a 26x construction speedup via the go module and up to ~420x with direct imports. But without this feature, achieving the same result requires giving up IDE autocomplete, runtime validation, error messages, and property discovery - trading developer experience for performance. An official _as_dict mode would eliminate this tradeoff.
Existing community demand
This is closely related to:
- add a
validate=Falseoption forgraph_objectsandpxfigures #1812 - "add a validate=False option for graph_objects" (open since 2019). A maintainer noted "the last time we tried, we were unable to make it work." The_as_dictapproach is simpler because it intercepts in__new__before anyBasePlotlyTypeinitialization runs - no need to modify the existing class hierarchy. - Allow disabling lookup of missing prop names #4100 - "Allow disabling lookup of missing prop names" (demonstrated ~12x speedup by deferring Levenshtein distance calculations).
- Adding N annotations takes O(N^2) time #5316 - "Adding N annotations takes O(N^2) time" -
_as_dictpath is O(N), giving ~189x speedup for 200 annotations. - Drawing lines /
add_shape()is very slow, possible quadratic Schlemiel the Painter algorithm #3620 - "Drawing lines / add_shape() is very slow, possible quadratic Schlemiel the Painter algorithm" - ~189x speedup for shapes. - Performance issue - add_vlines #4965 - "Performance issue - add_vlines" - ~11,300x speedup for vlines.
Why _validate=False is insufficient (#1812)
I benchmarked the existing _validate=False parameter and found it only provides a ~5x speedup (892 -> 293 function calls). Even with validation disabled, Scatter.__init__ still:
- Calls
super().__init__("scatter")-> allocates 15 instance attributes - Loops through all 75 properties with
arg.pop() - Calls
__setitem__for each non-None property (which still parses property paths via_str_to_dict_path(), checks_mapped_properties, and handlesBasePlotlyTypevalue conversion)
The _as_dict=True flag sidesteps all of this: __new__ returns a dict before __init__ is ever called. This is why it achieves ~300x speedup (6 function calls) vs _validate=False's ~5x.
Mocks/Designs
Proposed implementation
I prototyped this by modifying basedatatypes.py. The implementation has two parts:
- Part 1 - Trace-level:
__new__overrides onBasePlotlyTypeandBaseTraceTypethat return plain dicts - Part 2 - Figure-level: fast paths in
BaseFigure.__init__,add_traces,update_layout,_add_annotation_like, and_process_multiple_axis_spanning_shapes
All changes are in a single file (basedatatypes.py). No codegen changes needed. No changes to _figure.py or any trace class files. The __new__ override in the base classes automatically applies to all trace types (Scatter, Bar, Heatmap, etc.) and all layout objects (Annotation, Shape, Layout, etc.).
Part 1: Trace-level _as_dict=True
The key insight: when Python's __new__ returns something that is not an instance of the class, __init__ is never called. This means we can return a plain dict before any of the 857-line Scatter.__init__ runs.
1a. __new__ in BasePlotlyType (base class for all graph objects)
def __new__(cls, *args, **kwargs):
"""Support _as_dict=True to return a plain dict instead of an object.
When _as_dict=True, bypasses all validation and object creation.
The returned dict is directly compatible with Plotly.js rendering.
"""
if kwargs.pop("_as_dict", False):
kwargs.pop("skip_invalid", None)
kwargs.pop("_validate", None)
return kwargs
return super().__new__(cls)1b. __new__ in BaseTraceType (parent class of all trace types)
def __new__(cls, *args, **kwargs):
"""Support _as_dict=True to return a plain dict with auto-injected type.
When _as_dict=True, bypasses all validation and object creation.
The 'type' field is automatically set (e.g. Scatter -> 'scatter').
"""
if kwargs.pop("_as_dict", False):
kwargs.pop("skip_invalid", None)
kwargs.pop("_validate", None)
kwargs["type"] = cls._path_str
return kwargs
return super().__new__(cls)1c. One-line addition to _process_kwargs in BasePlotlyType
Because Python passes the original keyword arguments separately to both __new__ and __init__, when _as_dict=False (the default), the _as_dict key must also be stripped in __init__'s processing path:
def _process_kwargs(self, **kwargs):
kwargs.pop("_as_dict", None) # Handled by __new__, strip here to avoid validation error
# ... rest of existing code unchanged ...How it works:
- Every trace class already has
_path_stras a class attribute (e.g.,Scatter._path_str = "scatter",Bar._path_str = "bar") __new__receives the same keyword arguments as__init__, so all trace properties are already inkwargs- When
__new__returns adict(not aScatterinstance), Python skips__init__entirely - zero overhead - When
_as_dictisFalseor not passed (the default),__new__callssuper().__new__(cls)and everything works exactly as before
Part 2: Figure-level _as_dict=True
When go.Figure(_as_dict=True) is used, the Figure is still a real Figure object (not a dict), but with minimal initialization. This means show(), to_json(), add_traces(), etc. all work - it just skips the expensive internals.
2a. Fast path in BaseFigure.__init__
When _as_dict=True, skip validate_coerce, deepcopy, reparenting, validators, templates, and batch mode. Store data and layout as plain dicts directly.
# In BaseFigure.__init__, after self._validate = kwargs.pop("_validate", True)
self._as_dict_mode = kwargs.pop("_as_dict", False)
if self._as_dict_mode:
# Fast path: minimal init for to_dict()/show()/to_json() to work.
# Skips validate_coerce, deepcopy, reparenting, validators,
# templates, animation validators, and batch mode setup.
# Subplot properties
self._grid_str = None
self._grid_ref = None
# Handle Figure-like dict input
if isinstance(data, dict) and (
"data" in data or "layout" in data or "frames" in data
):
layout_plotly = data.get("layout", layout_plotly)
frames = data.get("frames", frames)
data = data.get("data", None)
# Store data directly - no validate_coerce, no deepcopy
self._data = list(data) if data else []
self._data_objs = ()
self._data_defaults = [{} for _ in self._data]
# Store layout directly
self._layout = layout_plotly if isinstance(layout_plotly, dict) else {}
self._layout_defaults = {}
# Frames
self._frame_objs = ()
return # Skip everything elseBaseFigure.__init__ normally calls validate_coerce(data) which reconstructs all trace dicts into full objects, then deepcopy(trace._props). In _as_dict mode, we skip all of this. Since to_dict() reads self._data, self._layout, and self._frame_objs (set to () in fast mode), serialization works perfectly.
2b. Fast path in BaseFigure.add_traces
# At the start of add_traces method body
if getattr(self, '_as_dict_mode', False):
# Fast path: just extend self._data with dicts
if not isinstance(data, (list, tuple)):
data = [data]
self._data.extend(data)
self._data_defaults.extend([{} for _ in data])
return selfadd_trace() delegates to add_traces(), and all 50+ code-generated methods (add_scatter(), add_bar(), add_heatmap(), etc.) delegate to add_trace(). So this single fast path covers all trace addition methods.
2c. Fast path in BaseFigure.update_layout
# At the start of update_layout method body
if getattr(self, '_as_dict_mode', False):
# Fast path: directly update the layout dict
if dict1:
self._layout.update(dict1)
if kwargs:
self._layout.update(kwargs)
return selfNote: In _as_dict mode, Plotly's underscore-to-nested expansion doesn't happen (e.g., xaxis_title='X' stays as {"xaxis_title": "X"} instead of {"xaxis": {"title": {"text": "X"}}}). However, Plotly.js automatically interprets flat keys like xaxis_title as nested xaxis.title.text on the client side, so the rendered output is identical.
2d. Fast path in BaseFigure._add_annotation_like
This single method handles add_annotation, add_shape, add_layout_image, and add_selection.
# At the start of _add_annotation_like method body
if getattr(self, '_as_dict_mode', False):
# Fast path: append dict directly to layout
if hasattr(new_obj, 'to_plotly_json'):
obj_dict = new_obj.to_plotly_json()
elif isinstance(new_obj, dict):
obj_dict = new_obj
else:
obj_dict = {}
if prop_plural not in self._layout:
self._layout[prop_plural] = []
self._layout[prop_plural].append(obj_dict)
return selfThe default does self.layout[prop_plural] += (new_obj,) which copies the entire tuple on every call - O(N^2) for N annotations (#5316). The fast path does list.append() - O(1) per call.
Note: In _as_dict mode, row/col/secondary_y parameters are ignored. Subplot placement requires the full layout object graph. For subplot-targeted annotations, use the default mode.
2e. Fast path in BaseFigure._process_multiple_axis_spanning_shapes
This method handles add_vline, add_hline, add_vrect, and add_hrect.
# At the start of _process_multiple_axis_spanning_shapes method body
if getattr(self, '_as_dict_mode', False):
# Fast path: build shape dict directly, skip layout property access
shape_kwargs, annotation_kwargs = shapeannotation.split_dict_by_key_prefix(
kwargs, "annotation_"
)
shape_dict = _combine_dicts([shape_args, shape_kwargs])
# Set default xref/yref if not specified
if "xref" not in shape_dict:
shape_dict["xref"] = "x"
if "yref" not in shape_dict:
shape_dict["yref"] = "y"
# Apply axis-spanning: append " domain" to the spanning axis ref
if shape_type in ["vline", "vrect"]:
shape_dict["yref"] = shape_dict["yref"] + " domain"
elif shape_type in ["hline", "hrect"]:
shape_dict["xref"] = shape_dict["xref"] + " domain"
if "shapes" not in self._layout:
self._layout["shapes"] = []
self._layout["shapes"].append(shape_dict)
# Handle annotation if provided
augmented_annotation = shapeannotation.axis_spanning_shape_annotation(
annotation, shape_type, shape_args, annotation_kwargs
)
if augmented_annotation is not None:
ann_dict = augmented_annotation if isinstance(augmented_annotation, dict) else {}
if "annotations" not in self._layout:
self._layout["annotations"] = []
self._layout["annotations"].append(ann_dict)
returnadd_vline(x=5) normally creates a Shape object + O(N^2) tuple concatenation + layout property access through validators. The fast path constructs the shape dict directly with correct xref/yref domain settings. No graph objects created at all - this is why it goes from 6,550ms to 0.6ms.
Why _as_dict instead of a classmethod
I initially considered a classmethod (go.Scatter.as_dict(...)) but the _as_dict flag is better because:
- Consistent API: follows the same pattern as the existing
_validate=False- an underscore-prefixed constructor flag - Minimal learning curve: users don't need to learn a new method, just add one flag
- Drop-in replacement: changing
go.Scatter(...)togo.Scatter(..., _as_dict=True)is a one-line change - Easy to toggle: can be controlled by an environment variable or config flag
What works in _as_dict mode
| Method | Works? | Notes |
|---|---|---|
fig.show() |
Yes | Goes through to_dict() |
fig.to_dict() / fig.to_plotly_json() |
Yes | Reads self._data and self._layout directly |
fig.to_json() / fig.write_image() / fig.to_html() |
Yes | Delegates to plotly.io |
fig.add_trace() / fig.add_traces() |
Yes | Fast path: extends self._data |
fig.add_scatter(), fig.add_bar(), etc. |
Yes | All 50+ methods delegate to add_trace() |
fig.update_layout() |
Yes | Fast path: self._layout.update() |
fig.add_annotation() / fig.add_shape() |
Yes | Fast path: appends dict to layout |
fig.add_vline() / fig.add_hline() / add_vrect() / add_hrect() |
Yes | Fast path: constructs shape dict directly |
fig.add_layout_image() / fig.add_selection() |
Yes | Fast path via _add_annotation_like() |
fig.data property |
Yes | Returns tuple of dicts (not trace objects) |
fig.layout property |
Yes | Returns a dict (not a Layout object) |
Usage
import plotly.graph_objects as go
# Trace construction (type auto-injected)
trace = go.Scatter(x=[1, 2, 3], y=[4, 5, 6], mode="lines", _as_dict=True)
# {'x': [1, 2, 3], 'y': [4, 5, 6], 'mode': 'lines', 'type': 'scatter'}
bar = go.Bar(x=["a", "b"], y=[1, 2], _as_dict=True)
# {'x': ['a', 'b'], 'y': [1, 2], 'type': 'bar'}
# Full pipeline with go.Figure
fig = go.Figure(_as_dict=True)
fig.add_traces([
go.Scatter(x=[1, 2], y=[3, 4], mode="lines", _as_dict=True),
go.Bar(x=["a", "b"], y=[1, 2], _as_dict=True),
])
fig.update_layout(title="My Plot")
fig.add_annotation(text="Note", x=1, y=3, showarrow=False)
fig.add_vline(x=1.5, line_dash="dash")
fig.show() # Works!
# Default behavior unchanged:
trace = go.Scatter(x=[1, 2], y=[3, 4]) # Scatter object (unchanged)
trace = go.Scatter(x=[1, 2], y=[3, 4], _as_dict=False) # Scatter object (unchanged)
fig = go.Figure() # Normal Figure (unchanged)Correctness verification
I verified that _as_dict mode produces the same output as the default mode:
Annotations: default=200, _as_dict=200 OK
Shapes: default=200, _as_dict=200 OK
Vlines: default=200, _as_dict=200 OK
Sample annotation (default): {'showarrow': False, 'text': 'Label 0', 'x': 0, 'y': 0}
Sample annotation (_as_dict): {'showarrow': False, 'text': 'Label 0', 'x': 0, 'y': 0}
Sample vline (default): {'type': 'line', 'x0': 0, 'x1': 0, 'xref': 'x', 'y0': 0, 'y1': 1, 'yref': 'y domain'}
Sample vline (_as_dict): {'type': 'line', 'x0': 0, 'x1': 0, 'y0': 0, 'y1': 1, 'xref': 'x', 'yref': 'y domain'}
Both modes produce identical output - the only difference is key ordering within dicts, which has no effect on rendering.
Benchmark code
import time
import cProfile
import pstats
import numpy as np
import plotly.graph_objects as go
from plotly.graph_objs._scatter import Scatter
x = np.random.rand(1000).astype(np.float32)
y = np.random.rand(1000).astype(np.float32)
N = 1000
kwargs = dict(x=x, y=y, mode="lines", line=dict(color="red", width=1))
def bench(fn, n=N):
start = time.perf_counter()
for _ in range(n):
fn()
return (time.perf_counter() - start) / n * 1000 # ms per call
# --- Single-trace timing ---
print("=== Single-trace construction ===")
for label, fn in [
("go.Scatter() [default]", lambda: go.Scatter(**kwargs)),
("go.Scatter(_validate=False)", lambda: go.Scatter(**kwargs, _validate=False)),
("go.Scatter(_as_dict=True) [via go]",lambda: go.Scatter(**kwargs, _as_dict=True)),
("Scatter(_as_dict=True) [direct]", lambda: Scatter(**kwargs, _as_dict=True)),
("dict() [baseline]", lambda: dict(type="scatter", **kwargs)),
]:
print(f" {label:<45} {bench(fn):>8.4f}ms")
# --- Pipeline: 200 traces ---
num_traces = 200
pipelines = [
("go.Figure + go.Scatter [default]",
lambda: go.Figure(data=[go.Scatter(**kwargs) for _ in range(num_traces)])),
("go.Figure(_as_dict) + go.Scatter(_as_dict)",
lambda: go.Figure(data=[go.Scatter(**kwargs, _as_dict=True) for _ in range(num_traces)], _as_dict=True)),
("go.Figure(_as_dict).add_traces(go.Scatter(_as_dict))",
lambda: go.Figure(_as_dict=True).add_traces([go.Scatter(**kwargs, _as_dict=True) for _ in range(num_traces)])),
("go.Figure(_as_dict) + Scatter(_as_dict) [direct]",
lambda: go.Figure(data=[Scatter(**kwargs, _as_dict=True) for _ in range(num_traces)], _as_dict=True)),
]
results = [(label, bench(fn)) for label, fn in pipelines]
t_base = results[0][1]
print(f"\n=== Pipeline: {num_traces} traces ===")
for label, t in results:
print(f" {label:<55} {t:>7.2f}ms {t_base/t:>7.0f}x")
# --- Annotations/shapes ---
num_items = 200
def bench_ann(fn, n=10):
start = time.perf_counter()
for _ in range(n):
fn()
return (time.perf_counter() - start) / n * 1000
def add_anns(as_dict=False):
fig = go.Figure(_as_dict=as_dict)
for i in range(num_items):
fig.add_annotation(text=f"L{i}", x=i, y=i, showarrow=False)
def add_vlines(as_dict=False):
fig = go.Figure(_as_dict=as_dict)
for i in range(num_items):
fig.add_vline(x=i)
print(f"\n=== Annotations/shapes: {num_items} ops ===")
for label, fn, n in [
("add_annotation [default]", lambda: add_anns(False), 10),
("add_annotation [_as_dict]", lambda: add_anns(True), 500),
("add_vline [default]", lambda: add_vlines(False), 10),
("add_vline [_as_dict]", lambda: add_vlines(True), 500),
]:
print(f" {label:<40} {bench_ann(fn, n):>8.2f}ms")Tradeoffs and limitations
What's different in _as_dict mode:
fig.datareturns a tuple of dicts (not trace objects) -len(fig.data), indexing, and iteration all work, but individual dicts don't have methods like.update()fig.layoutreturns a dict (not a Layout object) - key access works (fig.layout["title"]), but not attribute access (fig.layout.title)update_traces(),for_each_trace(),select_traces()- require trace objects, won't workrow/col/secondary_ysubplot targeting - silently ignored inadd_trace,add_annotation,add_shape(requires full layout object graph)- Underscore-to-nested expansion in
update_layout()-xaxis_title="X"stays flat. Plotly.js handles both formats - Trace-level
_as_dict=Truereturns a dict, not a graph object - can't call.update()or.show()on individual traces
These limitations are for interactive manipulation - not needed in the server-side serialization use case where you build a figure and immediately serialize it. The tradeoff is explicit and opt-in.
Why this is safe:
- Purely additive: When
_as_dictis not passed (the default), behavior is 100% unchanged. Passing_as_dict=Falsealso behaves identically to not passing it - Opt-in: Only activates on explicit
_as_dict=Trueflag - No codegen changes: All changes are in
BaseFigureandBasePlotlyTypemethods inbasedatatypes.py - Compatible output:
to_dict()produces the same structure in both modes - Same pattern as
_validate=False: Follows the existing convention of underscore-prefixed constructor flags - The
__new__approach is safe because it only activates on an explicit opt-in flag - when_as_dict=False,__new__callssuper().__new__(cls)and everything works exactly as before
Future optimizations:
- The code-generated
add_annotation(),add_shape(),add_layout_image()methods in_figure.pystill create full graph objects internally. Passing_as_dict=Trueto these constructors in the generated code would bringadd_annotationfrom 12ms down to near-zero (similar toadd_vline's 0.58ms) - A global
plotly.io.as_dict_mode = Truesetting could eliminate the need to pass_as_dict=Trueto every constructor
Notes
- Since
graph_objsclasses are code-generated, the__new__override in the base classes automatically applies to ALL trace types (Scatter, Bar, Heatmap, etc.) and ALL layout objects (Annotation, Shape, Layout, etc.) with no codegen changes needed - The
_as_dictflag ongo.Figurecreates a real Figure object (not a dict), soshow(),to_json(),add_traces(), etc. all work - it just skips the expensive internal initialization - IDE autocomplete works since the constructor signature is unchanged - the same keyword arguments are accepted
- This would also benefit Dash applications where figure construction in callbacks is a common bottleneck
- I prototyped all changes locally - existing tests continue to pass since the default path is unchanged
- The O(N^2) behavior of
add_annotation/add_shape(Adding N annotations takes O(N^2) time #5316, Drawing lines /add_shape()is very slow, possible quadratic Schlemiel the Painter algorithm #3620, Performance issue - add_vlines #4965) is a separate issue, but_as_dictmode provides a workaround that's ~189x to ~11,300x faster - I'm happy to contribute a PR with these changes
Plotly version: 6.0.1 | Python version: 3.10.17