Have you ever handed cut a tidy list of labels and been told the count is wrong?
temps = pd.Series([3, 18, 24, 31, 9])
pd.cut(temps, bins=[0, 10, 20, 30, 40],
labels=['cold', 'mild', 'warm', 'hot', 'extra'])
ValueError: Bin labels must be one fewer than the number of bin edges
Five edges, five labels, and Pandas is unhappy. The short answer is that edges are not bins. Five edges describe four bins, because each bin sits between two edges:
0 ---- 10 ---- 20 ---- 30 ---- 40
1 2 3 4
So drop one label and it works:
pd.cut(temps, bins=[0, 10, 20, 30, 40],
labels=['cold', 'mild', 'warm', 'hot'])
0 cold
1 mild
2 warm
3 hot
4 cold
dtype: category
Categories (4, str): ['cold' < 'mild' < 'warm' < 'hot']
Counting fenceposts is the whole of it. If you want four named categories, you need five numbers to describe where they start and stop.
When bins is a number, the rule inverts
The confusion doubles when you pass an integer instead of a list, because then the number you give is the bin count:
pd.cut(temps, bins=4, labels=['a', 'b', 'c', 'd'])
0 a
1 c
2 c
3 d
4 a
dtype: category
Categories (4, str): ['a' < 'b' < 'c' < 'd']
Four bins, four labels, no complaint. So bins=4 wants four labels and bins=[0, 10, 20, 30, 40] wants four labels too — the same answer arrived at from opposite directions. That is why people fix the error once, meet it again a month later with the other form, and conclude Pandas is being arbitrary.
The rule that covers both: count the bins, not the numbers you typed.
What the edges actually do
Letting Pandas choose the edges shows what it is doing on your behalf:
pd.cut(temps, bins=4)
0 (2.972, 10.0]
1 (17.0, 24.0]
2 (17.0, 24.0]
3 (24.0, 31.0]
4 (2.972, 10.0]
dtype: category
Categories (4, interval[float64, right]): [(2.972, 10.0] < (10.0, 17.0] < (17.0, 24.0] <
(24.0, 31.0]]
Those intervals are the bins, and there are four of them. Notice the brackets: (17.0, 24.0] excludes 17 and includes 24, which is what right=True means by default. It also explains the odd-looking 2.972 — Pandas nudges the lowest edge down slightly so the smallest value is not excluded by its own left parenthesis.
qcut counts the same way
qcut splits by quantile rather than by value, but labels work identically:
pd.qcut(temps, q=2, labels=['low', 'high'])
0 low
1 low
2 high
3 high
4 low
dtype: category
Categories (2, str): ['low' < 'high']
Two quantiles, two labels. If you pass q as a list of quantile cut points — [0, 0.5, 1] — then you are back to edges, and you need one fewer label than you have points.
The mistakes I see most often
Labeling the edges instead of the gaps. If your labels read like '0-10', '10-20', '20-30', '30-40' you have already done the right thing by accident. If they read like '0', '10', '20', '30', '40' you are naming fenceposts.
Forgetting the open end. Bins built from [0, 10, 20, 30] put anything above 30 in no bin at all, and it comes back as missing rather than as an error. If your data can exceed your top edge, either add one or pass float('inf').
Assuming the error means the labels are wrong. Sometimes the edge list is what is wrong, and you meant to write five bins. The message points at the labels because that is where the count came up short, not because the labels are necessarily the mistake.
Practice
See cut in Pandas for binning on real data, and groupby in Pandas for what to do with the categories once you have them.