Binning with pd.cut & pd.qcut
Turn continuous numbers (runs, ages, strike rates) into labelled categories with pd.cut (your own edges) and pd.qcut (equal-sized quantile groups) — video 07.
Watch the lesson
1 video on CodeWithMunnaX
Runs like 741, 417 and 161 are continuous numbers. Often we want categories instead: Low / Medium / High scorer, young / prime / veteran player, top 25% / bottom 25%. Converting numbers into ranges is called binning (or bucketing / discretisation). pandas has two tools: pd.cut (you decide the range edges) and pd.qcut (pandas decides the edges so every bin has the same number of rows).
Definition
pd.cut()sorts numeric values into bins (intervals) defined either by a number of equal-width ranges or by an explicit list of bin edges, and returns a Categorical saying which interval (or label) each value falls into.
pd.qcut()("quantile cut") sorts values into bins whose edges are quantiles of the data, so that each bin contains (roughly) the same number of values.
- Bin / interval — a range such as
(300, 600]. A round bracket(means the edge is excluded; a square bracket]means included. So(300, 600]means 300 < x ≤ 600. - Bin edges — the boundary numbers. Edges
[0, 300, 600, 900]make 3 bins (always one fewer than the number of edges). - Categorical — a special pandas dtype (
category) for a fixed, ordered set of possible values. Ordered meansLow < Medium < High, so sorting and comparisons work logically. - Quantile — the value below which a given fraction of the data lies (the 0.25 quantile = 25th percentile).
Syntax
python
pd.cut(x, bins, right=True, labels=None, include_lowest=False, retbins=False, duplicates="raise")
pd.qcut(x, q, labels=None, retbins=False, duplicates="raise")| Parameter | Meaning | Default |
|---|---|---|
x | the 1D numeric data (Series/array) to bin | required |
bins (cut) | an int = number of equal-width bins between min and max, or a list of edges | required |
q (qcut) | an int = number of quantile bins (4 = quartiles, 10 = deciles), or a list of quantiles like [0, 0.5, 0.9, 1] | required |
right (cut) | True: bins are (a, b] (right edge included). False: [a, b) (left edge included) | True |
labels | names for the bins (list, length = number of bins); False returns integer bin numbers 0, 1, 2… | None (show intervals) |
include_lowest (cut) | make the very first interval include its left edge | False |
retbins | also return the computed edges as a second value | False |
duplicates | "raise" an error on repeated edges, or "drop" them | "raise" |
Example — cut with your own edges and labels
python
df = pd.DataFrame({
"player": ["Kohli", "Rohit", "Dhoni", "Gill", "Jadeja", "Bumrah", "Faf", "Surya"],
"runs": [741, 417, 161, 890, 280, 15, 730, 605],
})
df["band"] = pd.cut(df["runs"], bins=[0, 300, 600, 900])
df["level"] = pd.cut(df["runs"], bins=[0, 300, 600, 900], labels=["Low", "Medium", "High"])
print(df)
print(df["level"].dtype)text
player runs band level
0 Kohli 741 (600, 900] High
1 Rohit 417 (300, 600] Medium
2 Dhoni 161 (0, 300] Low
3 Gill 890 (600, 900] High
4 Jadeja 280 (0, 300] Low
5 Bumrah 15 (0, 300] Low
6 Faf 730 (600, 900] High
7 Surya 605 (600, 900] High
categoryHow it works: 4 edges → 3 bins: (0, 300], (300, 600], (600, 900]. Each run total is placed in the bin that contains it; labels replaces the interval text with friendly names (first label → first bin). The new column has dtype category, so df["level"].value_counts(sort=False) lists Low, Medium, High in their logical order.
Example — bins as a number (equal width)
python
print(pd.cut(df["runs"], bins=3))text
0 (598.333, 890.0]
1 (306.667, 598.333]
2 (14.125, 306.667]
3 (598.333, 890.0]
4 (14.125, 306.667]
5 (14.125, 306.667]
6 (598.333, 890.0]
7 (598.333, 890.0]
Name: runs, dtype: category
Categories (3, interval[float64, right]): [(14.125, 306.667] < (306.667, 598.333] < (598.333, 890.0]]How it works: pandas splits the range min–max (15 → 890 = 875) into 3 equal widths of ≈ 291.67. The lowest edge is moved slightly below the minimum (14.125) so that 15 is included. The bins are equal in width, not in number of players (4, 1 and 3 here).
Example — right= and include_lowest=
python
s = pd.Series([0, 50, 99, 100, 150])
print(pd.cut(s, bins=[0, 50, 100, 200]))
print(pd.cut(s, bins=[0, 50, 100, 200], right=False))text
0 NaN
1 (0.0, 50.0]
2 (50.0, 100.0]
3 (50.0, 100.0]
4 (100.0, 200.0]
dtype: category
Categories (3, interval[int64, right]): [(0, 50] < (50, 100] < (100, 200]]
0 [0, 50)
1 [50, 100)
2 [50, 100)
3 [100, 200)
4 [100, 200)
dtype: category
Categories (3, interval[int64, left]): [[0, 50) < [50, 100) < [100, 200)]How it works: with the default right=True, bins are (a, b] so 0 is outside (0, 50] → NaN, and a score of exactly 50 is a "0–50" score, 100 is in (50, 100]. With right=False bins are [a, b), so 0 is included, 50 starts the next bin and 100 counts as "100+" — usually what cricket fans mean by "a fifty" / "a hundred". include_lowest=True is the other fix for the first edge (it shows the first bin as (-0.001, 50.0]).
Tip: values outside all bins become
NaN. Usenp.infas the last edge for an open-ended top bin:bins=[0, 30, 100, np.inf].
Example — qcut: equal-sized groups
python
df2 = df[["player", "runs"]].copy()
df2["tier"] = pd.qcut(df2["runs"], q=4, labels=["Q4 (bottom)", "Q3", "Q2", "Q1 (top)"])
print(df2)text
player runs tier
0 Kohli 741 Q1 (top)
1 Rohit 417 Q3
2 Dhoni 161 Q4 (bottom)
3 Gill 890 Q1 (top)
4 Jadeja 280 Q3
5 Bumrah 15 Q4 (bottom)
6 Faf 730 Q2
7 Surya 605 Q2How it works: q=4 uses the quartiles (25%, 50%, 75%) as edges — here (14.999, 250.25], (250.25, 511.0], (511.0, 732.75], (732.75, 890.0] — so each tier gets exactly 2 of the 8 players. A list gives custom quantiles: pd.qcut(runs, q=[0, 0.5, 0.9, 1.0], labels=["bottom half", "middle", "top 10%"]).
If many values are equal, two quantile edges can be the same and qcut raises ValueError: Bin edges must be unique...; pass duplicates="drop" (you then get fewer bins).
Example — bin, then group
python
print(df.groupby("level", observed=True)["runs"].agg(["count", "mean"]))text
count mean
level
Low 3 152.0
Medium 1 417.0
High 4 741.5Binning + groupby is a classic analysis pattern ("average strike rate per age group"). With a categorical key, observed=True (the pandas 3 default) shows only categories that occur; observed=False also lists empty bins with a count of 0.
cut vs qcut
pd.cut | pd.qcut | |
|---|---|---|
| Edges decided by | you (list) or equal width (int) | the data's quantiles |
| Bins have equal… | width (range) | number of rows |
| Use when | edges have meaning: 0–30, 30–50, 50+ runs | you want rankings: top 25%, deciles |
| Outliers | can leave bins empty or crowded | always balanced counts |
| Values outside edges | become NaN | impossible (covers min–max) |
Common mistakes
- ❌ Giving 3 labels for 3 edges. ✅ Number of labels = number of edges − 1.
- ❌ Expecting the lowest edge to be included (
(0, 300]excludes 0). ✅ Useright=False,include_lowest=True, or a lower first edge like-1. - ❌ Forgetting values above the last edge become
NaN. ✅ End withnp.inf. - ❌ Using
cutwithbins=4and expecting 4 equal-sized groups. ✅ That isqcut(q=4). - ❌ Labels in the wrong order for qcut (top label on the lowest bin). ✅ Labels always go from the lowest bin to the highest.
Interview tip
Q: When would you use pd.qcut instead of pd.cut?
A: When you need groups with (nearly) equal numbers of observations — quartiles, deciles, "top 10%". cut uses fixed or equal-width ranges, so skewed data can put most rows in one bin.
Key points to remember
- Binning turns continuous numbers into categories (ranges); pd.cut and pd.qcut both return an ordered Categorical.
- pd.cut(x, bins=[...]) uses your edges; pd.cut(x, bins=n) makes n equal-width bins between min and max.
- Number of bins = number of edges − 1, and labels must match the number of bins.
- By default bins are right-closed (a, b]; right=False makes them [a, b), include_lowest=True includes the first edge.
- Values outside all edges become NaN — use np.inf for an open-ended last bin.
- pd.qcut(x, q=4) uses quantiles so each bin holds about the same number of rows; duplicates='drop' handles repeated edges.
- Binning + groupby (with observed=True) gives summaries per range, e.g. average runs per scoring band.
Try it yourself
pd.cut with edges, labels and right=False
pd.qcut quartiles and binning + groupby
Practice: label strike rates
Revision Flashcards
Tap a card to flip it and see the answer.
