You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
importosFAST=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_inrange(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.Figure stores dicts directly in self._data and self._layout, skipping validate_coerce + deepcopy. Fast paths in add_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:
Rows 1-3: Even with trace-level _as_dict=True, go.Figure only gets 1.9x faster because BaseFigure.__init__ calls validate_coerce() which reconstructs full objects from dicts, then deepcopy() 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 the go module.
Row 6: The go.Scatter(...) path includes ~106 function calls from Plotly's lazy go module __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):
add_vline/add_hline call BOTH add_shape AND add_annotation, doubling the O(N^2) cost. In _as_dict mode, 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 the arg.pop() + self["prop"] = value pattern
__setitem__ -> _get_validator() -> validate_coerce(): for each non-None property, resolves a validator, runs type coercion, copies arrays via copy_to_readonly_numpy_array()
Figure-level - BaseFigure.__init__ and add_traces():
validate_coerce(data): pops "type" from each dict, looks up the class, instantiates Scatter(**dict_data) - reconstructs the full object from a dict
deepcopy(trace._props): copies the entire property dict for each trace into self._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 BasePlotlyType wrapper objects are unnecessary - they're immediately converted back to dicts via to_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:
importosimportplotly.graph_objectsasgoFAST_MODE=os.getenv("PLOTLY_FAST", "false").lower() =="true"defbuild_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")
foranninannotations:
fig.add_annotation(text=ann["text"], x=ann["x"], y=ann["y"])
fig.add_hline(y=0, line_dash="dash")
returnfig# Development: PLOTLY_FAST=false → full validation, error messages, IDE support# Production: PLOTLY_FAST=true → 26x-11,300x faster, same outputfig=build_figure(x, y, annotations)
fig.show() # Works in BOTH modes
In 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=False option for graph_objects and px figures #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_dict approach is simpler because it intercepts in __new__ before any BasePlotlyType initialization runs - no need to modify the existing class hierarchy.
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 __setitem__ for each non-None property (which still parses property paths via _str_to_dict_path(), checks _mapped_properties, and handles BasePlotlyType value 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 on BasePlotlyType and BaseTraceType that 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. """ifkwargs.pop("_as_dict", False):
kwargs.pop("skip_invalid", None)
kwargs.pop("_validate", None)
returnkwargsreturnsuper().__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'). """ifkwargs.pop("_as_dict", False):
kwargs.pop("skip_invalid", None)
kwargs.pop("_validate", None)
kwargs["type"] =cls._path_strreturnkwargsreturnsuper().__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_str as 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 in kwargs
When __new__ returns a dict (not a Scatter instance), Python skips __init__ entirely - zero overhead
When _as_dict is False or not passed (the default), __new__ calls super().__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)
ifself._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 propertiesself._grid_str=Noneself._grid_ref=None# Handle Figure-like dict inputifisinstance(data, dict) and (
"data"indataor"layout"indataor"frames"indata
):
layout_plotly=data.get("layout", layout_plotly)
frames=data.get("frames", frames)
data=data.get("data", None)
# Store data directly - no validate_coerce, no deepcopyself._data=list(data) ifdataelse []
self._data_objs= ()
self._data_defaults= [{} for_inself._data]
# Store layout directlyself._layout=layout_plotlyifisinstance(layout_plotly, dict) else {}
self._layout_defaults= {}
# Framesself._frame_objs= ()
return# Skip everything else
BaseFigure.__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 bodyifgetattr(self, '_as_dict_mode', False):
# Fast path: just extend self._data with dictsifnotisinstance(data, (list, tuple)):
data= [data]
self._data.extend(data)
self._data_defaults.extend([{} for_indata])
returnself
add_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 bodyifgetattr(self, '_as_dict_mode', False):
# Fast path: directly update the layout dictifdict1:
self._layout.update(dict1)
ifkwargs:
self._layout.update(kwargs)
returnself
Note: 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 bodyifgetattr(self, '_as_dict_mode', False):
# Fast path: append dict directly to layoutifhasattr(new_obj, 'to_plotly_json'):
obj_dict=new_obj.to_plotly_json()
elifisinstance(new_obj, dict):
obj_dict=new_objelse:
obj_dict= {}
ifprop_pluralnotinself._layout:
self._layout[prop_plural] = []
self._layout[prop_plural].append(obj_dict)
returnself
The 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 bodyifgetattr(self, '_as_dict_mode', False):
# Fast path: build shape dict directly, skip layout property accessshape_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 specifiedif"xref"notinshape_dict:
shape_dict["xref"] ="x"if"yref"notinshape_dict:
shape_dict["yref"] ="y"# Apply axis-spanning: append " domain" to the spanning axis refifshape_typein ["vline", "vrect"]:
shape_dict["yref"] =shape_dict["yref"] +" domain"elifshape_typein ["hline", "hrect"]:
shape_dict["xref"] =shape_dict["xref"] +" domain"if"shapes"notinself._layout:
self._layout["shapes"] = []
self._layout["shapes"].append(shape_dict)
# Handle annotation if providedaugmented_annotation=shapeannotation.axis_spanning_shape_annotation(
annotation, shape_type, shape_args, annotation_kwargs
)
ifaugmented_annotationisnotNone:
ann_dict=augmented_annotationifisinstance(augmented_annotation, dict) else {}
if"annotations"notinself._layout:
self._layout["annotations"] = []
self._layout["annotations"].append(ann_dict)
return
add_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(...) to go.Scatter(..., _as_dict=True) is a one-line change
Easy to toggle: can be controlled by an environment variable or config flag
fig.data returns 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.layout returns 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 work
row/col/secondary_y subplot targeting - silently ignored in add_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=True returns 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_dict is not passed (the default), behavior is 100% unchanged. Passing _as_dict=False also behaves identically to not passing it
Opt-in: Only activates on explicit _as_dict=True flag
No codegen changes: All changes are in BaseFigure and BasePlotlyType methods in basedatatypes.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__ calls super().__new__(cls) and everything works exactly as before
Future optimizations:
The code-generated add_annotation(), add_shape(), add_layout_image() methods in _figure.py still create full graph objects internally. Passing _as_dict=True to these constructors in the generated code would bring add_annotation from 12ms down to near-zero (similar to add_vline's 0.58ms)
A global plotly.io.as_dict_mode = True setting could eliminate the need to pass _as_dict=True to every constructor
Notes
Since graph_objs classes 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_dict flag on go.Figure creates a real Figure object (not a dict), so show(), 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
Description
Graph object constructors (
go.Scatter(),go.Bar(), etc.) andgo.Figureoperations (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_dictparameter (consistent with the existing_validateparameter) to both graph_objects constructors andgo.Figure, enabling the same developer code to work identically in both default and fast modes:_as_dict=True:__new__intercepts before__init__and returns a plain dict - ~300x faster than default (6 function calls vs 892)_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-endWhy 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):
go.Figure+go.Scatter[default]go.Figure+go.Scatter(_validate=False)go.Figure+go.Scatter(_as_dict=True)[trace only]go.Figure(_as_dict)+go.Scatter(_as_dict)go.Figure(_as_dict).add_traces(go.Scatter(_as_dict))go.Figure(_as_dict)+Scatter(_as_dict)[direct import]dict()+ dict assembly [baseline]Key observations:
_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._as_dict, the full pipeline drops from 87ms to 3.4ms (26x). This is the recommended usage via thegomodule.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):
add_annotationx200add_shapex200add_vlinex200add_hlinex200The annotation/shape speedup is extreme because:
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)_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 allSingle-trace construction benchmarks (1000 iterations, x=np.random.rand(1000))
go.Scatter()[default]go.Scatter(_validate=False)go.Scatter(_as_dict=True)[via go module]Scatter()[direct import]Scatter(_validate=False)[direct import]Scatter(_as_dict=True)[direct import]dict()[baseline]Key finding:
_validate=Falseonly gives a ~5x speedup because it still allocates the full object hierarchy (15 instance attributes), still loops through all 75 properties witharg.pop(), and still calls__setitem__for each non-None property. The_as_dict=Trueflag 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()_set_compound_prop()recursively creates childBasePlotlyTypeinstances (e.g.,line=dict(...)creates ascatter.Lineobject)Figure-level -
BaseFigure.__init__andadd_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_dictflag 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:
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_dictflag makes this easy to control:In my plugin system - where developers write data visualization plugins - I measured a 26x construction speedup via the
gomodule 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_dictmode would eliminate this tradeoff.Existing community demand
This is closely related to:
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._as_dictpath is O(N), giving ~189x speedup for 200 annotations.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.Why
_validate=Falseis insufficient (#1812)I benchmarked the existing
_validate=Falseparameter and found it only provides a ~5x speedup (892 -> 293 function calls). Even with validation disabled,Scatter.__init__still:super().__init__("scatter")-> allocates 15 instance attributesarg.pop()__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=Trueflag 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:__new__overrides onBasePlotlyTypeandBaseTraceTypethat return plain dictsBaseFigure.__init__,add_traces,update_layout,_add_annotation_like, and_process_multiple_axis_spanning_shapesAll changes are in a single file (
basedatatypes.py). No codegen changes needed. No changes to_figure.pyor 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=TrueThe 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-lineScatter.__init__runs.1a.
__new__inBasePlotlyType(base class for all graph objects)1b.
__new__inBaseTraceType(parent class of all trace types)1c. One-line addition to
_process_kwargsinBasePlotlyTypeBecause Python passes the original keyword arguments separately to both
__new__and__init__, when_as_dict=False(the default), the_as_dictkey must also be stripped in__init__'s processing path:How it works:
_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__new__returns adict(not aScatterinstance), Python skips__init__entirely - zero overhead_as_dictisFalseor not passed (the default),__new__callssuper().__new__(cls)and everything works exactly as beforePart 2: Figure-level
_as_dict=TrueWhen
go.Figure(_as_dict=True)is used, the Figure is still a real Figure object (not a dict), but with minimal initialization. This meansshow(),to_json(),add_traces(), etc. all work - it just skips the expensive internals.2a. Fast path in
BaseFigure.__init__When
_as_dict=True, skipvalidate_coerce,deepcopy, reparenting, validators, templates, and batch mode. Store data and layout as plain dicts directly.BaseFigure.__init__normally callsvalidate_coerce(data)which reconstructs all trace dicts into full objects, thendeepcopy(trace._props). In_as_dictmode, we skip all of this. Sinceto_dict()readsself._data,self._layout, andself._frame_objs(set to()in fast mode), serialization works perfectly.2b. Fast path in
BaseFigure.add_tracesadd_trace()delegates toadd_traces(), and all 50+ code-generated methods (add_scatter(),add_bar(),add_heatmap(), etc.) delegate toadd_trace(). So this single fast path covers all trace addition methods.2c. Fast path in
BaseFigure.update_layoutNote: In
_as_dictmode, 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 likexaxis_titleas nestedxaxis.title.texton the client side, so the rendered output is identical.2d. Fast path in
BaseFigure._add_annotation_likeThis single method handles
add_annotation,add_shape,add_layout_image, andadd_selection.The 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 doeslist.append()- O(1) per call.Note: In
_as_dictmode,row/col/secondary_yparameters 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_shapesThis method handles
add_vline,add_hline,add_vrect, andadd_hrect.add_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 correctxref/yrefdomain settings. No graph objects created at all - this is why it goes from 6,550ms to 0.6ms.Why
_as_dictinstead of a classmethodI initially considered a classmethod (
go.Scatter.as_dict(...)) but the_as_dictflag is better because:_validate=False- an underscore-prefixed constructor flaggo.Scatter(...)togo.Scatter(..., _as_dict=True)is a one-line changeWhat works in
_as_dictmodefig.show()to_dict()fig.to_dict()/fig.to_plotly_json()self._dataandself._layoutdirectlyfig.to_json()/fig.write_image()/fig.to_html()plotly.iofig.add_trace()/fig.add_traces()self._datafig.add_scatter(),fig.add_bar(), etc.add_trace()fig.update_layout()self._layout.update()fig.add_annotation()/fig.add_shape()fig.add_vline()/fig.add_hline()/add_vrect()/add_hrect()fig.add_layout_image()/fig.add_selection()_add_annotation_like()fig.datapropertyfig.layoutpropertyUsage
Correctness verification
I verified that
_as_dictmode produces the same output as the default mode:Both modes produce identical output - the only difference is key ordering within dicts, which has no effect on rendering.
Benchmark code
Tradeoffs and limitations
What's different in
_as_dictmode: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)update_layout()-xaxis_title="X"stays flat. Plotly.js handles both formats_as_dict=Truereturns a dict, not a graph object - can't call.update()or.show()on individual tracesThese 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:
_as_dictis not passed (the default), behavior is 100% unchanged. Passing_as_dict=Falsealso behaves identically to not passing it_as_dict=TrueflagBaseFigureandBasePlotlyTypemethods inbasedatatypes.pyto_dict()produces the same structure in both modes_validate=False: Follows the existing convention of underscore-prefixed constructor flags__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 beforeFuture optimizations:
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)plotly.io.as_dict_mode = Truesetting could eliminate the need to pass_as_dict=Trueto every constructorNotes
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_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 initializationadd_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 fasterPlotly version: 6.0.1 | Python version: 3.10.17