Binning Continuous Data: cut & qcut
Reviewed & published by Brayan K
Binning is the act of converting a continuous numeric column into a small set of labelled categories — turning raw ages into "child", "adult", and "senior" buckets so the values become easier to group, count, and chart.
Part of the free Pandas course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
You will learn pd.cut() for fixed value ranges, pd.qcut() for equal-frequency quantiles, and the boundary options right= and include_lowest= that decide exactly where each value lands.
What You'll Learn in This Lesson
- Why turning numbers into categories is useful
- Use pd.cut(bins=[...]) for fixed edges
- Attach readable names with labels=[...]
- Use pd.qcut(q=4) for equal-frequency quantiles
- Control boundaries with right= and include_lowest=
- Count rows per bin with .value_counts()
1 pd.cut() — Fixed Edges You Choose
pd.cut() slices a numeric column at edges you specify. You hand it a list of boundaries, and every value is placed into the interval it falls inside. With four edges you get three bins. By default each interval is closed on the right, written like (18, 35], which means 18 is excluded and 35 is included.
import pandas as pd
ages = pd.Series([5, 17, 22, 40, 65, 80])
# four edges -> three bins
groups = pd.cut(ages, bins=[0, 18, 65, 120])
# (0, 18] -> 5, 17
# (18, 65] -> 22, 40, 65
# (65, 120] -> 80import pandas as pd
df = pd.DataFrame({"name": ["Ana", "Ben", "Cara", "Dev", "Eli"],
"age": [8, 17, 34, 67, 91]})
# Bin ages into 3 named groups
df["group"] = pd.cut(df["age"],
bins=[0, 18, 65, 120],
labels=["child", "adult", "senior"])
print(df)
# ✅ Expected output:
# name age group
# 0 Ana 8 child
# 1 Ben 17 child
# 2 Cara 34 adult
# 3 Dev 67 senior
# 4 Eli 91 senior2 Labels, Counts & the right= Boundary
Pass labels=[...] to replace the interval notation with friendly names — there must be exactly one label per bin. To see how many rows landed in each bin, call .value_counts(sort=False) so the bins stay in numeric order.
The right= argument flips which edge is inclusive. With right=False the intervals become [0, 18), so a value of exactly 18 moves up into the next bin instead of staying in the lower one.
scores = pd.Series([18, 50, 90])
# right=True (default): (0,18], so 18 stays low
pd.cut(scores, bins=[0, 18, 60, 100])
# right=False: [0,18), so 18 moves up
pd.cut(scores, bins=[0, 18, 60, 100], right=False)import pandas as pd
scores = pd.Series([40, 55, 70, 85, 90, 100])
bins = pd.cut(scores,
bins=[0, 60, 75, 90, 100],
labels=["F", "C", "B", "A"],
include_lowest=True)
# Count rows per grade, kept in bin order
print(bins.value_counts(sort=False))
# ✅ Expected output:
# F 2
# C 1
# B 2
# A 1
# Name: count, dtype: int643 pd.qcut() — Equal-Frequency Quantiles
Where cut fixes the edges, qcut fixes the counts. You ask for q=4 and pandas finds the edges so each bin holds roughly a quarter of the rows — the classic quartile split. This is ideal when you want "top 25%" style buckets and you do not care about the exact value thresholds.
income = pd.Series([20, 25, 30, 42, 55, 80, 120, 200])
# 4 equal-frequency buckets (quartiles)
q = pd.qcut(income, q=4, labels=["low", "mid", "high", "top"])
# each label holds 2 of the 8 rowsimport pandas as pd
income = pd.Series([20, 25, 30, 42, 55, 80, 120, 200])
# Split into 4 equal-frequency buckets
buckets = pd.qcut(income, q=4, labels=["low", "mid", "high", "top"])
print(buckets.value_counts(sort=False))
# ✅ Expected output:
# low 2
# mid 2
# high 2
# top 2
# Name: count, dtype: int64import pandas as pd
df = pd.DataFrame({"city": ["A", "B", "C", "D", "E"],
"temp": [12, 24, 31, 5, 19]})
# 🎯 YOUR TURN:
# Bin the "temp" column into 3 named bands:
# "cold" for 0-15
# "mild" for 15-25
# "hot" for 25-40
# Replace ___ with the correct labels list.
df["band"] = pd.cut(df["temp"],
bins=[0, 15, 25, 40],
labels=___)
print(df)
# Expected output:
# city temp band
# 0 A 12 cold
# 1 B 24 mild
# 2 C 31 hot
# 3 D 5 cold
# 4 E 19 mild! Common Errors (And How to Fix Them)
❌ Wrong number of labels
With 4 edges you get 3 bins, so you need exactly 3 labels:
pd.cut(s, bins=[0, 18, 65, 120], labels=["a", "b"]) # ❌ ValueError✅ Fix: one label per bin (edges minus one):
pd.cut(s, bins=[0, 18, 65, 120], labels=["child", "adult", "senior"]) # ✅❌ Smallest value becomes NaN
pd.cut(pd.Series([0, 5]), bins=[0, 10]) # ❌ the 0 becomes NaN✅ Fix: include the lowest edge:
pd.cut(pd.Series([0, 5]), bins=[0, 10], include_lowest=True) # ✅🎯 Mini Challenge: Tier a Customer Table
Take a small spend table and produce both a fixed-edge tier and an equal-frequency tier.
- Use cut to label spend as bronze / silver / gold at edges 0, 50, 100, 1000
- Use qcut to split the same column into 2 equal halves
- Print both new columns alongside the spend
import pandas as pd
df = pd.DataFrame({"customer": ["w", "x", "y", "z"],
"spend": [30, 75, 140, 600]})
# 1) fixed-edge tier
df["tier"] = pd.cut(df["spend"],
bins=[0, 50, 100, 1000],
labels=["bronze", "silver", "gold"],
include_lowest=True)
# 2) equal-frequency halves
df["half"] = pd.qcut(df["spend"], q=2, labels=["bottom", "top"])
print(df)
# ✅ Expected output:
# customer spend tier half
# 0 w 30 bronze bottom
# 1 x 75 silver bottom
# 2 y 140 gold top
# 3 z 600 gold top❓ Frequently Asked Questions
Lesson complete — your numbers are now categories!
You can carve a continuous column into labelled groups with pd.cut(), balance the counts with pd.qcut(), and steer the boundaries with right= and include_lowest=.
Practice quiz
What is the key difference between pd.cut and pd.qcut?
- They are identical
- qcut only works on dates
- cut fixes the edges; qcut fixes the count per bin
- cut returns numbers, qcut returns strings
Answer: cut fixes the edges; qcut fixes the count per bin. cut uses value-range edges you choose; qcut chooses edges so each bin holds roughly equal counts.
With bins=[0, 18, 65, 120] passed to pd.cut, how many bins do you get?
- 3
- 4
- 2
- 5
Answer: 3. Four edges create three intervals, so you need three labels.
By default (right=True), which edge of an interval like (18, 65] is inclusive?
- The left edge
- Both edges
- Neither edge
- The right edge
Answer: The right edge. right=True closes the interval on the right, so 65 lands in (18, 65].
With pd.cut and right=False, where does a value of exactly 18 land given bins [0,18,60,100]?
- In (0, 18]
- In the bin starting at 18, e.g. [18, 60)
- It becomes NaN
- In the last bin
Answer: In the bin starting at 18, e.g. [18, 60). right=False makes intervals like [18, 60), so 18 moves up into the next bin.
Why might the smallest value become NaN after pd.cut?
- The lowest edge is exclusive by default
- cut always drops the minimum
- qcut was used instead
- Labels were missing
Answer: The lowest edge is exclusive by default. The first lower edge is excluded by default; pass include_lowest=True to include it.
For pd.qcut(income, q=4) on 8 evenly-binnable values, how many rows fall in each bin?
- 1
- 4
- 2
- 8
Answer: 2. q=4 splits the 8 rows into 4 equal-frequency quartiles of 2 rows each.
How do you count how many rows landed in each bin while keeping bin order?
- .sum()
- .value_counts(sort=False)
- .mean()
- .describe()
Answer: .value_counts(sort=False). value_counts(sort=False) tallies each bin and keeps the natural numeric bin order.
If pd.cut has 4 edges but you supply only 2 labels, what happens?
- The extra bins are dropped silently
- Labels repeat
- It uses interval notation
- A ValueError is raised
Answer: A ValueError is raised. You must supply exactly one label per bin (edges minus one), or pandas raises a ValueError.
What kind of object does pd.cut return?
- A float Series
- An ordered Categorical
- A list of ints
- A DatetimeIndex
Answer: An ordered Categorical. cut returns an ordered Categorical, so you can group, sort, and count by it.
When should you reach for cut rather than qcut?
- When you want balanced equal-count groups
- When the data is text
- When the thresholds carry real meaning, like a legal age or pass mark
- When you want quartiles
Answer: When the thresholds carry real meaning, like a legal age or pass mark. Use cut for meaningful fixed thresholds; use qcut for balanced equal-frequency buckets.
Continue this course
- Previous: Checkpoint: Cleaning & Wrangling
- Next: One-Hot Encoding with get_dummies — Turn category columns into model-ready 0/1 columns with get_dummies
- Quick reference: Pandas cheat sheet › Transforming
Frequently asked questions
What is the difference between pd.cut and pd.qcut?
pd.cut splits data into bins of fixed value ranges that you control with explicit edges — bins can hold very different numbers of rows. pd.qcut splits data into quantiles so each bin holds roughly the same number of rows, letting the data decide the edges. Use cut for meaningful thresholds like age groups, and qcut for equal-sized buckets like quartiles.
Why is a value sometimes labelled NaN after pd.cut?
By default pd.cut excludes the very first lower edge, so a value equal to the minimum edge falls outside all bins and becomes NaN. Pass include_lowest=True to include that boundary, or make sure your first edge is below the smallest value in the data.
Does right=True or right=False change which edge is inclusive?
Yes. By default right=True means each interval is closed on the right, like (0, 18] — 18 lands in that bin. Setting right=False makes intervals closed on the left, like [0, 18), so 18 moves up to the next bin. Choose the convention that matches how your thresholds are defined.
Can I see how many rows landed in each bin?
Yes — call .value_counts() on the result of cut or qcut. Pass sort=False to keep the bins in their natural numeric order rather than sorting by frequency.