Skip to main content

Updated Aug 24, 2026

Binning and Categorical Data

Binning converts continuous data into discrete intervals (bins). Categorical data represents discrete categories with limited unique values. Both are essential for analysis and visualization.

Binning Continuous Data​

cut() - Equal-Width Bins​

Divide data into bins of equal width:

Create equal-width bins
import pandas as pd
import numpy as np

df = pd.DataFrame({
'age': [5, 15, 25, 35, 45, 55, 65, 75]
})

# Create 4 equal-width bins
df['age_group'] = pd.cut(df['age'], bins=4)
# age age_group
# 0 5 (4.93, 22.5]
# 1 15 (4.93, 22.5]
# 2 25 (22.5, 40.0]
# 3 35 (22.5, 40.0]
# 4 45 (40.0, 57.5]
# 5 55 (40.0, 57.5]
# 6 65 (57.5, 75.0]
# 7 75 (57.5, 75.0]

# With custom labels
df['age_group'] = pd.cut(
df['age'],
bins=4,
labels=['Child', 'Adult', 'Middle-aged', 'Senior']
)
# age age_group
# 0 5 Child
# 1 15 Child
# 2 25 Adult
# 3 35 Adult
# 4 45 Middle-aged
# 5 55 Middle-aged
# 6 65 Senior
# 7 75 Senior

Custom Bin Edges​

Define specific bin boundaries
df = pd.DataFrame({
'age': [5, 15, 25, 35, 45, 55, 65, 75]
})

# Specify exact bin edges
bins = [0, 18, 35, 60, 100]
labels = ['Child', 'Young Adult', 'Adult', 'Senior']

df['age_group'] = pd.cut(df['age'], bins=bins, labels=labels)
# age age_group
# 0 5 Child
# 1 15 Child
# 2 25 Young Adult
# 3 35 Young Adult
# 4 45 Adult
# 5 55 Adult
# 6 65 Senior
# 7 75 Senior
info

Intervals are right-inclusive by default: (0, 18] means 0 < x ≤ 18. Use right=False for left-inclusive.

Include Edge Cases​

Handle values at boundaries
df = pd.DataFrame({
'value': [0, 25, 50, 75, 100]
})

# Right-inclusive (default)
pd.cut(df['value'], bins=[0, 50, 100])
# 0 NaN # 0 not included!
# 1 (0, 50]
# 2 (0, 50]
# 3 (50, 100]
# 4 (50, 100]

# Include lowest value
pd.cut(df['value'], bins=[0, 50, 100], include_lowest=True)
# 0 [0, 50] # Now 0 is included
# 1 (0, 50]
# 2 (0, 50]
# 3 (50, 100]
# 4 (50, 100]

qcut() - Equal-Frequency Bins​

Create bins with approximately equal number of observations:

Create quantile-based bins
df = pd.DataFrame({
'income': [20000, 25000, 30000, 35000, 40000,
50000, 60000, 80000, 100000, 150000]
})

# 4 bins with equal counts
df['income_quartile'] = pd.qcut(df['income'], q=4)
# income income_quartile
# 0 20000 [20000, 32500]
# 1 25000 [20000, 32500]
# 2 30000 [20000, 32500]
# 3 35000 (32500, 45000]
# 4 40000 (32500, 45000]
# 5 50000 (45000, 70000]
# 6 60000 (45000, 70000]
# 7 80000 (70000, 150000]
# 8 100000 (70000, 150000]
# 9 150000 (70000, 150000]

# With labels
df['income_quartile'] = pd.qcut(
df['income'],
q=4,
labels=['Q1', 'Q2', 'Q3', 'Q4']
)
Choose the Right Binning Method

Use qcut() when you want balanced groups. Use cut() when bin boundaries are meaningful (e.g., age groups).

Percentile-Based Bins​

Bin by specific percentiles
df = pd.DataFrame({
'score': np.random.randint(0, 100, 100)
})

# Create bins at 25th, 50th, 75th percentiles
df['score_group'] = pd.qcut(
df['score'],
q=[0, 0.25, 0.5, 0.75, 1.0],
labels=['Bottom 25%', 'Lower-middle', 'Upper-middle', 'Top 25%']
)

# Count per group
df['score_group'].value_counts()

Working with Categorical Data​

Creating Categories​

Create categorical dtype
df = pd.DataFrame({
'color': ['red', 'blue', 'red', 'green', 'blue']
})

# Convert to categorical
df['color'] = df['color'].astype('category')
df['color'].dtype
# CategoricalDtype(categories=['blue', 'green', 'red'], ordered=False)

# Create with specific categories
df['color'] = pd.Categorical(
df['color'],
categories=['red', 'blue', 'green', 'yellow'], # yellow not in data
ordered=False
)

Ordered Categories​

Create ordered categorical
df = pd.DataFrame({
'size': ['M', 'L', 'S', 'M', 'S', 'L']
})

# Create ordered categories
df['size'] = pd.Categorical(
df['size'],
categories=['S', 'M', 'L'],
ordered=True
)

# Now comparisons work
df[df['size'] > 'S'] # Returns M and L
# size
# 0 M
# 1 L
# 3 M
# 5 L
info

Ordered categoricals enable meaningful comparisons (>, <, >=, <=). Useful for ordinal data like ratings, sizes, or education levels.

Categorical Properties​

Access categorical information
df = pd.DataFrame({
'grade': pd.Categorical(['A', 'B', 'A', 'C', 'B'],
categories=['A', 'B', 'C', 'D'],
ordered=True)
})

# View categories
df['grade'].cat.categories
# Index(['A', 'B', 'C', 'D'], dtype='object')

# Check if ordered
df['grade'].cat.ordered # True

# Get codes (integer representation)
df['grade'].cat.codes
# 0 0 # A
# 1 1 # B
# 2 0 # A
# 3 2 # C
# 4 1 # B

# Count per category (including unused)
df['grade'].value_counts()
# A 2
# B 2
# C 1
# D 0 # Unused category still shown

Add/Remove Categories​

Modify categories
df = pd.DataFrame({
'status': pd.Categorical(['active', 'pending', 'active'])
})

# Add new category
df['status'] = df['status'].cat.add_categories(['archived'])
df['status'].cat.categories
# Index(['active', 'pending', 'archived'], dtype='object')

# Remove unused categories
df['status'] = df['status'].cat.remove_unused_categories()

# Remove specific category
df['status'] = df['status'].cat.remove_categories(['archived'])

# Rename categories
df['status'] = df['status'].cat.rename_categories({
'active': 'Active',
'pending': 'Pending'
})

Reorder Categories​

Change category order
df = pd.DataFrame({
'priority': pd.Categorical(['high', 'low', 'medium', 'low'],
ordered=True)
})

# Reorder categories
df['priority'] = df['priority'].cat.reorder_categories(
['low', 'medium', 'high'],
ordered=True
)

# Now sorting works as expected
df.sort_values('priority')
# priority
# 1 low
# 3 low
# 2 medium
# 0 high

Memory Benefits​

Categorical vs Object​

Memory savings with categorical
df = pd.DataFrame({
'status': ['active'] * 1000 + ['inactive'] * 1000
})

# As object (string)
df['status'].memory_usage(deep=True)
# ~112000 bytes

# As categorical
df['status'] = df['status'].astype('category')
df['status'].memory_usage(deep=True)
# ~2000 bytes (98% reduction!)
Convert to categorical when:
  • Column has <50% unique values
  • Values repeat frequently
  • Need to save memory
  • Want to preserve unused categories

Binning Strategies​

Age Groups​

Common age binning
df = pd.DataFrame({
'age': [5, 12, 18, 25, 35, 45, 55, 65, 75]
})

# Standard age groups
bins = [0, 18, 35, 50, 65, 100]
labels = ['Child', 'Young Adult', 'Adult', 'Middle-aged', 'Senior']
df['age_group'] = pd.cut(df['age'], bins=bins, labels=labels)

# Or generations
bins = [0, 25, 40, 56, 75, 100]
labels = ['Gen Z', 'Millennial', 'Gen X', 'Boomer', 'Silent']
df['generation'] = pd.cut(df['age'], bins=bins, labels=labels)

Income Brackets​

Income binning
df = pd.DataFrame({
'income': [25000, 35000, 50000, 75000, 125000, 250000]
})

# Income brackets
bins = [0, 30000, 60000, 100000, 200000, np.inf]
labels = ['Low', 'Lower-middle', 'Middle', 'Upper-middle', 'High']
df['income_bracket'] = pd.cut(df['income'], bins=bins, labels=labels)

Grade Ranges​

Score to grade conversion
df = pd.DataFrame({
'score': [95, 85, 75, 65, 55, 45]
})

# Letter grades
bins = [0, 60, 70, 80, 90, 100]
labels = ['F', 'D', 'C', 'B', 'A']
df['grade'] = pd.cut(
df['score'],
bins=bins,
labels=labels,
include_lowest=True
)
# score grade
# 0 95 A
# 1 85 B
# 2 75 C
# 3 65 D
# 4 55 F
# 5 45 F

Time-Based Bins​

Time period binning
df = pd.DataFrame({
'hour': range(24)
})

# Time of day
bins = [0, 6, 12, 18, 24]
labels = ['Night', 'Morning', 'Afternoon', 'Evening']
df['period'] = pd.cut(
df['hour'],
bins=bins,
labels=labels,
right=False, # [0, 6) instead of (0, 6]
include_lowest=True
)

Advanced Binning​

Custom Binning Function​

Apply custom binning logic
df = pd.DataFrame({
'value': [10, 25, 50, 75, 100, 150, 200]
})

def custom_bin(value):
if value < 50:
return 'Small'
elif value < 100:
return 'Medium'
else:
return 'Large'

df['size'] = df['value'].apply(custom_bin)
# value size
# 0 10 Small
# 1 25 Small
# 2 50 Medium
# 3 75 Medium
# 4 100 Large
# 5 150 Large
# 6 200 Large

Binning with NaN Handling​

Handle missing values in binning
df = pd.DataFrame({
'value': [10, np.nan, 30, 40, np.nan, 60]
})

# NaN values are excluded from bins
df['bin'] = pd.cut(df['value'], bins=3)
# value bin
# 0 10.0 (9.95, 26.67]
# 1 NaN NaN
# 2 30.0 (26.67, 43.33]
# 3 40.0 (26.67, 43.33]
# 4 NaN NaN
# 5 60.0 (43.33, 60.0]

# Fill NaN bins
df['bin'] = df['bin'].cat.add_categories(['Unknown'])
df['bin'].fillna('Unknown', inplace=True)

Duplicate Edges in qcut​

Handle duplicate edges in quantiles
df = pd.DataFrame({
'value': [1, 1, 1, 1, 2, 2, 3, 3, 4, 5]
})

# duplicates='drop' removes duplicate edges
df['quartile'] = pd.qcut(df['value'], q=4, duplicates='drop')

# Or use duplicates='raise' to see the error
# pd.qcut(df['value'], q=4, duplicates='raise') # ValueError!
warning

qcut() fails if there aren't enough unique values to create distinct bins. Use duplicates='drop' to handle this.

Categorical Operations​

Group by Category​

Aggregate by categorical
df = pd.DataFrame({
'category': pd.Categorical(['A', 'B', 'A', 'B', 'C']),
'value': [10, 20, 15, 25, 30]
})

# GroupBy includes all categories (even unused)
result = df.groupby('category', observed=False)['value'].sum()
# A 25
# B 45
# C 30

# observed=True: only include categories present in data
result = df.groupby('category', observed=True)['value'].sum()

Dummy Variables (One-Hot Encoding)​

Convert categorical to dummy variables
df = pd.DataFrame({
'color': pd.Categorical(['red', 'blue', 'red', 'green'])
})

# Create dummy variables
dummies = pd.get_dummies(df['color'])
# blue green red
# 0 0 0 1
# 1 1 0 0
# 2 0 0 1
# 3 0 1 0

# Drop first category (avoid multicollinearity)
dummies = pd.get_dummies(df['color'], drop_first=True)
# green red
# 0 0 1
# 1 0 0
# 2 0 1
# 3 1 0

# Add prefix
dummies = pd.get_dummies(df['color'], prefix='color')
# color_blue color_green color_red
# 0 0 0 1
# 1 1 0 0
# 2 0 0 1
# 3 0 1 0

Map Categories​

Map categorical values
df = pd.DataFrame({
'size': pd.Categorical(['S', 'M', 'L', 'S', 'M'])
})

# Map to numeric
size_map = {'S': 1, 'M': 2, 'L': 3}
df['size_num'] = df['size'].map(size_map)
# size size_num
# 0 S 1
# 1 M 2
# 2 L 3
# 3 S 1
# 4 M 2

# Or use cat.codes (automatic)
df['size_code'] = df['size'].cat.codes
# size size_code
# 0 S 2 # Based on alphabetical order
# 1 M 1
# 2 L 0

Practical Examples​

Customer Segmentation​

Segment customers by behavior
df = pd.DataFrame({
'customer_id': range(100),
'total_spent': np.random.uniform(100, 5000, 100),
'visits': np.random.randint(1, 50, 100)
})

# Spending tier
df['spending_tier'] = pd.qcut(
df['total_spent'],
q=4,
labels=['Bronze', 'Silver', 'Gold', 'Platinum']
)

# Engagement level
df['engagement'] = pd.cut(
df['visits'],
bins=[0, 5, 15, 50],
labels=['Low', 'Medium', 'High']
)

# Segment (combination)
df['segment'] = df['spending_tier'].astype(str) + '-' + df['engagement'].astype(str)

Risk Scoring​

Create risk categories
df = pd.DataFrame({
'credit_score': np.random.randint(300, 850, 1000)
})

# Risk levels
bins = [300, 580, 670, 740, 850]
labels = ['Very High Risk', 'High Risk', 'Medium Risk', 'Low Risk']
df['risk_level'] = pd.cut(
df['credit_score'],
bins=bins,
labels=labels
)

# Count by risk
df['risk_level'].value_counts().sort_index()

Performance Tips​

Optimize categorical operations
# Convert to categorical once, not repeatedly
df['status'] = df['status'].astype('category') # Do this once

# Not this (slow)
for col in df.columns:
if df[col].dtype == 'object':
df[col] = df[col].astype('category') # Repeated conversion

# Better: convert all object columns at once
object_cols = df.select_dtypes(include=['object']).columns
df[object_cols] = df[object_cols].astype('category')

Quick Reference​

Binning:

pd.cut(series, bins=4) # Equal-width bins
pd.cut(series, bins=[0, 10, 20]) # Custom edges
pd.qcut(series, q=4) # Equal-frequency bins
pd.qcut(series, q=[0, 0.25, 0.5, 1]) # Custom percentiles

Categorical:

series.astype('category') # Convert to categorical
pd.Categorical(series, ordered=True) # Create ordered
series.cat.categories # View categories
series.cat.codes # Get numeric codes
series.cat.add_categories(['new']) # Add category
series.cat.remove_unused_categories() # Remove unused

Operations:

pd.get_dummies(series) # One-hot encoding
series.cat.reorder_categories([...]) # Change order
df.groupby('cat', observed=False) # Include all categories
series.cat.rename_categories({...}) # Rename

Common patterns:

# Age groups
pd.cut(df['age'], bins=[0, 18, 35, 50, 65, 100])

# Quartiles
pd.qcut(df['value'], q=4, labels=['Q1', 'Q2', 'Q3', 'Q4'])

# Custom binning
df['value'].apply(lambda x: 'high' if x > 100 else 'low')

# Memory optimization
df['col'].astype('category') # For repeated values