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

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] -> 80
import 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  senior

2 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: int64

3 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 rows
import 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: int64
import 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.

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

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.