Written and maintained by CASRAI Editorial Board
Last updated
pandas.cut() sorts the values of a one-dimensional array or Series into discrete intervals (bins), turning a continuous variable such as age into a categorical one. You pass the data and either a number of equal-width bins or your own bin edges, and optionally labels to name the groups. The pitfalls are at the edges: with the defaults, a value equal to the first edge, or any value beyond the last edge, comes back as NaN. This guide shows each case with run output. Documentation version: pandas 3.1.1, current as of 2026-11-07; example output was produced with pandas 3.0.5 and NumPy 2.5.1.
What it is used for in research
Researchers use pd.cut() to turn a continuous measurement into ordered groups. The pandas documentation’s own example is converting ages to age ranges. Typical uses are age bands for a baseline table, score or concentration bands defined in a protocol, and bins for a frequency count. The function does not choose cut-points for you: an integer gives equal-width bins, and a list gives exactly the edges you supply.
Grouping a continuous variable discards detail, and the result is an ordered categorical variable (see levels of measurement and categorical variable examples for what that changes). Altman and Royston discuss the cost of dichotomising continuous variables in a 2006 BMJ Practice Statistics Note listed under Sources. Whichever edges you use, report them and which side of each interval is closed, so the grouping can be reproduced.
Install or access
pd.cut is part of pandas. The official installation page says the pandas development team distributes pandas through conda-forge, PyPI and GitHub, and gives these commands:
pip install pandas
conda install -c conda-forge pandas
The page recommends installing and running pandas from a virtual environment, for example conda create -c conda-forge -n name_of_my_env python pandas followed by conda activate name_of_my_env. It lists NumPy as a required dependency (minimum supported version 2.0.2 for pandas 3.1.1) and notes that pandas from Anaconda is not officially managed by the pandas development team. Then import it in your script or notebook: import pandas as pd and import numpy as np.
Step-by-step: bin a column with pd.cut
The signature in the current documentation is pandas.cut(x, bins, right=True, labels=None, retbins=False, precision=3, include_lowest=False, duplicates='raise', ordered=True).
- Put the values in a one-dimensional Series or array. The documentation says
xmust be 1-dimensional; a DataFrame column such asdf["age"]qualifies. To start from a DataFrame, see create a DataFrame in Python. - Choose
bins. An integer makes that many equal-width bins across the range ofx, and the range is extended by 0.1% on each side so the minimum and maximum are included. A sequence of scalars defines the bin edges exactly, allows non-uniform widths, and does not extend the range. AnIntervalIndexdefines the exact bins and must be non-overlapping. - Call
pd.cut(x, bins). With the defaults, each bin includes its right edge: edges[1, 2, 3, 4]mean (1, 2], (2, 3], (3, 4]. - Name the groups with
labelsif you want readable categories. The labels must be the same length as the resulting bins.labels=Falsereturns integer bin codes instead. - Settle the edge convention. Use
right=Falseto close the left edge instead, andinclude_lowest=Trueto put values equal to the first edge into the first bin. - Count the NaN results with
result.isna().sum()before you use the grouping, so you know how many rows fell outside the bins. - Store the result as a column, for example
df["age_group"] = pd.cut(df["age"], bins=edges, labels=labs).
The parameters
| Parameter | Default | What the documentation says |
|---|---|---|
x |
required | 1-dimensional ndarray or Series to bin. |
bins |
required | int (equal-width bins, range extended by 0.1% on each side), sequence of scalars (exact edges, no extension) or IntervalIndex (exact, non-overlapping bins). |
right |
True |
Whether bins include the rightmost edge. Ignored when bins is an IntervalIndex. |
labels |
None |
Labels for the returned bins, same length as the resulting bins; False returns only integer indicators; True raises an error; ignored for an IntervalIndex. |
retbins |
False |
Also return the bins. Useful when bins is a single number. |
precision |
3 |
Precision at which bin labels are stored and displayed. |
include_lowest |
False |
Whether values equal to the first bin edge go in the first bin. No effect when right=False or bins is an IntervalIndex. |
duplicates |
'raise' |
If bin edges are not unique, raise ValueError or drop the non-unique edges ('drop'). |
ordered |
True |
Whether the labels are ordered. With False, labels must be provided. |
Worked example: twelve illustrative ages
The ages below are an illustrative composite for demonstration, not data from a study. The youngest participant is 18 and the oldest is 70.
import pandas as pd
ages = pd.Series([18, 22, 25, 31, 34, 38, 41, 47, 52, 58, 63, 70],
index=[f"P{i:02d}" for i in range(1, 13)], name="age")
Equal-width bins: bins=3
groups, edges = pd.cut(ages, bins=3, retbins=True)
print(edges)
[17.948 35.33333333 52.66666667 70. ]
The lowest edge is 17.948, just below the minimum of 18, which is the 0.1% extension the documentation describes (the documentation’s own example shows the same lower edge, 0.994 for a minimum of 1). Five participants fall in (17.948, 35.333], four in (35.333, 52.667] and three in (52.667, 70.0]. Equal-width bins land on awkward edge values such as 35.333; pass explicit edges when the cut-points must be round numbers.
Explicit edges with the defaults: two values vanish
edges = [18, 30, 45, 65]
grp = pd.cut(ages, bins=edges)
print(grp.isna().sum())
2
P01 (age 18) is NaN because the first interval is (18, 30], which excludes 18 itself, and P12 (age 70) is NaN because it is above the last edge, 65. The remaining ten participants are counted as 2 in (18, 30], 4 in (30, 45] and 4 in (45, 65]. Note that grp.value_counts() leaves NaN out by default, so the counts add up to 10 and nothing in that table warns you that two rows were dropped. This is why step 6 above checks isna() explicitly.
include_lowest=True with labels
labs = ["18-30", "31-45", "46-65"]
grp = pd.cut(ages, bins=edges, include_lowest=True, labels=labs)
print(grp.isna().sum(), grp.dtype, grp.cat.ordered)
1 category True
Now P01 is in “18-30” (counts 3, 4 and 4) and only P12 (age 70) is NaN, because include_lowest does not cover values above the last edge. Without labels, the first interval is displayed as (17.999, 30.0]: the documentation explains that the left edge of the first default label is lowered slightly, so integer bins get float labels.
right=False and a wider top edge
grp = pd.cut(ages, bins=[18, 30, 45, 65, 120], right=False)
The intervals are now [18, 30), [30, 45), [45, 65) and [65, 120), with 3, 4, 4 and 1 participants, and no NaN. Here each interval includes its left edge and excludes its right edge, so a participant aged exactly 30 would go into [30, 45), not the first bin. Choose the top edge deliberately: 120 here is only a number larger than any plausible value.
Integer codes: labels=False
codes = pd.cut(ages, bins=[17, 30, 45, 65, 120], labels=False)
This returns the codes 0 for P01 to P03, 1 for P04 to P07, 2 for P08 to P11 and 3 for P12. The codes count bins from 0, in order, regardless of the edge values.
Output and how to read it
What comes back depends on the input and on labels, as the documentation describes:
| Call | Returns |
|---|---|
labels=None (default) |
A Series for Series input or a Categorical for other inputs, holding Interval values. |
labels a sequence |
A Series for Series input or a Categorical for other inputs, holding your labels. With ordered=True the categorical is ordered. |
labels=False |
A 1-d ndarray (or Series) of integers. |
retbins=True |
The result above plus the computed bins; for scalar or sequence bins the bins are an ndarray. |
Read an interval such as (30, 45] as “greater than 30 and up to and including 45”. A square bracket is closed (value included) and a round bracket is open (value excluded).
Which values become NaN
| Situation | Result | Basis |
|---|---|---|
| The input value is already NA | NA in the result | Documentation Notes; the example below the table |
| The value is outside the range of the bins | NA | Documentation Notes; P12 (age 70) in the example |
The value equals the first edge with right=True and include_lowest=False |
NA | P01 (age 18) in the example |
| The value falls between two bins of a non-contiguous IntervalIndex | NA | Documentation Notes |
For a missing input, pd.cut(pd.Series([1.0, np.nan, 5.0]), [0, 2, 6]) returns (0.0, 2.0], NaN and (2.0, 6.0]. Report how many rows were NaN after binning separately from how many were missing before it; the two have different causes. For the wider cleaning workflow, see data cleaning steps for research data.
Troubleshooting
Some rows came back NaN
Check the first and last edges against the minimum and maximum of your data. Either widen the outer edges, set include_lowest=True, or use right=False with an upper edge above the maximum, as in the worked example.
ValueError: Bin edges must be unique
Raised when two edges are equal, for example pd.cut(ages, bins=[0, 30, 30, 70]); the message in the run suggests setting the duplicates keyword. Passing duplicates="drop" removes the repeated edge. The pandas documentation’s example uses edges [0, 2, 4, 6, 10, 10] with duplicates="drop" and gets the bins [0, 2, 4, 6, 10].
ValueError: bins must increase monotonically
Raised for edges that go down, such as [0, 50, 30, 70]. Sort the edges in ascending order.
ValueError: Input array must be 1 dimensional
The documentation requires x to be 1-dimensional. Pass one column or one row at a time, for example df["age"], not a two-dimensional array or a whole DataFrame.
ValueError: Bin labels must be one fewer than the number of bin edges
The number of labels must equal the number of bins, which is the number of edges minus one. Three labels need four edges, as in the worked example. labels=True also raises an error; use a sequence of labels or False.
I want the same label on more than one bin
Pass ordered=False, which the documentation says can be used to allow non-unique labels. Its example is labels=["B", "A", "B"] with ordered=False, which gives the categories A and B.
The interval labels show too many or too few decimals
Set precision. For the values 0.12345, 0.5 and 0.98765 split into two bins, the default precision of 3 displays (0.123, 0.556] and (0.556, 0.988]; with precision=1 the same bins display as (0.1, 0.6] and (0.6, 1.0]. The label text changes, not which bin each value belongs to.
Free alternatives
These are the alternatives named in the pandas documentation. Each is part of pandas or NumPy and is installed the same way as above.
| Tool | Call | What the documentation says |
|---|---|---|
| pandas.qcut | pd.qcut(x, q, labels=None, retbins=False, precision=3, duplicates='raise') |
Quantile-based discretization: equal-sized buckets based on rank or sample quantiles. q is a number of quantiles (10 for deciles, 4 for quartiles) or a list such as [0, .25, .5, .75, 1.]. |
| IntervalIndex bins | pd.cut(x, pd.IntervalIndex.from_breaks([0, 40, 70])) |
Defines the exact bins and must be non-overlapping. Values outside the intervals become NaN. |
| NumPy bin edges | pd.cut(x, bins=np.histogram_bin_edges(x, bins="auto")) |
The cut documentation lists numpy.histogram_bin_edges as the function that calculates only the edges of the bins used by the histogram function, and says it can be used along with cut to calculate bins according to some predefined methods. |
| Indicator columns | pd.get_dummies(pd.cut(values, bins)) |
The pandas user guide notes that get_dummies is often used with discretization functions like cut(). |
cut or qcut: how the counts differ
Use cut when the edges mean something in your protocol (age bands, score bands). Use qcut when you want groups of about equal size. The documentation defines cut as binning by value (equal-width bins for an integer) and qcut as equal-sized buckets based on rank or sample quantiles, so on skewed data the counts diverge. Twelve illustrative reaction times in milliseconds (310, 325, 340, 350, 360, 375, 390, 420, 480, 650, 900, 1500), split into four groups each way:
| Method | Intervals | Counts |
|---|---|---|
pd.cut(rt, bins=4) |
(308.81, 607.5], (607.5, 905.0], (905.0, 1202.5], (1202.5, 1500.0] | 9, 2, 0, 1 |
pd.qcut(rt, q=4) |
(309.999, 347.5], (347.5, 382.5], (382.5, 522.5], (522.5, 1500.0] | 3, 3, 3, 3 |
cut leaves one bin empty and puts nine of twelve values in one bin, because the single large value stretches the range. qcut balances the counts but its edges come from the data, so they will differ between samples. For the quantile idea itself, see percentile and quantile. With many tied values qcut can also fail on non-unique edges: for the values 1, 1, 1, 1, 1, 1, 2, 3, 4, 5 with q=4, the run raised “Bin edges must be unique”, and duplicates="drop" returned two bins, (0.999, 2.75] with 7 values and (2.75, 5.0] with 3.
FAQ
What does pandas cut do?
It bins values into discrete intervals, which is how you go from a continuous variable to a categorical one. Give it an array or Series plus a bin count or bin edges; it returns the interval or label each value belongs to, or integer codes when labels=False.
What is the difference between pandas cut and qcut?
cut bins by value: an integer gives equal-width bins and a list gives your own edges. qcut bins by rank or sample quantiles, giving buckets of about equal size. See the cut or qcut comparison above for counts on skewed data.
How do I include the lowest value in pandas cut?
Set include_lowest=True. It places values equal to the first bin edge into the first bin, and has no effect when right=False or when bins is an IntervalIndex. It does not rescue values above the last edge, which stay NaN.
How do I set the bins in pandas cut?
Pass an integer for equal-width bins, a list of ascending edges for exact bins, or an IntervalIndex for exact non-overlapping bins. With an integer, use retbins=True to see the edges it computed.
How do I use pandas cut on a DataFrame column in Python?
Pass the column as x and assign the result to a new column: df["age_group"] = pd.cut(df["age"], bins=edges, labels=labs). For a Series input the result is a Series with category dtype (unless labels=False).
Is pandas clip the same as cut?
No. clip trims values at thresholds: values outside the boundary are assigned the boundary value, and the result has the same type as the caller. cut assigns each value to an interval and returns a categorical (or integer codes). For clipping, see NumPy clip, which also covers the pandas equivalent.
Sources
Current as of 2026-11-07.
- pandas documentation (3.1.1), pandas.cut: https://pandas.pydata.org/docs/reference/api/pandas.cut.html
- pandas documentation (3.1.1), pandas.qcut: https://pandas.pydata.org/docs/reference/api/pandas.qcut.html
- pandas documentation (3.1.1), Installation: https://pandas.pydata.org/docs/getting_started/install.html
- pandas user guide, Reshaping and pivot tables (cut and get_dummies): https://pandas.pydata.org/docs/user_guide/reshaping.html
- pandas documentation (3.1.1), pandas.DataFrame.clip: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.clip.html
- Altman DG, Royston P. The cost of dichotomising continuous variables. BMJ. 2006;332(7549):1080. doi:10.1136/bmj.332.7549.1080. PubMed: https://pubmed.ncbi.nlm.nih.gov/16675816/







