Turn a column of numbers into a column of names.
How do you group by a number? Group by age and you get one group per distinct age; group by income and you get one group per person. Numbers make terrible group keys, and the fix is to stop treating them as numbers. pd.cut takes a numeric series and returns a categorical one, each value replaced by the name of the bucket it landed in — something you can group by, count, and put on an axis. It is a function rather than a method, so the series goes in first: pd.cut(df['age'], bins=4).
Official documentation: pandas.cut and pandas.qcut.
The arguments that earn their keep
pd.cut(s, bins=4) # four bins of equal WIDTH
pd.cut(s, bins=[0, 10, 100, 1000, 20000]) # edges you chose: 5 edges, 4 bins
pd.cut(s, bins=edges, labels=['tiny', 'small', 'large', 'giant'])
pd.cut(s, bins=edges, right=False) # [a, b) instead of (a, b]
pd.cut(s, bins=edges, include_lowest=True) # let the very lowest value in
pd.qcut(s, q=4) # four bins of equal COUNT
An integer bins divides the range into that many equal slices. A list of edges puts them where you say — and n edges make n − 1 bins, which is the off-by-one everybody hits once. labels names them, in order, and needs exactly n − 1 entries or you get ValueError: Bin labels must be one fewer than the number of bin edges.
A worked example, on real data
Our World in Data's CO2 file, one row per country per year, rebuilt as national inventories are revised. Take 2023, dropping the aggregates like "Europe" and "World" that have no ISO code:
import pandas as pd
url = 'https://raw.githubusercontent.com/owid/co2-data/master/owid-co2-data.csv'
df = pd.read_csv(url, usecols=['country', 'iso_code', 'year', 'co2'])
d = df.loc[(df['year'] == 2023) & (df['iso_code'].str.len() == 3)]
pd.cut(d['co2'], bins=4).value_counts().sort_index()
co2
(-12.172, 3043.002] 212
(3043.002, 6086.004] 2
(6086.004, 9129.007] 0
(9129.007, 12172.009] 1
Name: count, dtype: int64
That is the trap, in four lines. bins=4 cut the range from 0 to 12,172 Mt into four slices 3,043 Mt wide and put 212 of 215 countries in the first one. The second holds India and the United States, the third holds nobody at all, and the fourth holds China. As a grouping it is worthless.
Equal width is almost never what you want on skewed data, and most real quantities are skewed. qcut cuts on counts instead, putting the edges at the quartiles:
pd.qcut(d['co2'], q=4).value_counts().sort_index()
co2
(-0.001, 1.381] 54
(1.381, 10.516] 54
(10.516, 55.559] 53
(55.559, 12172.009] 54
Name: count, dtype: int64
Four groups you can actually compare. The printed intervals are worth reading: open on the left, closed on the right, with the lowest edge pushed out a little — to −12.172 above, −0.001 here — so the smallest value is included rather than falling out.
The third way is to pick the edges yourself, which is right when they mean something outside the data:
labels = ['tiny', 'small', 'large', 'giant']
(d
.assign(band=pd.cut(d['co2'], bins=[0, 10, 100, 1000, 20000], labels=labels))
.groupby('band')['co2'].agg(['count', 'sum'])
.assign(share=lambda df_: (df_['sum'] / df_['sum'].sum() * 100).round(1))
.round(1))
count sum share
band
tiny 103 285.3 0.8
small 69 2412.6 6.5
large 37 12418.8 33.6
giant 4 21886.3 59.1
Four countries account for 59 percent of the world's emissions; the 103 smallest emitters together account for eight-tenths of one percent. Notice that the rows came back in bin order rather than alphabetically — cut returns an ordered categorical, and groupby respects that order.
Three mistakes people make
Losing the values that fall outside your edges. Count the rows above: 103 + 69 + 37 + 4 is 213, and 215 countries have a CO2 figure. Two are missing, because the first interval is (0, 10] and two countries emitted exactly zero. Anything outside the outermost edges becomes NaN, with no warning at all. Two arguments fix it: include_lowest=True opens the bottom bin to [0, 10], and right=False flips every interval to [a, b). Run .isna().sum() on the result of every cut you write with explicit edges.
Reading bins=4 as quartiles. The most common misreading of this function, and the CO2 table above is what it costs. If the groups should be comparable in size, that is qcut. If they should mean something — a passing grade, a poverty line, a published threshold — that is cut with edges you typed.
Expecting empty bins to show up. In Pandas 3, groupby observes only the categories that occur, so a band with nothing in it drops out of the table without a word — the third bin in the bins=4 example above would simply not appear. Pass observed=False when the empty bucket is part of the finding.
Where it shows up in Bamboo Weekly
Bamboo Weekly #56: Rent increases builds its edges out of the data's own quantiles, and is free to read: [population.min(), population.quantile(0.25), population.quantile(0.75), population.max()] labels counties small, medium and large. That is the qcut instinct written by hand, with the middle group deliberately twice the size of the others.
Bamboo Weekly #157: Government corruption is the other kind of edge entirely: [0, 38, 47, 71, 100] on the Corruption Perceptions Index, with include_lowest=True and the labels authoritarian, hybrid, flawed democracy and democracy. Those cutoffs were given in advance rather than derived from the distribution, which is exactly when cut beats qcut.
Bamboo Weekly #172: World Cup is right=False earning its keep. Kickoff hours are cut at [0, 12, 18, 24] into morning, afternoon and night — and under the default, noon would count as morning and 6pm as afternoon. Half-open intervals are what clock time wants.
Practice it
Work through a cut exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/cut/
Go deeper
groupby is what the new column is for, value_counts counts the bands, and quantile is where qcut gets its edges. hist bins the same values without naming them, and px.histogram is worth a look at the shape before you decide where the edges belong.
More Pandas videos on Python and Pandas with Reuven Lerner.
Bamboo Weekly is the practice. If you want the structured version — full courses with downloadable Jupyter notebooks, plus live Pandas office hours when you get stuck — that is LernerPython+Data. A paid Bamboo Weekly subscription is included with it.
Related methods
.hist()— when you want to see the distribution rather than label it.value_counts()— which is how you count the bins once you have made them.quantile()— which is what qcut uses to make bins of equal size rather than equal width