From 290c109497a54591965fa780cb208553c34e99c6 Mon Sep 17 00:00:00 2001 From: Caner Olcay <118784594+Irahan2@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:58:14 +0200 Subject: [PATCH] Make px hierarchy sector order deterministic for Polars input process_dataframe_hierarchy groups each level of the path with group_by, whose output order is not guaranteed: pandas and PyArrow return groups in order of first appearance, but Polars returns them in a different order on every run. Track the original row index, aggregate its minimum per group and sort each level by it, so that sunburst, treemap and icicle sectors follow their order of first appearance for all dataframe backends. Closes #5765 --- CHANGELOG.md | 1 + plotly/express/_core.py | 9 +++++-- .../test_px/test_px_functions.py | 26 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7107261c9f2..0583fec45c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,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! ## [7.1.0] - 2026-09-15 diff --git a/plotly/express/_core.py b/plotly/express/_core.py index 027df9e5aac..b354ed6f8f9 100644 --- a/plotly/express/_core.py +++ b/plotly/express/_core.py @@ -1958,7 +1958,10 @@ def process_dataframe_hierarchy(args): _check_dataframe_all_leaves(df[path[::-1]]) discrete_color = not _is_continuous(df, args["color"]) if args["color"] else False - df = df.lazy() + # Keep track of the original row order, so that the sectors can be sorted by + # first appearance after each group_by (Polars' group_by does not keep order). + row_index_colname = _generate_temporary_column_name(n_bytes=16, columns=df.columns) + df = df.with_row_index(row_index_colname).lazy() new_path = [col_name + "_path_copy" for col_name in path] df = df.with_columns( @@ -1997,6 +2000,7 @@ def process_dataframe_hierarchy(args): # Since count_colname is always in agg_f, it can be used later to normalize color # in the continuous case after some gymnastic agg_f[count_colname] = nw.sum(count_colname) + agg_f[row_index_colname] = nw.min(row_index_colname) discrete_aggs = [] continuous_aggs = [] @@ -2049,7 +2053,7 @@ def process_dataframe_hierarchy(args): agg_f[args["color"]] = nw.sum(args["color"]) # Other columns (for color, hover_data, custom_data etc.) - cols = list(set(df.collect_schema().names()).difference(path)) + cols = list(set(df.collect_schema().names()).difference([*path, row_index_colname])) df = df.with_columns(nw.col(c).cast(nw.String()) for c in cols if c not in agg_f) for col in cols: # for hover_data, custom_data etc. @@ -2092,6 +2096,7 @@ def post_agg(dframe: nw.LazyFrame, continuous_aggs, discrete_aggs) -> nw.LazyFra dfg = ( df.group_by(path[i:], drop_null_keys=True) .agg(**agg_f) + .sort(row_index_colname) .pipe(post_agg, continuous_aggs, discrete_aggs) ) diff --git a/tests/test_optional/test_px/test_px_functions.py b/tests/test_optional/test_px/test_px_functions.py index fed8183acab..03712486581 100644 --- a/tests/test_optional/test_px/test_px_functions.py +++ b/tests/test_optional/test_px/test_px_functions.py @@ -233,6 +233,32 @@ def test_sunburst_treemap_with_path(constructor): assert fig.data[0].values[-1] == 8 +@pytest.mark.parametrize("px_fn", [px.sunburst, px.treemap, px.icicle]) +def test_sunburst_treemap_with_path_order(constructor, px_fn): + # Sectors should follow the order of first appearance in the data, whatever + # the dataframe backend (Polars' group_by does not keep the row order). + df = constructor( + dict( + regions=["South", "North", "South", "West", "North", "West"], + sectors=["Tech", "Finance", "Finance", "Tech", "Tech", "Finance"], + values=[1, 2, 3, 4, 5, 6], + ) + ) + fig = px_fn(df, path=["regions", "sectors"], values="values") + assert list(fig.data[0].ids) == [ + "South/Tech", + "North/Finance", + "South/Finance", + "West/Tech", + "North/Tech", + "West/Finance", + "South", + "North", + "West", + ] + assert list(fig.data[0].values) == [1, 2, 3, 4, 5, 6, 4, 7, 10] + + def test_sunburst_treemap_with_path_and_hover(backend): df = px.data.tips(return_type=backend) fig = px.sunburst(