Skip to content

Commit f7fe626

Browse files
jbrockmendelclaude
andcommitted
BUG: pivot with array-like index/columns labels (GH#35785)
In the no-values path, `cols + columns_listlike` combined the label arguments elementwise, so an ndarray/Index/Series/ExtensionArray of valid column labels raised instead of being used as labels; a Series or ExtensionArray also reached `unstack` unconverted. Convert `index` and `columns` to plain lists once and reuse them. The label check added in GH#66311 converted `index`/`values` a second time, exhausting a one-shot iterator so the body saw an empty sequence, and it rejected an unhashable entry of the `index` list even though `set_index` takes that as a column of level values. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7986b42 commit f7fe626

4 files changed

Lines changed: 187 additions & 31 deletions

File tree

doc/source/whatsnew/v3.1.0.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,7 @@ Reshaping
594594
- Bug in :meth:`DataFrame.combine` raising ``OverflowError`` when the combining function returned a value too large for the columns' common integer dtype (e.g. ``2**64``) instead of keeping the result (:issue:`66394`)
595595
- Bug in :meth:`DataFrame.melt` where ``var_name`` colliding with an ``id_vars`` column or ``value_name`` silently overwrote the affected column data instead of raising (:issue:`65654`)
596596
- Bug in :meth:`DataFrame.pivot_table` with ``margins=True`` raising ``TypeError`` when ``values`` has an :class:`ExtensionDtype` that cannot hold ``NA`` (e.g. :class:`IntervalDtype` with an integer subtype) and no ``columns`` were specified (:issue:`55484`)
597+
- Bug in :meth:`DataFrame.pivot` and :func:`pivot` raising ``TypeError`` or ``ValueError`` when ``index`` or ``columns`` was given as an array-like of column labels, e.g. a ``numpy.ndarray``, :class:`Index`, or :class:`Series`, or when ``values`` was given as an iterator of column labels (:issue:`35785`)
597598
- Bug in :meth:`DataFrame.select_dtypes` where a nullable extension dtype name such as ``"Int64"`` or ``"boolean"`` also selected numpy columns sharing the same scalar type; it now selects only columns of that extension dtype (:issue:`40234`)
598599
- Bug in :meth:`DataFrame.select_dtypes` where an interval spec giving a subtype but no ``closed`` keyword, such as ``"interval[int64]"`` or ``pd.IntervalDtype("int64")``, selected no columns; it now selects every interval column with that subtype, for any ``closed`` value (:issue:`40234`)
599600
- Bug in :meth:`DataFrame.select_dtypes` where the string form of an Arrow dtype such as ``"int64[pyarrow]"`` selected no columns and ``"float64[pyarrow]"`` selected numpy float columns; an Arrow dtype string now selects only columns of that exact dtype (:issue:`59888`)

pandas/core/frame.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13180,11 +13180,11 @@ def pivot(
1318013180

1318113181
Parameters
1318213182
----------
13183-
columns : Hashable or a sequence of the previous
13183+
columns : Hashable or a sequence of the previous, or array-like
1318413184
Column to use to make new frame's columns.
13185-
index : Hashable or a sequence of the previous, optional
13185+
index : Hashable or a sequence of the previous, or array-like, optional
1318613186
Column to use to make new frame's index. If not given, uses existing index.
13187-
values : Hashable or a sequence of the previous, optional
13187+
values : Hashable or a sequence of the previous, or array-like, optional
1318813188
Column(s) to use for populating new frame's values. If not
1318913189
specified, all remaining columns will be used and the result will
1319013190
have hierarchically indexed columns.
@@ -13211,6 +13211,12 @@ def pivot(
1321113211

1321213212
Notes
1321313213
-----
13214+
An array-like ``index`` or ``columns`` is a sequence of column labels, unlike
13215+
:meth:`DataFrame.pivot_table`, which also accepts an array of values to group
13216+
by. When ``values`` is not given, an array nested inside the ``index`` list is
13217+
a column of level values, as in :meth:`DataFrame.set_index`. ``index``,
13218+
``columns`` and ``values`` may also be given as an iterator of labels.
13219+
1321413220
For finer-tuned control, see hierarchical indexing documentation along
1321513221
with the related stack/unstack methods.
1321613222

pandas/core/reshape/pivot.py

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from pandas.core.dtypes.cast import maybe_downcast_to_dtype
1616
from pandas.core.dtypes.common import (
17+
is_hashable,
1718
is_list_like,
1819
is_nested_list_like,
1920
is_scalar,
@@ -38,6 +39,7 @@
3839
from collections.abc import (
3940
Callable,
4041
Hashable,
42+
Iterable,
4143
)
4244

4345
from pandas._typing import (
@@ -747,9 +749,9 @@ def _convert_by(by):
747749
def pivot(
748750
data: DataFrame,
749751
*,
750-
columns: IndexLabel,
751-
index: IndexLabel | lib.NoDefault = lib.no_default,
752-
values: IndexLabel | lib.NoDefault = lib.no_default,
752+
columns: IndexLabel | Iterable[Hashable],
753+
index: IndexLabel | Iterable[Hashable] | lib.NoDefault = lib.no_default,
754+
values: IndexLabel | Iterable[Hashable] | lib.NoDefault = lib.no_default,
753755
) -> DataFrame:
754756
"""
755757
Return reshaped DataFrame organized by given index / column values.
@@ -764,11 +766,11 @@ def pivot(
764766
----------
765767
data : DataFrame
766768
Input pandas DataFrame object.
767-
columns : Hashable or a sequence of the previous
769+
columns : Hashable or a sequence of the previous, or array-like
768770
Column to use to make new frame's columns.
769-
index : Hashable or a sequence of the previous, optional
771+
index : Hashable or a sequence of the previous, or array-like, optional
770772
Column to use to make new frame's index. If not given, uses existing index.
771-
values : Hashable or a sequence of the previous, optional
773+
values : Hashable or a sequence of the previous, or array-like, optional
772774
Column(s) to use for populating new frame's values. If not
773775
specified, all remaining columns will be used and the result will
774776
have hierarchically indexed columns.
@@ -795,6 +797,12 @@ def pivot(
795797
796798
Notes
797799
-----
800+
An array-like ``index`` or ``columns`` is a sequence of column labels, unlike
801+
:meth:`DataFrame.pivot_table`, which also accepts an array of values to group by.
802+
When ``values`` is not given, an array nested inside the ``index`` list is a column
803+
of level values, as in :meth:`DataFrame.set_index`. ``index``, ``columns`` and
804+
``values`` may also be given as an iterator of labels.
805+
798806
For finer-tuned control, see hierarchical indexing documentation along
799807
with the related stack/unstack methods.
800808
@@ -897,18 +905,45 @@ def pivot(
897905
...
898906
ValueError: Index contains duplicate entries, cannot reshape
899907
"""
900-
columns_listlike = com.convert_to_list_like(columns)
901-
902-
# GH#35785 without this, downstream label arithmetic raises cryptically.
903-
labels_to_check: list[tuple[str, list]] = [("columns", list(columns_listlike))]
908+
# GH#35785 normalize index/columns to plain lists once and reuse them: array-likes
909+
# of valid labels are then used as labels instead of being combined elementwise,
910+
# and a one-shot iterator is not exhausted by the check below.
911+
columns_listlike = list(com.convert_to_list_like(columns))
912+
# GH#35785 check up front so the error names the offending parameter; the
913+
# downstream set_index/unstack failure does not say which argument was wrong.
914+
labels_to_check: list[tuple[str, list]] = [("columns", columns_listlike)]
915+
916+
index_listlike: list = []
904917
if index is not lib.no_default:
905-
labels_to_check.append(("index", list(com.convert_to_list_like(index))))
918+
index_listlike = list(com.convert_to_list_like(index))
919+
index_labels = index_listlike
920+
if values is lib.no_default:
921+
# GH#35785 the set_index below takes an Index/Series/ndarray/list/iterator
922+
# entry as a column of level values rather than as a label
923+
index_labels = [
924+
label
925+
for label in index_listlike
926+
if not isinstance(label, (Index, ABCSeries, np.ndarray, list))
927+
and not lib.is_iterator(label)
928+
]
929+
labels_to_check.append(("index", index_labels))
930+
931+
if lib.is_iterator(values):
932+
# GH#35785 materialize before the check below consumes it. ``values`` is not
933+
# list-ified like index/columns: it is used for the lookup as-is, where a
934+
# named Index/Series names the resulting columns level.
935+
values = list(cast("Iterable[Hashable]", values))
906936
if values is not lib.no_default and not isinstance(values, tuple):
907937
# GH#17160 a tuple ``values`` is a single (MultiIndex) label; the
908938
# existing lookup already raises a KeyError naming it.
909939
labels_to_check.append(("values", list(com.convert_to_list_like(values))))
940+
910941
for param_name, labels in labels_to_check:
911-
missing = [label for label in labels if label not in data.columns]
942+
missing = [
943+
label
944+
for label in labels
945+
if not is_hashable(label) or label not in data.columns
946+
]
912947
if missing:
913948
raise KeyError(
914949
f"The following '{param_name}' labels are not columns of the "
@@ -926,18 +961,8 @@ def pivot(
926961

927962
indexed: DataFrame | Series
928963
if values is lib.no_default:
929-
if index is not lib.no_default:
930-
cols = com.convert_to_list_like(index)
931-
else:
932-
cols = []
933-
934964
append = index is lib.no_default
935-
# error: Unsupported operand types for + ("List[Any]" and "ExtensionArray")
936-
# error: Unsupported left operand type for + ("ExtensionArray")
937-
indexed = data.set_index(
938-
cols + columns_listlike, # type: ignore[operator]
939-
append=append,
940-
)
965+
indexed = data.set_index(index_listlike + columns_listlike, append=append)
941966
else:
942967
index_list: list[Index] | list[Series]
943968
if index is lib.no_default:
@@ -951,7 +976,7 @@ def pivot(
951976
data._constructor_sliced(data.index, name=data.index.name)
952977
]
953978
else:
954-
index_list = [data[idx] for idx in com.convert_to_list_like(index)]
979+
index_list = [data[idx] for idx in index_listlike]
955980

956981
data_columns = [data[col] for col in columns_listlike]
957982
index_list.extend(data_columns)
@@ -966,11 +991,8 @@ def pivot(
966991
)
967992
else:
968993
indexed = data._constructor_sliced(data[values]._values, index=multiindex)
969-
# error: Argument 1 to "unstack" of "DataFrame" has incompatible type "Union
970-
# [List[Any], ExtensionArray, ndarray[Any, Any], Index, Series]"; expected
971-
# "Hashable"
972994
# unstack with a MultiIndex returns a DataFrame
973-
result = cast("DataFrame", indexed.unstack(columns_listlike)) # type: ignore[arg-type]
995+
result = cast("DataFrame", indexed.unstack(columns_listlike))
974996
result.index.names = [
975997
name if name is not lib.no_default else None for name in result.index.names
976998
]

pandas/tests/reshape/test_pivot.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3280,3 +3280,130 @@ def test_pivot_non_column_label_raises_gh35785():
32803280
)
32813281
expected.columns.name = "bar"
32823282
tm.assert_frame_equal(result, expected)
3283+
3284+
3285+
@pytest.mark.parametrize(
3286+
"box",
3287+
[list, np.array, Series, Index, pd.array],
3288+
ids=["list", "ndarray", "Series", "Index", "ExtensionArray"],
3289+
)
3290+
def test_pivot_array_like_labels_gh35785(box):
3291+
# GH#35785 array-likes of valid column labels are labels, not values
3292+
df = DataFrame(
3293+
{"foo": ["one", "one", "two"], "bar": ["A", "B", "A"], "baz": [1, 2, 3]}
3294+
)
3295+
3296+
result = df.pivot(index=box(["foo"]), columns=box(["bar"]))
3297+
tm.assert_frame_equal(result, df.pivot(index="foo", columns="bar"))
3298+
3299+
result = df.pivot(columns=box(["bar"]))
3300+
tm.assert_frame_equal(result, df.pivot(columns="bar"))
3301+
3302+
# the values path is affected too; e.g. a Series ``columns`` reached unstack
3303+
result = df.pivot(index=box(["foo"]), columns=box(["bar"]), values=["baz"])
3304+
tm.assert_frame_equal(
3305+
result, df.pivot(index=["foo"], columns=["bar"], values=["baz"])
3306+
)
3307+
3308+
3309+
@pytest.mark.parametrize(
3310+
"box",
3311+
[list, np.array, Series, Index, pd.array],
3312+
ids=["list", "ndarray", "Series", "Index", "ExtensionArray"],
3313+
)
3314+
def test_pivot_array_like_multiple_labels_gh35785(box):
3315+
# GH#35785 two labels take unstack's _unstack_multiple path, which an
3316+
# unconverted array-like reaches as an ambiguous truth value
3317+
df = DataFrame(
3318+
{
3319+
"lev1": [1, 1, 1, 2, 2, 2],
3320+
"lev2": [1, 1, 2, 1, 1, 2],
3321+
"lev3": [1, 2, 1, 2, 1, 2],
3322+
"values": [0, 1, 2, 3, 4, 5],
3323+
}
3324+
)
3325+
3326+
result = df.pivot(
3327+
index=box(["lev1"]), columns=box(["lev2", "lev3"]), values="values"
3328+
)
3329+
tm.assert_frame_equal(
3330+
result, df.pivot(index="lev1", columns=["lev2", "lev3"], values="values")
3331+
)
3332+
3333+
3334+
@pytest.mark.parametrize("box", [Index, Series])
3335+
def test_pivot_named_values_names_columns_level_gh35785(box):
3336+
# GH#35785 ``values`` is looked up as-is, so a named Index/Series names the
3337+
# resulting columns level; normalizing it to a list would drop the name
3338+
df = DataFrame(
3339+
{"foo": ["one", "one", "two"], "bar": ["A", "B", "A"], "baz": [1, 2, 3]}
3340+
)
3341+
3342+
result = df.pivot(index="foo", columns="bar", values=box(["baz"], name="V"))
3343+
3344+
expected = df.pivot(index="foo", columns="bar", values=["baz"])
3345+
expected.columns.names = ["V", "bar"]
3346+
tm.assert_frame_equal(result, expected)
3347+
3348+
3349+
@pytest.mark.parametrize("box", [np.array, list, Series, Index, iter])
3350+
def test_pivot_index_array_of_values_gh35785(box):
3351+
# GH#35785 an entry that set_index takes as level values is not a label
3352+
df = DataFrame(
3353+
{"foo": ["one", "one", "two"], "bar": ["A", "B", "A"], "baz": [1, 2, 3]}
3354+
)
3355+
values = ["x", "y", "z"]
3356+
3357+
result = df.pivot(index=[box(values)], columns="bar")
3358+
expected = df.set_index([np.array(values), "bar"]).unstack(["bar"])
3359+
tm.assert_frame_equal(result, expected)
3360+
3361+
# only set_index's array spellings are exempt from the label check, and only
3362+
# on this path: with ``values`` the entries are looked up as columns
3363+
with pytest.raises(KeyError, match="'index' labels are not columns"):
3364+
df.pivot(index=[box(values)], columns="bar", values="baz")
3365+
with pytest.raises(KeyError, match="'index' labels are not columns"):
3366+
df.pivot(index=[{"a": 1}], columns="bar")
3367+
3368+
3369+
def test_pivot_array_columns_entry_raises_gh35785():
3370+
# GH#35785 unlike index, a columns entry is always a label, so an array is
3371+
# named by the KeyError instead of failing later in unstack
3372+
df = DataFrame(
3373+
{"foo": ["one", "one", "two"], "bar": ["A", "B", "A"], "baz": [1, 2, 3]}
3374+
)
3375+
3376+
with pytest.raises(KeyError, match="'columns' labels are not columns"):
3377+
df.pivot(columns=[np.array(["x", "y", "z"])])
3378+
with pytest.raises(KeyError, match="'values' labels are not columns"):
3379+
df.pivot(index="foo", columns="bar", values=[np.array(["x", "y", "z"])])
3380+
3381+
3382+
def test_pivot_datetimelike_array_labels_gh35785():
3383+
# GH#35785 a datetimelike array-like of labels also reached the elementwise add
3384+
df = DataFrame({pd.Timestamp("2016-03-02"): ["one", "two"], "bar": ["A", "B"]})
3385+
result = df.pivot(columns=np.array(["2016-03-02"], dtype="M8[ns]"))
3386+
tm.assert_frame_equal(result, df.pivot(columns=[pd.Timestamp("2016-03-02")]))
3387+
3388+
df = DataFrame({pd.Timedelta("1D"): ["one", "two"], "bar": ["A", "B"]})
3389+
result = df.pivot(columns=np.array([86400000000000], dtype="m8[ns]"))
3390+
tm.assert_frame_equal(result, df.pivot(columns=[pd.Timedelta("1D")]))
3391+
3392+
3393+
def test_pivot_iterator_labels_gh35785():
3394+
# GH#35785 a one-shot iterator must not be consumed by the label check
3395+
df = DataFrame(
3396+
{"foo": ["one", "one", "two"], "bar": ["A", "B", "A"], "baz": [1, 2, 3]}
3397+
)
3398+
3399+
result = df.pivot(index=(col for col in ["foo"]), columns=(col for col in ["bar"]))
3400+
tm.assert_frame_equal(result, df.pivot(index="foo", columns="bar"))
3401+
3402+
result = df.pivot(
3403+
index=(col for col in ["foo"]),
3404+
columns=(col for col in ["bar"]),
3405+
values=(col for col in ["baz"]),
3406+
)
3407+
tm.assert_frame_equal(
3408+
result, df.pivot(index=["foo"], columns=["bar"], values=["baz"])
3409+
)

0 commit comments

Comments
 (0)