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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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 `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 color utilities mutating the list of colors passed to them [[#5836](https://github.com/plotly/plotly.py/pull/5836)], with thanks to @Enesuygurs 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, on single-subplot figures, on each facet of faceted figures and in 3D figures, while marginal axes spanning the y dimension are left alone [[#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
106 changes: 105 additions & 1 deletion plotly/express/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2530,6 +2530,66 @@ def get_groups_and_orders(args, grouper):
return groups, orders


def _default_x_category_order(args, groups, orders, constructor):
"""
Return the data-appearance order of the categorical x column, or None.

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 the axis configurators 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 only applies to string categoricals the user did not order
explicitly, to figures split into more than one trace, and to the x axis:

- Cartesian figures with facets or marginals apply the default per axis
in `make_figure`: each x axis gets the whole-data-frame order
restricted to the categories carried by the traces on its subplot,
exactly the order its single-subplot counterpart would use with the
categories absent from the subplot dropped. Shared categories keep the
same relative positions on matched facet axes, and the documented
independent-axes technique (disabling `matches`, see
`doc/python/facet-plots.md`) still lists each axis only its own
categories. The right-hand marginal of `marginal_y` spans the y
dimension and carries the binned y values, so it never receives the x
order.

- `px.ecdf` sorts the x values inside each trace, so its categorical
axis must keep the trace-derived order for the step function to read
in sequence.

- `px.scatter_3d` has a single scene, so the whole-data-frame order is
applied through the per-column mechanism in `configure_3d_axes`.

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 and constructor is not go.Scatter3d:
return None
if len(groups) <= 1:
return None
if args.get("ecdfmode") is not None:
return None
df = args["data_frame"]
col = args.get("x")
if not isinstance(col, str) or col in orders or col not in df.columns:
return None
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):
return categories
return None


def make_figure(args, constructor, trace_patch=None, layout_patch=None):
trace_patch = trace_patch or {}
layout_patch = layout_patch or {}
Expand All @@ -2556,7 +2616,25 @@ 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)

default_x_categories = _default_x_category_order(args, groups, orders, constructor)
per_axis_order = (
default_x_categories is not None
and constructor in cartesians
and (
args.get("facet_row") is not None
or args.get("facet_col") is not None
or args.get("marginal_x") is not None
or args.get("marginal_y") is not None
)
)
if default_x_categories is not None and not per_axis_order:
# The figure has a single x axis spanning the whole data frame (a
# plain cartesian subplot or a 3D scene), so the per-column
# mechanism in `set_cartesian_axis_opts` / `configure_3d_axes`
# applies the data order directly.
orders[args["x"]] = default_x_categories

trace_axes_rows = []
col_labels = []
row_labels = []
nrows = ncols = 1
Expand Down Expand Up @@ -2605,6 +2683,11 @@ def make_figure(args, constructor, trace_patch=None, layout_patch=None):
for trace_spec in trace_specs:
# Create the trace
trace = trace_spec.constructor(name=trace_name)
if per_axis_order:
# Remember the rows feeding this trace so the data order can
# be computed per x axis once the traces are placed on their
# subplots.
trace_axes_rows.append((trace, group, trace_spec.marginal))
if trace_spec.constructor not in [
go.Parcats,
go.Parcoords,
Expand Down Expand Up @@ -2853,6 +2936,27 @@ def make_figure(args, constructor, trace_patch=None, layout_patch=None):
fig._px_trendlines = []

configure_axes(args, constructor, fig, orders)
if per_axis_order:
# Each x axis gets the whole-data-frame order restricted to the
# categories carried by the traces placed on it: the same order its
# single-subplot counterpart would use, with the categories absent
# from the subplot dropped, so matched facet axes agree on the
# relative positions of shared categories. Marginal axes spanning
# the y dimension do not carry the x column and get no default.
axis_x_values = {}
for trace, group, marginal in trace_axes_rows:
xaxis_id = getattr(trace, "xaxis", None)
if xaxis_id is None or marginal == "y":
continue
axis_x_values.setdefault(xaxis_id, set()).update(
group.get_column(args["x"]).to_list()
)
for xaxis_id, values in axis_x_values.items():
categories = [value for value in default_x_categories if value in values]
if categories:
axis = fig.layout["xaxis" + xaxis_id[1:]]
axis["categoryorder"] = "array"
axis["categoryarray"] = categories
configure_animation_controls(args, constructor, fig)
return fig

Expand Down
196 changes: 196 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,202 @@ 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_default_category_order_applies_to_facets():
# https://github.com/plotly/plotly.py/pull/5776#discussion_r4429048272
# A facet must not change the x order relative to the same chart without
# facets: every facet axis follows the data order of its own rows, and
# the y axes stay untouched.
df = pd.DataFrame(
{
"month": ["Jan", "Feb", "Mar", "Apr", "Jan", "Feb", "Mar", "Apr"],
"product": [
"Basic",
"Basic",
"Pro",
"Basic",
"Basic",
"Basic",
"Pro",
"Basic",
],
"region": ["East", "East", "East", "East", "West", "West", "West", "West"],
"units": [10, 12, 8, 15, 11, 13, 9, 16],
}
)
fig = px.bar(df, x="month", y="units", color="product")
assert fig["layout"]["xaxis"]["categoryorder"] == "array"
assert fig["layout"]["xaxis"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
fig = px.bar(df, x="month", y="units", color="product", facet_col="region")
assert fig["layout"]["xaxis"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
assert fig["layout"]["xaxis2"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
fig = px.bar(df, x="month", y="units", color="product", facet_row="region")
assert fig["layout"]["xaxis"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
assert fig["layout"]["xaxis2"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
assert fig["layout"]["yaxis"]["categoryorder"] is None
assert fig["layout"]["yaxis2"]["categoryorder"] is None
# numeric axes never get a category array, faceted or not
fig = px.bar(df, x="units", y="units", color="product", facet_col="region")
assert fig["layout"]["xaxis"]["categoryorder"] is None
assert fig["layout"]["xaxis2"]["categoryorder"] is None


def test_facet_axes_list_their_own_categories_in_data_order():
df = pd.DataFrame(
{
"region": ["E", "E", "W", "E", "W"],
"cat": ["a", "b", "a", "c", "b"],
"kind": ["p", "q", "q", "q", "p"],
"val": [1, 2, 3, 4, 5],
}
)
fig = px.bar(df, x="cat", y="val", color="kind", facet_col="region")
# each facet lists only its own categories, in the order of the whole
# data frame: the trace-derived order scrambles the second facet because
# its "kind=p" trace carries "b" and is created before its "kind=q" trace
assert fig["layout"]["xaxis"]["categoryarray"] == ("a", "b", "c")
assert fig["layout"]["xaxis2"]["categoryarray"] == ("a", "b")


def test_default_category_order_with_marginals():
# The top marginal of `marginal_x` shares the x dimension with the main
# subplot and gets the same data order. The right marginal of
# `marginal_y` spans the y dimension and carries the binned y values, so
# it must not receive the x order.
df = pd.DataFrame(
{
"month": ["Jan", "Feb", "Mar", "Apr", "Jan", "Feb", "Mar", "Apr"],
"product": [
"Basic",
"Basic",
"Pro",
"Basic",
"Basic",
"Basic",
"Pro",
"Basic",
],
"units": [10, 12, 8, 15, 11, 13, 9, 16],
}
)
fig = px.scatter(df, x="month", y="units", color="product", marginal_x="rug")
assert fig["layout"]["xaxis"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
assert fig["layout"]["xaxis2"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
fig = px.scatter(df, x="month", y="units", color="product", marginal_y="rug")
assert fig["layout"]["xaxis"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
assert fig["layout"]["xaxis2"]["categoryarray"] is None


def test_default_category_order_for_3d():
df = pd.DataFrame(
{
"month": ["Jan", "Feb", "Mar", "Apr", "Jan", "Feb", "Mar", "Apr"],
"product": [
"Basic",
"Basic",
"Pro",
"Basic",
"Basic",
"Basic",
"Pro",
"Basic",
],
"units": [10, 12, 8, 15, 11, 13, 9, 16],
}
)
fig = px.scatter_3d(df, x="month", y="units", z="units", color="product")
assert fig["layout"]["scene"]["xaxis"]["categoryorder"] == "array"
assert fig["layout"]["scene"]["xaxis"]["categoryarray"] == (
"Jan",
"Feb",
"Mar",
"Apr",
)
assert fig["layout"]["scene"]["yaxis"]["categoryorder"] is None
assert fig["layout"]["scene"]["zaxis"]["categoryorder"] is None


def test_no_default_category_array_for_ecdf():
# px.ecdf sorts the x values inside each trace, so its categorical axis
# must keep the trace-derived order for the step function to read in
# sequence.
df = pd.DataFrame(
{"x": ["b", "a", "c", "a", "b", "c"], "g": ["p", "p", "p", "q", "q", "q"]}
)
fig = px.ecdf(df, x="x", color="g")
assert fig["layout"]["xaxis"]["categoryorder"] is None
assert fig["layout"]["xaxis"]["categoryarray"] is None


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