Repository: matplotlib/matplotlib

Issue:
[ENH]: Legend entries for boxplot
### Problem

Currently, boxplots do not get legend entries. #27711 was an attempt to introduce them but only solved one very particular usecase and did not generalize well. See https://github.com/matplotlib/matplotlib/pull/27780.

There is a `labels` parameter, but that only sets x-tick labels. And it has one entry per box. For the legend we should have only one entry per `boxplot()` call no matter how many boxes that has, because `boxplot` draws N identically styled boxes and it does not make sense to have N identical handles with different labels.

Because of the x-tick relation of  `labels` and it's relation to individual boxes, this parameter is not suited for the legend labels. 

### Proposed solution

We need a separate parameter. There are basically two options:

#### Variant 1) `legend_label: str`

*pro*: simple and clear, no interference with the existing API
*con*: inconsistent with the rest of the library: all other functions use `label` for the legend entry.


#### Variant 2) `label: str`
*pro*: consistent with the rest of the library
*con*: Having `label` alongside `labels` is quite confusing and easily leads to errors. (We could runtime-check for the type str vs list of str, to give helpful error messages but still ...)
If we want to go this way, we should rename `labels` to `tick_labels` or similar.



Discussion:
Side note: We may later allow a list of labels if we want to ease labeling when styling individually, e.g. (currently not working code):

```
bplot = ax.boxplot(fruit_weights, patch_artist=True, legend_labels=labels)
for patch, color in zip(bplot['boxes'], colors):
    patch.set_facecolor(color)
```
![grafik](https://github.com/matplotlib/matplotlib/assets/2836374/905f4713-15d5-4128-8d98-cdd2bf660b33)

I'm unconvinced we would need to support passing multiple legend labels.  If you are already looping through your artists to set the colours, you can set the labels at the same time.  This works with v3.8.2:

```python
import matplotlib.pyplot as plt
import numpy as np

np.random.seed(19680801)
fruit_weights = [
    np.random.normal(130, 10, size=100),
    np.random.normal(125, 20, size=100),
    np.random.normal(120, 30, size=100),
]
labels = ['peaches', 'oranges', 'tomatoes']
colors = ['peachpuff', 'orange', 'tomato']

fig, ax = plt.subplots()
ax.set_ylabel('fruit weight (g)')

bplot = ax.boxplot(fruit_weights,
                   patch_artist=True)  # fill with color

# fill with colors and add legend labels
for patch, color, label in zip(bplot['boxes'], colors, labels):
    patch.set_facecolor(color)
    patch.set_label(label)
    
ax.legend()
ax.get_xaxis().set_visible(False)

plt.show()
```
![image](https://github.com/matplotlib/matplotlib/assets/10599679/3795eee8-e663-4e41-bf82-38ed1845240c)


> I'm unconvinced we would need to support passing multiple legend labels. 

True. OTOH it doesn't cost us much. And it leaves the theoretical option open to add per-box coloring later (note that we've recently done this for `bar` (see https://matplotlib.org/stable/gallery/lines_bars_and_markers/bar_colors.html). Not that I have any concrete plans to do this, but it's not unreasonable.

What I actually wanted to say with this is that extensions in that directions are possible in a consistent way.
> Because of the x-tick relation of labels and it's relation to individual boxes, this parameter is not suited for the legend labels.
> If we want to go this way, we should rename labels to tick_labels or similar.

How bad would it be to do a shuffle `labels->tick_labels` then `labels` gets used for legend like everywhere else? 

Note: There`s currently `labels` (plural). If anything the new one would be `label` (singular). So strictly there's no overlap.

But

- if you don't rename `labels`, as said above having `labels` and `label` would be really confusing.
- if you rename `labels`, I assume that will break about every boxplot. As soon as you have two boxes `labels` is by far the most simple solution to identify them.

>  If anything the new one would belabel` (singular).

Yeah, sorry missed that `label` is what gets used everywhere for legend. Then variant 2 + rename makes sense- I assume the rename will go through a deprecation process to not break everything?

Disregarding the existing API and how we would migrate, would `label` +  `tick_labels` be good?

- yes in terms of API consistency
- meh in terms of `label` is generic `tick_labels` is specific. - As long as you only have one label (like in `plot`), generic is ok. The alternative here would be to have two specific names `legend_label` + `tick_labels`.
Could we introduce `tick_labels` and `legend_label` and have a longer-than-usual deprecation period for `labels`?
I lean towards `label`>`legend_label` b/c  API consistency >> name specificity. 
 Note to self/ to whoever is interested (I haven't made up my mind on this yet):

Should `positions` also support str labels - in other functions we allow str as inputs on coordinate labels (e.g. x in `bar()`).
And if so, should we then remove the current `labels`. - While a little less capable (one can currently use `labels` alongside positions to create str labeled boxes at arbitrary positions), the main use case for simple labeling would be covered.
`bar(h)` also uses the `tick_label` and `label` semantics so this would be consistent with that. 

> How bad would it be to do a shuffle `labels->tick_labels` then `labels` gets used for legend like everywhere else?

I have a solution almost ready for this using variant 2 where the labels are set this way:

```py
labels = ['peaches', 'oranges', 'tomatoes']
ticklabels = ['a', 'b', 'c']

bplot = ax.boxplot(fruit_weights,
                   tick_labels=ticklabels, 
                   label=labels) # legend labels
ax.legend()
```
I can send in a PR as it is, unless you want to discuss this more (I don't want to create a bias for this variant.)
> `bar(h)` also uses the `tick_label` and `label` semantics so this would be consistent with that.

This is a very good point. Let's go with that.


> I have a solution almost ready for this using variant 2 where the labels are set this way:
> 
> ```python
> labels = ['peaches', 'oranges', 'tomatoes']
> ticklabels = ['a', 'b', 'c']
> 
> bplot = ax.boxplot(fruit_weights,
>                    tick_labels=ticklabels, 
>                    label=labels) # legend labels
> ax.legend()
> ```
> 
> I can send in a PR as it is, unless you want to discuss this more (I don't want to create a bias for this variant.)

Go for it! Please note that `label` should accept str and list of str, as with `bar()`.



Patch:
diff --git a/doc/users/next_whats_new/boxplot_legend_support.rst b/doc/users/next_whats_new/boxplot_legend_support.rst
new file mode 100644
index 000000000000..44802960d9bb
--- /dev/null
+++ b/doc/users/next_whats_new/boxplot_legend_support.rst
@@ -0,0 +1,60 @@
+Legend support for Boxplot
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+Boxplots now support a *label* parameter to create legend entries.
+
+Legend labels can be passed as a list of strings to label multiple boxes in a single
+`.Axes.boxplot` call:
+
+
+.. plot::
+    :include-source: true
+    :alt: Example of creating 3 boxplots and assigning legend labels as a sequence.
+
+    import matplotlib.pyplot as plt
+    import numpy as np
+
+    np.random.seed(19680801)
+    fruit_weights = [
+        np.random.normal(130, 10, size=100),
+        np.random.normal(125, 20, size=100),
+        np.random.normal(120, 30, size=100),
+    ]
+    labels = ['peaches', 'oranges', 'tomatoes']
+    colors = ['peachpuff', 'orange', 'tomato']
+
+    fig, ax = plt.subplots()
+    ax.set_ylabel('fruit weight (g)')
+
+    bplot = ax.boxplot(fruit_weights,
+                       patch_artist=True,  # fill with color
+                       label=labels)
+
+    # fill with colors
+    for patch, color in zip(bplot['boxes'], colors):
+        patch.set_facecolor(color)
+
+    ax.set_xticks([])
+    ax.legend()
+
+
+Or as a single string to each individual `.Axes.boxplot`:
+
+.. plot::
+    :include-source: true
+    :alt: Example of creating 2 boxplots and assigning each legend label as a string.
+
+    import matplotlib.pyplot as plt
+    import numpy as np
+
+    fig, ax = plt.subplots()
+
+    data_A = np.random.random((100, 3))
+    data_B = np.random.random((100, 3)) + 0.2
+    pos = np.arange(3)
+
+    ax.boxplot(data_A, positions=pos - 0.2, patch_artist=True, label='Box A',
+               boxprops={'facecolor': 'steelblue'})
+    ax.boxplot(data_B, positions=pos + 0.2, patch_artist=True, label='Box B',
+               boxprops={'facecolor': 'lightblue'})
+
+    ax.legend()
diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py
index 627dd2c36d40..44d82184c3c8 100644
--- a/lib/matplotlib/axes/_axes.py
+++ b/lib/matplotlib/axes/_axes.py
@@ -3798,7 +3798,7 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None,
                 tick_labels=None, flierprops=None, medianprops=None,
                 meanprops=None, capprops=None, whiskerprops=None,
                 manage_ticks=True, autorange=False, zorder=None,
-                capwidths=None):
+                capwidths=None, label=None):
         """
         Draw a box and whisker plot.
 
@@ -3985,6 +3985,20 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None,
             The style of the median.
         meanprops : dict, default: None
             The style of the mean.
+        label : str or list of str, optional
+            Legend labels. Use a single string when all boxes have the same style and
+            you only want a single legend entry for them. Use a list of strings to
+            label all boxes individually. To be distinguishable, the boxes should be
+            styled individually, which is currently only possible by modifying the
+            returned artists, see e.g. :doc:`/gallery/statistics/boxplot_demo`.
+
+            In the case of a single string, the legend entry will technically be
+            associated with the first box only. By default, the legend will show the
+            median line (``result["medians"]``); if *patch_artist* is True, the legend
+            will show the box `.Patch` artists (``result["boxes"]``) instead.
+
+            .. versionadded:: 3.9
+
         data : indexable object, optional
             DATA_PARAMETER_PLACEHOLDER
 
@@ -4105,7 +4119,7 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None,
                            meanline=meanline, showfliers=showfliers,
                            capprops=capprops, whiskerprops=whiskerprops,
                            manage_ticks=manage_ticks, zorder=zorder,
-                           capwidths=capwidths)
+                           capwidths=capwidths, label=label)
         return artists
 
     def bxp(self, bxpstats, positions=None, widths=None, vert=True,
@@ -4114,7 +4128,7 @@ def bxp(self, bxpstats, positions=None, widths=None, vert=True,
             boxprops=None, whiskerprops=None, flierprops=None,
             medianprops=None, capprops=None, meanprops=None,
             meanline=False, manage_ticks=True, zorder=None,
-            capwidths=None):
+            capwidths=None, label=None):
         """
         Draw a box and whisker plot from pre-computed statistics.
 
@@ -4197,6 +4211,20 @@ def bxp(self, bxpstats, positions=None, widths=None, vert=True,
           If True, the tick locations and labels will be adjusted to match the
           boxplot positions.
 
+        label : str or list of str, optional
+            Legend labels. Use a single string when all boxes have the same style and
+            you only want a single legend entry for them. Use a list of strings to
+            label all boxes individually. To be distinguishable, the boxes should be
+            styled individually, which is currently only possible by modifying the
+            returned artists, see e.g. :doc:`/gallery/statistics/boxplot_demo`.
+
+            In the case of a single string, the legend entry will technically be
+            associated with the first box only. By default, the legend will show the
+            median line (``result["medians"]``); if *patch_artist* is True, the legend
+            will show the box `.Patch` artists (``result["boxes"]``) instead.
+
+            .. versionadded:: 3.9
+
         zorder : float, default: ``Line2D.zorder = 2``
           The zorder of the resulting boxplot.
 
@@ -4361,6 +4389,7 @@ def do_patch(xs, ys, **kwargs):
             if showbox:
                 do_box = do_patch if patch_artist else do_plot
                 boxes.append(do_box(box_x, box_y, **box_kw))
+                median_kw.setdefault('label', '_nolegend_')
             # draw the whiskers
             whisker_kw.setdefault('label', '_nolegend_')
             whiskers.append(do_plot(whis_x, whislo_y, **whisker_kw))
@@ -4371,7 +4400,6 @@ def do_patch(xs, ys, **kwargs):
                 caps.append(do_plot(cap_x, cap_lo, **cap_kw))
                 caps.append(do_plot(cap_x, cap_hi, **cap_kw))
             # draw the medians
-            median_kw.setdefault('label', '_nolegend_')
             medians.append(do_plot(med_x, med_y, **median_kw))
             # maybe draw the means
             if showmeans:
@@ -4389,6 +4417,18 @@ def do_patch(xs, ys, **kwargs):
                 flier_y = stats['fliers']
                 fliers.append(do_plot(flier_x, flier_y, **flier_kw))
 
+        # Set legend labels
+        if label:
+            box_or_med = boxes if showbox and patch_artist else medians
+            if cbook.is_scalar_or_string(label):
+                # assign the label only to the first box
+                box_or_med[0].set_label(label)
+            else:  # label is a sequence
+                if len(box_or_med) != len(label):
+                    raise ValueError(datashape_message.format("label"))
+                for artist, lbl in zip(box_or_med, label):
+                    artist.set_label(lbl)
+
         if manage_ticks:
             axis_name = "x" if vert else "y"
             interval = getattr(self.dataLim, f"interval{axis_name}")
diff --git a/lib/matplotlib/axes/_axes.pyi b/lib/matplotlib/axes/_axes.pyi
index 18c10c1452ba..b70d330aa442 100644
--- a/lib/matplotlib/axes/_axes.pyi
+++ b/lib/matplotlib/axes/_axes.pyi
@@ -372,6 +372,7 @@ class Axes(_AxesBase):
         autorange: bool = ...,
         zorder: float | None = ...,
         capwidths: float | ArrayLike | None = ...,
+        label: Sequence[str] | None = ...,
         *,
         data=...,
     ) -> dict[str, Any]: ...
@@ -397,6 +398,7 @@ class Axes(_AxesBase):
         manage_ticks: bool = ...,
         zorder: float | None = ...,
         capwidths: float | ArrayLike | None = ...,
+        label: Sequence[str] | None = ...,
     ) -> dict[str, Any]: ...
     def scatter(
         self,
diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py
index 04d889ec0616..81a6622db77e 100644
--- a/lib/matplotlib/pyplot.py
+++ b/lib/matplotlib/pyplot.py
@@ -2871,6 +2871,7 @@ def boxplot(
     autorange: bool = False,
     zorder: float | None = None,
     capwidths: float | ArrayLike | None = None,
+    label: Sequence[str] | None = None,
     *,
     data=None,
 ) -> dict[str, Any]:
@@ -2902,6 +2903,7 @@ def boxplot(
         autorange=autorange,
         zorder=zorder,
         capwidths=capwidths,
+        label=label,
         **({"data": data} if data is not None else {}),
     )
 


Test patch:
diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py
index 3b3145006427..9b7a6dfd10c9 100644
--- a/lib/matplotlib/tests/test_legend.py
+++ b/lib/matplotlib/tests/test_legend.py
@@ -1435,3 +1435,35 @@ def test_legend_text():
         leg_bboxes.append(
             leg.get_window_extent().transformed(ax.transAxes.inverted()))
     assert_allclose(leg_bboxes[1].bounds, leg_bboxes[0].bounds)
+
+
+def test_boxplot_legend_labels():
+    # Test that legend entries are generated when passing `label`.
+    np.random.seed(19680801)
+    data = np.random.random((10, 4))
+    fig, axs = plt.subplots(nrows=1, ncols=4)
+    legend_labels = ['box A', 'box B', 'box C', 'box D']
+
+    # Testing legend labels and patch passed to legend.
+    bp1 = axs[0].boxplot(data, patch_artist=True, label=legend_labels)
+    assert [v.get_label() for v in bp1['boxes']] == legend_labels
+    handles, labels = axs[0].get_legend_handles_labels()
+    assert labels == legend_labels
+    assert all(isinstance(h, mpl.patches.PathPatch) for h in handles)
+
+    # Testing legend without `box`.
+    bp2 = axs[1].boxplot(data, label=legend_labels, showbox=False)
+    # Without a box, The legend entries should be passed from the medians.
+    assert [v.get_label() for v in bp2['medians']] == legend_labels
+    handles, labels = axs[1].get_legend_handles_labels()
+    assert labels == legend_labels
+    assert all(isinstance(h, mpl.lines.Line2D) for h in handles)
+
+    # Testing legend with number of labels different from number of boxes.
+    with pytest.raises(ValueError, match='values must have same the length'):
+        bp3 = axs[2].boxplot(data, label=legend_labels[:-1])
+
+    # Test that for a string label, only the first box gets a label.
+    bp4 = axs[3].boxplot(data, label='box A')
+    assert bp4['medians'][0].get_label() == 'box A'
+    assert all(x.get_label().startswith("_") for x in bp4['medians'][1:])