Skip to content

Commit 290c109

Browse files
committed
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
1 parent 01fe2b8 commit 290c109

3 files changed

Lines changed: 34 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
66

77
### Fixed
88
- 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!
9+
- 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!
910

1011

1112
## [7.1.0] - 2026-09-15

plotly/express/_core.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1958,7 +1958,10 @@ def process_dataframe_hierarchy(args):
19581958
_check_dataframe_all_leaves(df[path[::-1]])
19591959
discrete_color = not _is_continuous(df, args["color"]) if args["color"] else False
19601960

1961-
df = df.lazy()
1961+
# Keep track of the original row order, so that the sectors can be sorted by
1962+
# first appearance after each group_by (Polars' group_by does not keep order).
1963+
row_index_colname = _generate_temporary_column_name(n_bytes=16, columns=df.columns)
1964+
df = df.with_row_index(row_index_colname).lazy()
19621965

19631966
new_path = [col_name + "_path_copy" for col_name in path]
19641967
df = df.with_columns(
@@ -1997,6 +2000,7 @@ def process_dataframe_hierarchy(args):
19972000
# Since count_colname is always in agg_f, it can be used later to normalize color
19982001
# in the continuous case after some gymnastic
19992002
agg_f[count_colname] = nw.sum(count_colname)
2003+
agg_f[row_index_colname] = nw.min(row_index_colname)
20002004

20012005
discrete_aggs = []
20022006
continuous_aggs = []
@@ -2049,7 +2053,7 @@ def process_dataframe_hierarchy(args):
20492053
agg_f[args["color"]] = nw.sum(args["color"])
20502054

20512055
# Other columns (for color, hover_data, custom_data etc.)
2052-
cols = list(set(df.collect_schema().names()).difference(path))
2056+
cols = list(set(df.collect_schema().names()).difference([*path, row_index_colname]))
20532057
df = df.with_columns(nw.col(c).cast(nw.String()) for c in cols if c not in agg_f)
20542058

20552059
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
20922096
dfg = (
20932097
df.group_by(path[i:], drop_null_keys=True)
20942098
.agg(**agg_f)
2099+
.sort(row_index_colname)
20952100
.pipe(post_agg, continuous_aggs, discrete_aggs)
20962101
)
20972102

tests/test_optional/test_px/test_px_functions.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,32 @@ def test_sunburst_treemap_with_path(constructor):
233233
assert fig.data[0].values[-1] == 8
234234

235235

236+
@pytest.mark.parametrize("px_fn", [px.sunburst, px.treemap, px.icicle])
237+
def test_sunburst_treemap_with_path_order(constructor, px_fn):
238+
# Sectors should follow the order of first appearance in the data, whatever
239+
# the dataframe backend (Polars' group_by does not keep the row order).
240+
df = constructor(
241+
dict(
242+
regions=["South", "North", "South", "West", "North", "West"],
243+
sectors=["Tech", "Finance", "Finance", "Tech", "Tech", "Finance"],
244+
values=[1, 2, 3, 4, 5, 6],
245+
)
246+
)
247+
fig = px_fn(df, path=["regions", "sectors"], values="values")
248+
assert list(fig.data[0].ids) == [
249+
"South/Tech",
250+
"North/Finance",
251+
"South/Finance",
252+
"West/Tech",
253+
"North/Tech",
254+
"West/Finance",
255+
"South",
256+
"North",
257+
"West",
258+
]
259+
assert list(fig.data[0].values) == [1, 2, 3, 4, 5, 6, 4, 7, 10]
260+
261+
236262
def test_sunburst_treemap_with_path_and_hover(backend):
237263
df = px.data.tips(return_type=backend)
238264
fig = px.sunburst(

0 commit comments

Comments
 (0)