Skip to content

[FEATURE]: Add _as_dict=True to graph_objects and go.Figure for 26x-11,300x faster figure construction #5514

@AlonSpivack

Description

@AlonSpivack

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.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:

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.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):

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:

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):

  1. Scatter.__init__ (857 lines of generated code): loops through all 75 properties with the arg.pop() + self["prop"] = value pattern
  2. BasePlotlyType.__init__ + BaseTraceType.__init__: allocate 15 instance attributes per object (including 4 empty dicts + 5 empty callback lists)
  3. __setitem__ -> _get_validator() -> validate_coerce(): for each non-None property, resolves a validator, runs type coercion, copies arrays via copy_to_readonly_numpy_array()
  4. Compound property handling: _set_compound_prop() recursively creates child BasePlotlyType instances (e.g., line=dict(...) creates a scatter.Line object)

Figure-level - BaseFigure.__init__ and add_traces():

  1. validate_coerce(data): pops "type" from each dict, looks up the class, instantiates Scatter(**dict_data) - reconstructs the full object from a dict
  2. deepcopy(trace._props): copies the entire property dict for each trace into self._data
  3. _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:

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 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:

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 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.
    """
    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_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)

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 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 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 self

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 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 self

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 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 self

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 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)
    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

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.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
  • 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_dict mode 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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions