Describe the issue
Joining two dask DataFrames on the index raises
ValueError: columns overlap but no suffix specified: MultiIndex([('_partitions', '')])
when both frames have MultiIndex columns and the right side has two or more partitions with unknown divisions. That combination lowers the join to the hash-shuffle merge path (RearrangeByColumn). The shuffle assigns a helper "_partitions" column, but with MultiIndex columns pandas stores that label as the tuple ("_partitions", ""). The drop-back step compares labels against the plain string, so the helper column is never removed, reaches the final per-partition pandas.merge on both sides, and collides.
The same join succeeds with single-level columns, and with MultiIndex columns when the right side has one partition (broadcast path), so the failure is specific to the shuffle path plus MultiIndex columns.
The real-world trigger was groupby(...).agg(["median"], split_out=2), which produces MultiIndex columns, multiple partitions, and unknown divisions at once, followed by a join onto another aggregated frame.
Minimal Complete Verifiable Example
import numpy as np
import pandas as pd
import dask.dataframe as dd
mi_l = pd.MultiIndex.from_tuples([("a", "median"), ("b", "median")])
mi_r = pd.MultiIndex.from_tuples([("c", "median"), ("d", "median")])
idx = pd.RangeIndex(6, name="id")
left = dd.from_pandas(
pd.DataFrame(np.arange(12).reshape(6, 2), index=idx, columns=mi_l),
npartitions=1,
)
# Two partitions with unknown divisions force the shuffle-merge path.
right = dd.from_pandas(
pd.DataFrame(np.arange(12).reshape(6, 2), index=idx, columns=mi_r),
npartitions=2,
).clear_divisions()
left.join(right).compute() # ValueError
Controls that pass with the same shapes:
# single-level columns, 2 partitions, unknown divisions
dd.from_pandas(pd.DataFrame(np.arange(12).reshape(6, 2), index=idx, columns=["a", "b"]), npartitions=1).join(
dd.from_pandas(pd.DataFrame(np.arange(12).reshape(6, 2), index=idx, columns=["c", "d"]), npartitions=2).clear_divisions()
).compute()
# MultiIndex columns, right side has 1 partition
left.join(right.repartition(npartitions=1)).compute()
Anything else we need to know?
The string "_partitions" is compared against column labels in two places in dask/dataframe/dask_expr/_shuffle.py, and both miss the tuple label that MultiIndex columns produce:
RearrangeByColumn._lower, the drop-back after the shuffle:
return shuffled[
[c for c in shuffled.columns if c not in ["_partitions", "_partitions_0"]]
]
ShuffleBase._simplify_up, the projection push-down, which keeps a column only if col in partitioning_index or col in projection. Fixing only the drop-back is not enough: the push-down then drops the helper before the shuffle runs and _shuffle_group fails with KeyError: '_partitions'.
A fix that treats a tuple label as a match when its first level is one of the helper names, applied in both places, makes the MCVE pass along with three-level columns, both sides multi-partition, a real groupby(...).agg(["median"], split_out=2) source, and a column-key merge between MultiIndex frames, and leaves test_shuffle.py and test_merge.py green. I am happy to open a PR with that change and a MultiIndex regression test.
Unrelated but noticed while testing: a real user column literally named _partitions is silently dropped by any shuffle, before and after such a fix.
The code on main today is unchanged from 2026.8.0 in this file, so the bug is present there as well. Searched for the error message together with _partitions and MultiIndex and found no existing report.
Environment
- Dask version: 2026.8.0 (also reproduces against current
main)
- pandas version: 3.0.5
- numpy version: 2.5.3
- Python version: 3.12.14
- Operating System: Linux
- Install method: conda-forge
- Scheduler: default threaded scheduler, task-based shuffle
Describe the issue
Joining two dask DataFrames on the index raises
when both frames have MultiIndex columns and the right side has two or more partitions with unknown divisions. That combination lowers the join to the hash-shuffle merge path (
RearrangeByColumn). The shuffle assigns a helper"_partitions"column, but with MultiIndex columns pandas stores that label as the tuple("_partitions", ""). The drop-back step compares labels against the plain string, so the helper column is never removed, reaches the final per-partitionpandas.mergeon both sides, and collides.The same join succeeds with single-level columns, and with MultiIndex columns when the right side has one partition (broadcast path), so the failure is specific to the shuffle path plus MultiIndex columns.
The real-world trigger was
groupby(...).agg(["median"], split_out=2), which produces MultiIndex columns, multiple partitions, and unknown divisions at once, followed by a join onto another aggregated frame.Minimal Complete Verifiable Example
Controls that pass with the same shapes:
Anything else we need to know?
The string
"_partitions"is compared against column labels in two places indask/dataframe/dask_expr/_shuffle.py, and both miss the tuple label that MultiIndex columns produce:RearrangeByColumn._lower, the drop-back after the shuffle:ShuffleBase._simplify_up, the projection push-down, which keeps a column only ifcol in partitioning_index or col in projection. Fixing only the drop-back is not enough: the push-down then drops the helper before the shuffle runs and_shuffle_groupfails withKeyError: '_partitions'.A fix that treats a tuple label as a match when its first level is one of the helper names, applied in both places, makes the MCVE pass along with three-level columns, both sides multi-partition, a real
groupby(...).agg(["median"], split_out=2)source, and a column-key merge between MultiIndex frames, and leavestest_shuffle.pyandtest_merge.pygreen. I am happy to open a PR with that change and a MultiIndex regression test.Unrelated but noticed while testing: a real user column literally named
_partitionsis silently dropped by any shuffle, before and after such a fix.The code on
maintoday is unchanged from 2026.8.0 in this file, so the bug is present there as well. Searched for the error message together with_partitionsand MultiIndex and found no existing report.Environment
main)