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 @@ -17,6 +17,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Fix Plotly Express mutating lists passed to the `x` or `y` arguments in wide mode [[#5727](https://github.com/plotly/plotly.py/pull/5727)], with thanks to @cpruijsen 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 [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution!
- Fix `mpl_to_plotly` conversion of matplotlib contour lines and line collections: close contour rings ending with `Z` codes, support dash styles, convert date x-axes, separate disjoint subpaths with `None` separators, hide line collection traces from the legend by default, and group consecutive same-style lines into single traces [[#5770](https://github.com/plotly/plotly.py/pull/5770)], with thanks to @robertoffmoura for the contribution!
- Fix Plotly Express scrambling the order of the categorical x-axis when the data is split into several traces (e.g. by `color`); the x-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
36 changes: 36 additions & 0 deletions plotly/express/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2530,6 +2530,41 @@ def get_groups_and_orders(args, grouper):
return groups, orders


def _default_category_orders_for_axes(args, groups, orders):
"""
Add data-appearance order for the categorical x column.

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.

Only the x axis is handled: y categoricals are intentionally left alone
because `set_cartesian_axis_opts` reverses y orders for top-down display
and figures such as `px.timeline` / horizontal `px.bar` pair that with
`autorange="reversed"` (see `doc/python/gantt.md`), so an explicit y
`categoryarray` would double-reverse and flip the documented order.
"""
if len(groups) <= 1:
return orders
df = args["data_frame"]
col = args.get("x")
if not isinstance(col, str) or col in orders or col not in df.columns:
return orders
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 @@ -2556,6 +2591,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
51 changes: 51 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,57 @@ 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_no_default_category_array_for_y_with_color():
# https://github.com/plotly/plotly.py/pull/5776#pullrequestreview-5478271667
# y categoricals must stay untouched so the `autorange="reversed"`
# pattern from doc/python/gantt.md keeps listing tasks top-down.
df = pd.DataFrame(
[
dict(Task="Job A", Start="2009-01-01", Finish="2009-02-28", Resource="Alex"),
dict(Task="Job B", Start="2009-03-05", Finish="2009-04-15", Resource="Alex"),
dict(Task="Job C", Start="2009-02-20", Finish="2009-05-30", Resource="Max"),
]
)
fig = px.timeline(df, x_start="Start", x_end="Finish", y="Task", color="Resource")
assert fig["layout"]["yaxis"]["categoryorder"] is None
assert fig["layout"]["yaxis"]["categoryarray"] is None
fig = px.bar(x=[1, 2, 3, 4], y=list("abcd"), color=list("ppqq"), orientation="h")
assert fig["layout"]["yaxis"]["categoryorder"] is None
assert fig["layout"]["yaxis"]["categoryarray"] is None


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