Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
- Fix concurrent first access to lazily initialized graph object properties, which could raise `ValueError("Invalid value")` [[#3441](https://github.com/plotly/plotly.py/issues/3441)], with thanks to @hb1915 for the contribution!
- Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5765](https://github.com/plotly/plotly.py/issues/5765)], with thanks to @Irahan2 for the contribution!
- Fix Plotly Express scrambling the order of categorical axes when the data is split into several traces (e.g. by `color`); the axis now follows the order of first appearance in the underlying data [[#3198](https://github.com/plotly/plotly.py/issues/3198), [#3291](https://github.com/plotly/plotly.py/issues/3291)]


## [7.1.0] - 2026-09-15
Expand Down
31 changes: 31 additions & 0 deletions plotly/express/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2529,6 +2529,36 @@ def get_groups_and_orders(args, grouper):
return groups, orders


def _default_category_orders_for_axes(args, groups, orders):
"""
Add data-appearance orders for categorical x/y columns.

When the data is split into several traces (e.g. by `color`), plotly.js
orders categories by first appearance across traces, which can differ from
the order in the underlying data. Recording the data-appearance order here
lets `set_cartesian_axis_opts` emit an explicit `categoryarray`, matching
the documented behavior that Plotly Express lays out categorical data in
the order in which it appears in the data. See #3198.
"""
if len(groups) <= 1:
return orders
df = args["data_frame"]
for letter in ("x", "y"):
col = args.get(letter)
if not isinstance(col, str) or col in orders or col not in df.columns:
continue
uniques = df.get_column(col).unique(maintain_order=True).to_list()
categories = [
value
for value in uniques
if value is not None
and not (isinstance(value, float) and math.isnan(value))
]
if categories and all(isinstance(value, str) for value in categories):
orders[col] = categories
return orders


def make_figure(args, constructor, trace_patch=None, layout_patch=None):
trace_patch = trace_patch or {}
layout_patch = layout_patch or {}
Expand All @@ -2555,6 +2585,7 @@ def make_figure(args, constructor, trace_patch=None, layout_patch=None):
)
grouper = [x.grouper or one_group for x in grouped_mappings] or [one_group]
groups, orders = get_groups_and_orders(args, grouper)
orders = _default_category_orders_for_axes(args, groups, orders)

col_labels = []
row_labels = []
Expand Down
32 changes: 32 additions & 0 deletions tests/test_optional/test_px/test_px.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,38 @@ def test_category_order_with_category_as_x(backend):
assert set(trace["x"]) == {"Thur", "Fri", "Sat", "Sun"}


def test_category_order_defaults_to_data_order_with_color():
# https://github.com/plotly/plotly.py/issues/3198
# Splitting into one trace per color must not change the axis order:
# it should follow first appearance in the underlying data.
fig = px.line(
x=["A", "B", None, "B", "C", None, "C", "D", None, "D", "E", None],
y=[1, 1, None, 2, 2, None, 3, 3, None, 4, 4, None],
color=[1, 1, 1, 2, 2, 2, 2, 2, 2, 1, 1, 1],
)
assert fig["layout"]["xaxis"]["categoryorder"] == "array"
assert fig["layout"]["xaxis"]["categoryarray"] == ("A", "B", "C", "D", "E")


def test_category_order_defaults_to_data_order_with_color_bar():
# https://github.com/plotly/plotly.py/issues/3291
df = pd.DataFrame(
{"x": ["a", "b", "c", "d"], "y": [1, 2, 3, 4], "z": ["p", "q", "p", "q"]}
)
fig = px.bar(df, x="x", y="y", color="z")
assert fig["layout"]["xaxis"]["categoryorder"] == "array"
assert fig["layout"]["xaxis"]["categoryarray"] == ("a", "b", "c", "d")


def test_no_default_category_array_for_single_trace_or_numeric():
# A single trace already follows data order, and numeric axes
# must never get a category array.
fig = px.line(x=["A", "B", "C"], y=[1, 2, 3])
assert fig["layout"]["xaxis"]["categoryorder"] is None
fig = px.line(x=[1, 2, 3], y=[1, 2, 3], color=["a", "b", "a"])
assert fig["layout"]["xaxis"]["categoryorder"] is None


def test_permissive_defaults():
msg = "'PxDefaults' object has no attribute 'should_not_work'"
with pytest.raises(AttributeError, match=msg):
Expand Down
Loading