Skip to content

Fix px scrambling categorical axis order when data is split into traces - #5776

Open
vineoy wants to merge 5 commits into
plotly:mainfrom
vineoy:px-category-order-3198
Open

vineoy wants to merge 5 commits into
plotly:mainfrom
vineoy:px-category-order-3198

Conversation

@vineoy

@vineoy vineoy commented Sep 27, 2026 •

Copy link
Copy Markdown

Link to issue

Fixes #3198, fixes #3291.

Description of change

When Plotly Express splits data into several traces (e.g. via color), plotly.js orders categories by first appearance across traces (default categoryorder). Because each trace only carries its own rows (None-padded), that order can differ from the order in the underlying data, contradicting the documented behavior that Plotly Express lays out categorical data in the order in which it appears in the data.

This change records the data-appearance order of the string categorical x column in make_figure (new _default_category_orders_for_axes helper in plotly/express/_core.py), but only when the figure has a single subplot, the data is actually split into more than one trace, and the user did not supply an explicit category_orders entry. The existing set_cartesian_axis_opts then emits categoryorder=array + categoryarray, so:

Explicit user category_orders, single-trace figures, figures with facets (their axes keep listing their own categories, so the independent-axes technique in doc/python/facet-plots.md still works), figures with marginals (their extra subplots span the y dimension), and numeric/datetime axes are untouched.

Testing strategy

  • New regression tests in tests/test_optional/test_px/test_px.py: data-order assertion for Bug when using categorical axes and colors in plotly.express #3198 and Plotly Express doesn't force category orders on axes #3291 repros, guard tests that single-trace and numeric-axis figures get no categoryarray, the unlinked facet axes test from the review, and a guard test that faceted and marginal figures get no categoryarray on any axis.
  • CHANGELOG.md entry added under Unreleased/Fixed.
  • Local runs: new tests pass; full test_px.py (334 passed; 3 test_render_mode failures are pre-existing missing-statsmodels env gaps, verified identical on clean main); test_px_functions/test_facets/test_px_wide/test_px_input failure sets identical to clean main (pre-existing env gaps: pyarrow timezones); pinned ruff==0.11.12 format --check clean on all files.

@camdecoster

Copy link
Copy Markdown
Contributor

Thanks for the PR! Our team will review and follow up.

@chrikrah chrikrah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vineoy blocking: with this branch, the discrete-color Gantt example in doc/python/gantt.md:69-76 lists its tasks bottom-up, the opposite of what that page promises.

# df = the three-task Resource frame at doc/python/gantt.md:69-73
>>> px.timeline(df, x_start="Start", x_end="Finish", y="Task").layout.yaxis.categoryarray
None                          # 85cb798 and main d586d22
>>> px.timeline(df, x_start="Start", x_end="Finish", y="Task", color="Resource").layout.yaxis.categoryarray
('Job C', 'Job B', 'Job A')   # 85cb798
None                          # main d586d22
>>> px.bar(x=[1,2,3,4], y=list("abcd"), color=list("ppqq"), orientation="h").layout.yaxis.categoryarray
('d', 'c', 'b', 'a')          # 85cb798
None                          # main d586d22

The page pairs that call with autorange="reversed" so tasks read top-down. The new default goes through the y reversal at _core.py:639, and the two reversals cancel. A colored horizontal bar flips the same way, even where the groups do not interleave. #3291 asked for the x order to be set internally, and it said nothing about y. Two ways out:

  1. Apply the default to x only.
  2. Keep y, and store its default so that the reversal at _core.py:639 gives back the bottom-up order.

The x-axis fix itself holds: the two data-order tests fail with _core.py from main and pass on the branch.

$ pytest -q tests/test_optional/test_px/test_px.py      # 85cb798
3 failed, 331 passed, 4 warnings in 29.64s
$ pytest -q tests/test_optional/test_px/test_px.py      # main d586d22
3 failed, 328 passed, 4 warnings in 27.27s
$ cp <main>/plotly/express/_core.py plotly/express/ && pytest -q tests/test_optional/test_px/test_px.py -k "data_order or no_default_category"
2 failed, 1 passed, 331 deselected, 4 warnings in 0.82s

The three failures on both commits are test_render_mode, which needs statsmodels.

@camdecoster would you take fix 1 or fix 2 here?

vineoy added a commit to vineoy/plotly.py that referenced this pull request Oct 10, 2026
Address review on plotly#5776: y categoricals stay untouched so the
autorange=reversed pattern from doc/python/gantt.md keeps listing
tasks top-down instead of double-reversing.

Adds guard test that px.timeline and horizontal px.bar with color emit
no y categoryarray, while x data-order fix for plotly#3198/plotly#3291 is kept.
@vineoy

vineoy commented Oct 10, 2026

Copy link
Copy Markdown
Author

Thanks @chrikrah for catching the y axis double reversal.

I took fix 1 and scoped the default to x only. y categoricals are now untouched, so the autorange reversed pattern in doc/python/gantt.md keeps listing tasks top down.

Pushed d6ddc2a. It limits _default_category_orders_for_axes to x with a note on why y is excluded, keeps the x data order fix for #3198 and #3291, adds test_no_default_category_array_for_y_with_color for px.timeline and horizontal px.bar with color, and updates CHANGELOG to say x axis.

Verified locally. The x repros still give ('A','B','C','D','E') and ('a','b','c','d'). The y timeline and y bar h cases now give None, matching main. test_px.py gives 3 failed (pre existing test_render_mode statsmodels gap) and 332 passed. Ruff check is clean.

@camdecoster please let me know if you would rather have fix 2.

Address review on plotly#5776: y categoricals stay untouched so the
autorange=reversed pattern from doc/python/gantt.md keeps listing
tasks top-down instead of double-reversing.

Adds guard test that px.timeline and horizontal px.bar with color emit
no y categoryarray, while x data-order fix for plotly#3198/plotly#3291 is kept.
@vineoy
vineoy force-pushed the px-category-order-3198 branch from d6ddc2a to 16ab376 Compare October 10, 2026 12:36
@vineoy

vineoy commented Oct 10, 2026

Copy link
Copy Markdown
Author

Update. I rebased this branch onto the latest main and resolved the CHANGELOG conflict by keeping the current Unreleased entries and adding the x axis entry for #3198 and #3291. The branch is mergeable again. Targeted tests pass (7 passed) and ruff check is clean. Ready for re review.

@chrikrah chrikrah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vineoy the Gantt flip is fixed at 16ab376, so I am approving. The y axis gets no default order, and the x data-order fix still holds.

# df = the three-task Resource frame at doc/python/gantt.md:69-73
>>> px.timeline(df, x_start="Start", x_end="Finish", y="Task", color="Resource").layout.yaxis.categoryarray
None                    # 16ab376 and main 3f8a650
>>> px.bar(x=[1,2,3,4], y=list("abcd"), color=list("ppqq"), orientation="h").layout.yaxis.categoryarray
None                    # 16ab376 and main 3f8a650
>>> px.bar(x=list("abcd"), y=[1,2,3,4], color=list("pqpq")).layout.xaxis.categoryarray
('a', 'b', 'c', 'd')    # 16ab376
None                    # main 3f8a650

The uncolored timeline stays None on both. Each new test also fails without its part of the change:

$ pytest -q tests/test_optional/test_px/test_px.py      # head 16ab376
3 failed, 332 passed, 4 warnings in 33.23s
$ pytest -q tests/test_optional/test_px/test_px.py      # main 3f8a650
3 failed, 328 passed, 4 warnings in 28.49s
$ cp <3f8a650>/plotly/express/_core.py plotly/express/ && pytest -q tests/test_optional/test_px/test_px.py -k "data_order or no_default_category"
2 failed, 2 passed, 331 deselected, 4 warnings in 0.86s    # both data_order tests
# the x-and-y loop from 85cb798 spliced into _core.py, same -k
1 failed, 3 passed, 331 deselected, 4 warnings in 0.83s    # test_no_default_category_array_for_y_with_color
# Python 3.12, pytest 9.1.1; the 3 failures on both commits are test_render_mode, which needs statsmodels
# not run: the rest of tests/test_optional

non-blocking: CHANGELOG.md now has ## [7.1.0] - 2026-09-15 twice, at lines 22 and 24. The new entry sits under Unreleased and an empty 7.1.0 section follows it. Drop the added heading.

@camdecoster from my side this is ready to merge. The y axis can follow in its own PR if you want it.

@vineoy

vineoy commented Oct 12, 2026

Copy link
Copy Markdown
Author

Thanks @chrikrah for verifying. I removed the duplicated 7.1.0 heading in CHANGELOG, so the new entry now sits directly under Unreleased followed by a single 7.1.0 section.

One note. Your review reads as an approval in text but GitHub recorded it as a comment, so the PR still shows review required. Could you mark it approved when you get a chance, if you are happy with the current state.

@chrikrah chrikrah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vineoy I'd merge 55d5c75. Since 16ab376 it only drops the second ## [7.1.0] line, so CHANGELOG.md reads Unreleased, then one 7.1.0 section.

$ git diff --stat 16ab376 55d5c75
 CHANGELOG.md | 2 --
$ pytest -q -p no:cacheprovider tests/test_optional/test_px/test_px.py     # head 55d5c75, Python 3.12, pytest 9.1.1
3 failed, 332 passed, 4 warnings       # the 3 are test_render_mode, statsmodels not installed
$ cp <3f8a650>/plotly/express/_core.py plotly/express/ && pytest -q -p no:cacheprovider tests/test_optional/test_px/test_px.py -k "data_order or no_default_category"
2 failed, 2 passed, 331 deselected     # both data_order tests
$ git merge-tree --write-tree 3f8a650 55d5c75     # main has not moved
296ca7b5d8f45aba1536e1c72609fcada2099fac         # exit 0, no conflict

The badge won't move for this, though. My account has no write access here, so GitHub leaves my review out of the required count whatever flag it carries, and only a maintainer clears it. @camdecoster nothing is open on my side. Would you rather see the y axis default handled here or in a follow-up PR?

@vineoy

vineoy commented Oct 12, 2026

Copy link
Copy Markdown
Author

Thanks @chrikrah for the extra verification.

@camdecoster on the y axis question, my preference is a follow up PR so this one stays minimal and scoped to the x axis fix for #3198 and #3291. Happy to pick up the y axis separately if you want it. This PR is ready from my side.

@KoolADE85 KoolADE85 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the submission here!
In addition to the breaking change that I found, there are some formatting issues revealed by the CI jobs that will need to be fixed. Please also resolve the trivial CHANGELOG conflict.
Cheers!

Comment thread tests/test_optional/test_px/test_px.py Resolved
Address review on plotly#5776: facet axes must keep listing their own categories (the matches=None technique in doc/python/facet-plots.md) and marginal axes span the y dimension, so the data-wide default must not reach any axis of a faceted or marginal figure. Adds the unlinked facet axes test plus a subplot guard test, limits the changelog entry, and applies ruff format to the touched files.
Keep main's new Unreleased changelog entries and place the Plotly Express x-axis entry at the end of the Fixed list.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plotly Express doesn't force category orders on axes Bug when using categorical axes and colors in plotly.express

4 participants