Use pd.cut() to assign numeric values to bands. Choose explicit edges for stable reporting rules and test the boundary values before summarising the categories.
Call the top-level function pd.cut() with a one-dimensional input. It assigns values to intervals; it does not sort the source rows. Explicit edges express fixed reporting rules. An integer bins value creates equal-width intervals from the input range; those boundaries can change with new data. qcut() instead uses quantiles to seek approximately equal-sized groups.
The intervals are (1, 50], (50, 70] and (70, 100]. The square bracket includes the boundary; the round bracket excludes it. The synthetic student table includes marks on a boundary, such as 70, so their placement can be checked directly.
import pandas as pd
import numpy as np
def student_data():
return pd.DataFrame({"NAME": ["Ravi", "Raju", "Alex", "Ron", "King", "Jack"], "ID": [1, 2, 3, 4, 5, 6], "MATH": [80, 40, 70, 70, 60, 30], "ENGLISH": [80, 70, 40, 50, 60, 30]})
df = student_data()
df["my_cut"] = pd.cut(df["MATH"], bins=[1, 50, 70, 100])
print(df)
print("dtype:", df["my_cut"].dtype)
assert str(df["my_cut"].dtype) == "category"
assert df.loc[2, "my_cut"].right == 70Expected output
NAME ID MATH ENGLISH my_cut
0 Ravi 1 80 80 (70, 100]
1 Raju 2 40 70 (1, 50]
2 Alex 3 70 40 (50, 70]
3 Ron 4 70 50 (50, 70]
4 King 5 60 60 (50, 70]
5 Jack 6 30 30 (1, 50]
dtype: categoryretbins=True returns the numeric edges as well as the categories. Integer bins include the observed extremes through a small range adjustment. Inspect the actual returned edges rather than inferring them from formatted interval labels.
df = student_data()
df["my_cut"], edges = pd.cut(df["MATH"], bins=5, retbins=True)
print(df)
print("Edges:", edges)
assert len(edges) == 6
assert df["my_cut"].notna().all()Expected output
NAME ID MATH ENGLISH my_cut
0 Ravi 1 80 80 (70.0, 80.0]
1 Raju 2 40 70 (29.95, 40.0]
2 Alex 3 70 40 (60.0, 70.0]
3 Ron 4 70 50 (60.0, 70.0]
4 King 5 60 60 (50.0, 60.0]
5 Jack 6 30 30 (29.95, 40.0]
Edges: [29.95 40. 50. 60. 70. 80. ]A sequence specifies every edge. It does not create gaps: the adjacent edges below create seven contiguous bins, including narrow intervals around selected marks.
df = student_data()
df["my_cut"] = pd.cut(df["MATH"], bins=[1, 49, 50, 69, 70, 79, 80, 100])
print(df)
assert len(df["my_cut"].cat.categories) == 7
assert df.loc[0, "my_cut"] == pd.Interval(79, 80, closed="right")Expected output
NAME ID MATH ENGLISH my_cut
0 Ravi 1 80 80 (79, 80]
1 Raju 2 40 70 (1, 49]
2 Alex 3 70 40 (69, 70]
3 Ron 4 70 50 (69, 70]
4 King 5 60 60 (50, 69]
5 Jack 6 30 30 (1, 49]An IntervalIndex can represent non-overlapping intervals with gaps. Its interval closure controls membership; right and labels do not override it. Values inside gaps become missing in the result.
intervals = pd.IntervalIndex.from_tuples([(0, 50), (70, 100)], closed="right")
values = pd.Series([30, 60, 70, 80])
bands = pd.cut(values, bins=intervals)
print(bands)
assert bands.isna().tolist() == [False, True, True, False]Expected output
0 (0.0, 50.0]
1 NaN
2 NaN
3 (70.0, 100.0]
dtype: category
Categories (2, interval[int64, right]): [(0, 50] < (70, 100]]By default 70 belongs to (50, 70]. With right=False it belongs to [70, 100). A value of 100 is then excluded by the final interval. Always test the exact boundary values in your reporting rule.
values = pd.Series([1, 50, 70, 100])
right_closed = pd.cut(values, [1, 50, 70, 100], right=True)
left_closed = pd.cut(values, [1, 50, 70, 100], right=False)
print(pd.DataFrame({"value": values, "right_closed": right_closed, "left_closed": left_closed}))
assert pd.isna(right_closed.iloc[0])
assert right_closed.iloc[2] == pd.Interval(50, 70, closed="right")
assert left_closed.iloc[2] == pd.Interval(70, 100, closed="left")
assert pd.isna(left_closed.iloc[3])Expected output
value right_closed left_closed
0 1 NaN [1.0, 50.0)
1 50 (1.0, 50.0] [50.0, 70.0)
2 70 (50.0, 70.0] [70.0, 100.0)
3 100 (70.0, 100.0] NaNProvide one label per resulting interval. These grade labels are sample rules, not a grading standard. Ordered categories preserve the intended band order; see dtypes for type inspection.
df = student_data()
df["grade"] = pd.cut(df["MATH"], [1, 50, 75, 100], labels=["Fail", "Second", "First"])
print(df)
print("Ordered:", df["grade"].cat.ordered)
assert df["grade"].tolist() == ["First", "Fail", "Second", "Second", "Second", "Fail"]Expected output
NAME ID MATH ENGLISH grade
0 Ravi 1 80 80 First
1 Raju 2 40 70 Fail
2 Alex 3 70 40 Second
3 Ron 4 70 50 Second
4 King 5 60 60 Second
5 Jack 6 30 30 Fail
Ordered: TrueCalculate totals explicitly before binning. If either mark can be missing, define a policy; sum() with min_count=2 can require both values.
df = student_data()
df["total"] = df["MATH"] + df["ENGLISH"]
df["grade"] = pd.cut(df["total"], [1, 100, 150, 200], labels=["Fail", "Second", "First"])
print(df)
assert df["total"].tolist() == [160, 110, 110, 120, 120, 60]
assert df["grade"].tolist() == ["First", "Second", "Second", "Second", "Second", "Fail"]Expected output
NAME ID MATH ENGLISH total grade
0 Ravi 1 80 80 160 First
1 Raju 2 40 70 110 Second
2 Alex 3 70 40 110 Second
3 Ron 4 70 50 120 Second
4 King 5 60 60 120 Second
5 Jack 6 30 30 60 Failinclude_lowest=True includes the first lower boundary when using ordinary edges. Displayed intervals may show a small precision adjustment; use membership tests and the returned edges to verify the rule.
df = student_data()
excluded = pd.cut(df["MATH"], [30, 60, 80, 100], include_lowest=False)
included = pd.cut(df["MATH"], [30, 60, 80, 100], include_lowest=True)
print(pd.DataFrame({"mark": df["MATH"], "excluded": excluded, "included": included}))
assert pd.isna(excluded.iloc[5])
assert pd.notna(included.iloc[5])Expected output
mark excluded included
0 80 (60.0, 80.0] (60.0, 80.0]
1 40 (30.0, 60.0] (29.999, 60.0]
2 70 (60.0, 80.0] (60.0, 80.0]
3 70 (60.0, 80.0] (60.0, 80.0]
4 60 (30.0, 60.0] (29.999, 60.0]
5 30 NaN (29.999, 60.0]Duplicate edges raise ValueError by default. duplicates="drop" removes repeated edges and changes the number of intervals. Recheck your labels after dropping duplicates. The lower edge 40 is excluded by default in this example.
df = student_data()
try:
pd.cut(df["MATH"], [40, 50, 50, 100], duplicates="raise")
except ValueError:
print("Repeated edges require a decision.")
bands, edges = pd.cut(df["MATH"], [40, 50, 50, 100], duplicates="drop", retbins=True)
print(bands)
print("Unique edges:", edges)
assert edges.tolist() == [40, 50, 100]
assert bands.isna().sum() == 2Expected output
Repeated edges require a decision.
0 (50.0, 100.0]
1 NaN
2 (50.0, 100.0]
3 (50.0, 100.0]
4 (50.0, 100.0]
5 NaN
Name: MATH, dtype: category
Categories (2, interval[int64, right]): [(40, 50] < (50, 100]]
Unique edges: [ 40 50 100]labels=False returns bin indicators rather than categorical labels. With missing results, the indicators may be floating point. Save training edges when bins must remain comparable across extracts; new out-of-range values need review.
training = pd.Series([10, 20, 30, 40])
codes, edges = pd.cut(training, bins=3, labels=False, retbins=True)
new_values = pd.Series([15, 35, 50])
new_codes = pd.cut(new_values, bins=edges, labels=False)
print("Training codes:", codes.tolist())
print("New codes:", new_codes.tolist())
assert new_codes.iloc[0] == 0
assert new_codes.iloc[1] == 2
assert pd.isna(new_codes.iloc[2])Expected output
Training codes: [0, 0, 1, 2]
New codes: [0.0, 2.0, nan]For this synthetic report, accepted amounts are 0 through 1000 inclusive. Bands are Low: [0, 50], Medium: (50, 200] and High: (200, 1000]. A missing category can mean an invalid source value or an out-of-range amount; inspect the source before filling it.
raw = pd.DataFrame({"order_id": [101, 102, 103, 104, 105, 106, 107], "amount": ["0", "50", "200", "450", "-5", "bad", "1200"]})
amount = pd.to_numeric(raw["amount"], errors="coerce")
report = raw.copy()
report["amount"] = amount
report["band"] = pd.cut(amount, [0, 50, 200, 1000], labels=["Low", "Medium", "High"], include_lowest=True)
accepted = report.loc[report["band"].notna()].copy()
review = raw.loc[report["band"].isna()].copy()
print("Accepted:")
print(accepted)
print("Review original amounts:")
print(review)
assert accepted["order_id"].tolist() == [101, 102, 103, 104]
assert accepted["band"].tolist() == ["Low", "Low", "Medium", "High"]
assert review["order_id"].tolist() == [105, 106, 107]Expected output
Accepted:
order_id amount band
0 101 0.0 Low
1 102 50.0 Low
2 103 200.0 Medium
3 104 450.0 High
Review original amounts:
order_id amount
4 105 -5
5 106 bad
6 107 1200Choose observed explicitly in groupby(). observed=False includes defined categories even if empty. A row count and an amount sum answer different questions; audit review rows separately.
summary = accepted.groupby("band", observed=False).agg(orders=("order_id", "size"), revenue=("amount", "sum"))
print(summary)
print("Review rows:", len(review))
print(summary.to_csv().rstrip())
assert summary["orders"].tolist() == [2, 1, 1]
assert summary["revenue"].tolist() == [50, 200, 450]
# Optional Colab export:
# summary.to_csv("sales_band_summary.csv")Expected output
orders revenue
band
Low 2 50.0
Medium 1 200.0
High 1 450.0
Review rows: 3
band,orders,revenue
Low,2,50.0
Medium,1,200.0
High,1,450.0Why is the minimum missing? Check include_lowest and interval closure. Why is the maximum missing? With right=False the final right edge is excluded. Why do bands change? Integer bins use the current range; save edges or choose fixed business boundaries. Does precision round my source data? No; do not infer membership from formatted labels. Why are values missing inside the range? Check IntervalIndex gaps and source missing values. Should I use cut() or qcut()? Choose fixed value intervals versus quantile-based grouping according to the report. Continue with value_counts() and the student grading exercise.
Group ages 0, 17, 18, 64 and 65 into Child: [0, 18), Adult: [18, 65) and Senior: [65, infinity). Use right=False and predict the labels before running the solution.
Explicit left-closed intervals include 18 in Adult and 65 in Senior.
ages = pd.Series([0, 17, 18, 64, 65])
answer = pd.cut(ages, [0, 18, 65, np.inf], right=False, labels=["Child", "Adult", "Senior"])
print(answer)
assert answer.tolist() == ["Child", "Child", "Adult", "Adult", "Senior"]Expected output
0 Child
1 Child
2 Adult
3 Adult
4 Senior
dtype: category
Categories (3, str): ['Child' < 'Adult' < 'Senior']Open in Google Colab View on GitHub
Run the examples in order, change bin edges and boundary values, then compare band assignments and summaries. Save your own copy to keep edits. All sample tables are included in the notebook.
Continue with the Data Cleaning hub, where(), loc, at, NumPy where(), fillna() and iloc.
Reference: Pandas cut documentation.
Author & Instructor at plus2net
I write and maintain practical tutorials on Python, PHP, SQL, JavaScript, HTML, jQuery, and web development at plus2net. The tutorials focus on clear explanations, working examples, and code that readers can test and adapt while learning.