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 @@ -23,6 +23,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- 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 serialization of box/lasso select data for figures with multiple subplots [[#5710](https://github.com/plotly/plotly.py/pull/5710)], with thanks to @flpi-sun for the contribution!
- Fix `plotly.io.write_images` ignoring `layout.width`/`layout.height` (and the template's) and exporting every figure at the default 700x500 size; it now uses the same width/height priority as `write_image` and `to_image` [[#5784](https://github.com/plotly/plotly.py/pull/5784)], with thanks to @breken-ai for the contribution!
- Fix Plotly Express scrambling the order of the categorical x-axis in single-subplot figures 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, while faceted and marginal figures are unchanged [[#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
55 changes: 55 additions & 0 deletions plotly/express/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2530,6 +2530,60 @@ def get_groups_and_orders(args, grouper):
return groups, orders


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

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.

The default is only emitted for figures with a single subplot, so the one
x axis it touches spans the whole data-frame:

- With facets each subplot only carries its own categories and the
documented independent-axes technique (disabling `matches`, see
`doc/python/facet-plots.md`) must keep listing each axis only its own
categories, which an explicit data-wide `categoryarray` would break. See
https://github.com/plotly/plotly.py/pull/5776#pullrequestreview-5701715902

- With marginals the extra subplots span the y dimension, so an explicit
x `categoryarray` on their axes would be wrong.

Figures with subplots could get per-axis data orders in a follow up.

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 constructor not in cartesians:
return orders
if len(groups) <= 1:
return orders
if args.get("facet_row") or args.get("facet_col"):
return orders
if args.get("marginal_x") or args.get("marginal_y"):
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 +2610,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, constructor)

col_labels = []
row_labels = []
Expand Down
101 changes: 101 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,107 @@ 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


Comment thread
vineoy marked this conversation as resolved.
Resolved
def test_unlinked_facet_axes_only_list_their_own_categories():
df = pd.DataFrame(
{
"panel": ["left", "right", "left", "right", "left"],
"fruit": ["apple", "banana", "cherry", "date", "elderberry"],
"sold": [5, 3, 4, 6, 2],
}
)
fig = px.bar(df, x="fruit", y="sold", facet_col="panel")
fig.update_xaxes(matches=None)
for trace in fig.data:
axis = fig.layout["xaxis" + trace.xaxis[1:]]
assert axis.categoryarray is None or set(axis.categoryarray) <= set(trace.x)


def test_no_default_category_array_with_subplots():
# https://github.com/plotly/plotly.py/pull/5776#pullrequestreview-5701715902
# Facet axes list their own categories (the `matches=None` technique in
# doc/python/facet-plots.md), and marginal subplots span the y dimension,
# so no axis of a faceted or marginal figure may get a data-wide default.
df = pd.DataFrame(
{
"panel": ["left", "right", "left", "right", "left"],
"fruit": ["apple", "banana", "cherry", "date", "elderberry"],
"sold": [5, 3, 4, 6, 2],
}
)
fig = px.bar(df, x="fruit", y="sold", facet_col="panel")
for axname in fig.layout:
if axname.startswith("xaxis"):
assert fig.layout[axname].categoryorder is None
assert fig.layout[axname].categoryarray is None
fig = px.scatter(
df,
x="fruit",
y="sold",
color="panel",
marginal_y="rug",
marginal_x="rug",
)
for axname in fig.layout:
if axname.startswith("xaxis"):
assert fig.layout[axname].categoryorder is None
assert fig.layout[axname].categoryarray is None


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