Repository: matplotlib/matplotlib

Issue:
[Bug]: Inconsistent treatment of list of labels in `plot` when the input is a dataframe
### Bug summary

`plt.plot` interprets a dataframe as a sequence of series to be plotted, one per column. Labels can be specified by setting `label` to a list of labels matching the number of columns. If the dataframe has only one column, though, the `label` argument is interpreted as a single label, instead of a list with one element.

### Code for reproduction

```python
from matplotlib import pyplot as plt
import pandas as pd
import numpy as np

x = np.arange(5)
y = pd.DataFrame({'a': x, 'b': x + 1})

plt.figure('double', clear=True)
plt.plot(x, y, label=['A', 'B'])
plt.legend() # -> ok

plt.figure('single', clear=True)
plt.plot(x, y.drop('b', axis=1), label=['A'])
plt.legend() # -> legend label is "['A']"

plt.show()
```


### Actual outcome

<img width="592" alt="immagine" src="https://github.com/matplotlib/matplotlib/assets/9684110/aa6abbcc-dd05-459b-b4ae-acb8a0d2e998">


### Expected outcome

<img width="573" alt="immagine" src="https://github.com/matplotlib/matplotlib/assets/9684110/4dd88c38-216d-446b-a8d4-de70e299f39e">


### Additional information

_No response_

### Operating system

macOS 14.2.1

### Matplotlib Version

3.8.2

### Matplotlib Backend

MacOSX

### Python version

3.12.1 | packaged by conda-forge | (main, Dec 23 2023, 08:01:35) [Clang 16.0.6 ]

### Jupyter version

_No response_

### Installation

conda


Discussion:
This isn't specific to dataframes.  The same behaviour can be seen with

```python
import matplotlib.pyplot as plt

fig, ax = plt.subplots()
ax.plot([[0, 0], [1, 2]], label=['A', 'B'])
ax.legend()
plt.show()
```
![image](https://github.com/matplotlib/matplotlib/assets/10599679/b4d55fae-0762-4c77-a95a-a23de20985fa)

```python
fig, ax = plt.subplots()
ax.plot([[0], [1]], label=['A'])
ax.legend()
plt.show()
```
![image](https://github.com/matplotlib/matplotlib/assets/10599679/8e93a686-fe41-431b-ae31-238b0ddff189)
I'm not aware that we make any guarantees that one can pass a single-element list for single data. - I think the behavior is undefined in this case. For context, multiple label support was introduced in #16178.

But if we want to support this, that'd need to be handled here:

https://github.com/matplotlib/matplotlib/blob/d2cc4d0b0a2e1e9e8a5c1f311f0e10ef8acdb9ef/lib/matplotlib/axes/_base.py#L522-L523

I tried poking the code that @timhoffm highlighted, but it seems the current behaviour is very much expected:

https://github.com/matplotlib/matplotlib/blob/d2cc4d0b0a2e1e9e8a5c1f311f0e10ef8acdb9ef/lib/matplotlib/tests/test_legend.py#L1191-L1203
I think the current behavior is like if `np.array([1])` yielded a 0-d array. It breaks agnostic coding patterns that do not depend on the length of things.
> I tried poking the code that @timhoffm highlighted, but it seems the current behaviour is very much expected:

My vague recollection is that #16178 should only affect the multiple-data case. Changes to the single-data case were out of scope. I believe, the test is just ensurging the single-data case is kept as is; i.e. anything passed to `label` is converted using `str`. So in a way, this behavior is expected as the de-facto behavior, but I would not claim it's necessarily intended.

We have a generalization ambiguity problem. We cannot have both:
1. For single data, convert any label object to str.
2. For N data, support a list of labels of length N.

While I see the motivation for 2 with the N=1 case, supporting that would mean to break 1. We certainly have to go through a deprecation period with this. And we'd have to consider how to handle the case one dataset, but >1 labels (as in the test). I think, if one dataset, length-1 label list gives the one element as label, the >1 labels case should error out and not create the str anymore.

I'd fundamentally support the idea of not converting iterables to str anymore - if anybody wants to take on the deprecation effort.
> I'd fundamentally support the idea of not converting iterables to str anymore - if anybody wants to take on the deprecation effort.

I think we should do this - I can't think of a situation where a user would want the result above, and if for some weird reason they do, they can convert to a single string themselves.  I think the current behaviour is undesirable enough that I'm not even sure we need a full deprecation cycle for it?
I would bet that there are people who for some reason have a list as identifier for their dataset and they don't care enough to format that explicitly (`['A', 'B']` in the legend was good enough for them). Therefore, I advocate for a regular deprecation.
So what should happen for the single element list during the deprecation period?  Can we implement new behaviour for that right away?
Given all context, I’d say yes. - Longer explanation will follow later (don’t want to type that on the phone)

Update for completeness:

While changing the behavior for single dataset + one-element list is breaking, we would not get much from a deprecation. Since we have a change of behavior, a runtime deprecation warning could announce that, but there is no reasonable way to allow people opting into the new behavior, which should be the desired one in the majority of cases. - They see the warning, and cannot do anything with it. So a runtime heads up is not feasible. Which would leave us with just a deprecation notice in the release notes. Apart from that I there's much overlap between people who read release notes and people who know (or cared) that they have passed a list for a single label, still the above argument holds, you cannot opt into the new behavior during deprecation. So the deprecation would only help people, wo intentionally wanted a list-formatted label. That is rare and not worth the effort and waiting time.



Patch:
diff --git a/doc/api/next_api_changes/behavior/27767-REC.rst b/doc/api/next_api_changes/behavior/27767-REC.rst
new file mode 100644
index 000000000000..f6b4dc156732
--- /dev/null
+++ b/doc/api/next_api_changes/behavior/27767-REC.rst
@@ -0,0 +1,7 @@
+Legend labels for ``plot``
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Previously if a sequence was passed to the *label* parameter of `~.Axes.plot` when
+plotting a single dataset, the sequence was automatically cast to string for the legend
+label.  Now, if the sequence has only one element, that element will be the legend
+label.  To keep the old behavior, cast the sequence to string before passing.
diff --git a/doc/api/next_api_changes/deprecations/27767-REC.rst b/doc/api/next_api_changes/deprecations/27767-REC.rst
new file mode 100644
index 000000000000..68781090df0a
--- /dev/null
+++ b/doc/api/next_api_changes/deprecations/27767-REC.rst
@@ -0,0 +1,9 @@
+Legend labels for ``plot``
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Previously if a sequence was passed to the *label* parameter of `~.Axes.plot` when
+plotting a single dataset, the sequence was automatically cast to string for the legend
+label.  This behavior is now deprecated and in future will error if the sequence length
+is not one (consistent with multi-dataset behavior, where the number of elements must
+match the number of datasets).  To keep the old behavior, cast the sequence to string
+before passing.
diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py
index 51347f08b989..0617fc7681bd 100644
--- a/lib/matplotlib/axes/_base.py
+++ b/lib/matplotlib/axes/_base.py
@@ -513,14 +513,22 @@ def _plot_args(self, axes, tup, kwargs, *,
 
         label = kwargs.get('label')
         n_datasets = max(ncx, ncy)
-        if n_datasets > 1 and not cbook.is_scalar_or_string(label):
-            if len(label) != n_datasets:
-                raise ValueError(f"label must be scalar or have the same "
-                                 f"length as the input data, but found "
-                                 f"{len(label)} for {n_datasets} datasets.")
+
+        if cbook.is_scalar_or_string(label):
+            labels = [label] * n_datasets
+        elif len(label) == n_datasets:
             labels = label
+        elif n_datasets == 1:
+            msg = (f'Passing label as a length {len(label)} sequence when '
+                    'plotting a single dataset is deprecated in Matplotlib 3.9 '
+                    'and will error in 3.11.  To keep the current behavior, '
+                    'cast the sequence to string before passing.')
+            _api.warn_deprecated('3.9', message=msg)
+            labels = [label]
         else:
-            labels = [label] * n_datasets
+            raise ValueError(
+                f"label must be scalar or have the same length as the input "
+                f"data, but found {len(label)} for {n_datasets} datasets.")
 
         result = (make_artist(axes, x[:, j % ncx], y[:, j % ncy], kw,
                               {**kwargs, 'label': label})


Test patch:
diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py
index b23649f22e48..73892698882c 100644
--- a/lib/matplotlib/tests/test_legend.py
+++ b/lib/matplotlib/tests/test_legend.py
@@ -1197,12 +1197,20 @@ def test_plot_single_input_multiple_label(label_array):
     x = [1, 2, 3]
     y = [2, 5, 6]
     fig, ax = plt.subplots()
-    ax.plot(x, y, label=label_array)
+    with pytest.warns(mpl.MatplotlibDeprecationWarning,
+                      match='Passing label as a length 2 sequence'):
+        ax.plot(x, y, label=label_array)
     leg = ax.legend()
     assert len(leg.get_texts()) == 1
     assert leg.get_texts()[0].get_text() == str(label_array)
 
 
+def test_plot_single_input_list_label():
+    fig, ax = plt.subplots()
+    line, = ax.plot([[0], [1]], label=['A'])
+    assert line.get_label() == 'A'
+
+
 def test_plot_multiple_label_incorrect_length_exception():
     # check that exception is raised if multiple labels
     # are given, but number of on labels != number of lines