import pandas as pd
import numpy as np
# Seed for reproducibility
np.random.seed(42)Common Data Processing Practices
Common Data Processing practices
Introduction
Real-world datasets are rarely clean. They arrive with missing values, duplicate records, inconsistent formats, outliers, and invalid entries. Before any meaningful analysis or modeling can take place, these issues must be identified and resolved. Data cleaning is often the most time-consuming step in a data science workflow — estimates suggest it can consume 60–80% of a project’s time — yet it is also the most critical. A model trained on dirty data will produce unreliable results, no matter how sophisticated the algorithm.
This tutorial walks through common data cleaning practices using Python, pandas, and NumPy. Each section introduces a problem, demonstrates how to detect it, and shows how to fix it using realistic sample data.
Setup
Handling Missing Values
Missing values are one of the most frequent issues in raw data. They can arise from sensor failures, incomplete surveys, or human error during data entry. Pandas provides several tools for detecting and dealing with them.
# Create a sample DataFrame with missing values
df = pd.DataFrame({
'name': ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'],
'age': [25, np.nan, 35, 28, np.nan],
'salary': [50000, 62000, np.nan, 48000, 55000],
'department': ['Engineering', np.nan, 'Marketing', 'Engineering', 'Sales']
})
df| name | age | salary | department | |
|---|---|---|---|---|
| 0 | Alice | 25.0 | 50000.0 | Engineering |
| 1 | Bob | NaN | 62000.0 | NaN |
| 2 | Charlie | 35.0 | NaN | Marketing |
| 3 | Diana | 28.0 | 48000.0 | Engineering |
| 4 | Eve | NaN | 55000.0 | Sales |
Detecting Missing Values
# Check which cells are null
print("Null mask:\n", df.isna())
# Count missing values per column
print("\nMissing per column:\n", df.isna().sum())Null mask:
name age salary department
0 False False False False
1 False True False True
2 False False True False
3 False False False False
4 False True False False
Missing per column:
name 0
age 2
salary 1
department 1
dtype: int64
Filling Missing Values
# Fill numeric columns with the column median
df['age'] = df['age'].fillna(df['age'].median())
df['salary'] = df['salary'].fillna(df['salary'].median())
# Fill categorical columns with the most frequent value (mode)
df['department'] = df['department'].fillna(df['department'].mode()[0])
df| name | age | salary | department | |
|---|---|---|---|---|
| 0 | Alice | 25.0 | 50000.0 | Engineering |
| 1 | Bob | 28.0 | 62000.0 | Engineering |
| 2 | Charlie | 35.0 | 52500.0 | Marketing |
| 3 | Diana | 28.0 | 48000.0 | Engineering |
| 4 | Eve | 28.0 | 55000.0 | Sales |
Dropping Rows or Columns
# Alternative approach: drop rows with any null value
df_dropped_rows = df.dropna()
# Drop columns where more than 50% of values are missing
df_dropped_cols = df.dropna(thresh=len(df) * 0.5, axis=1)Removing Duplicates
Duplicate rows can inflate counts, bias aggregations, and mislead statistical tests. Identifying and removing them is straightforward with pandas.
# Create a DataFrame with intentional duplicates
df_dup = pd.DataFrame({
'customer_id': [101, 102, 103, 101, 104, 102],
'product': ['Laptop', 'Mouse', 'Keyboard', 'Laptop', 'Monitor', 'Mouse'],
'price': [1200, 25, 80, 1200, 300, 25],
'date': ['2024-01-15', '2024-01-16', '2024-01-16',
'2024-01-15', '2024-01-17', '2024-01-16']
})
df_dup| customer_id | product | price | date | |
|---|---|---|---|---|
| 0 | 101 | Laptop | 1200 | 2024-01-15 |
| 1 | 102 | Mouse | 25 | 2024-01-16 |
| 2 | 103 | Keyboard | 80 | 2024-01-16 |
| 3 | 101 | Laptop | 1200 | 2024-01-15 |
| 4 | 104 | Monitor | 300 | 2024-01-17 |
| 5 | 102 | Mouse | 25 | 2024-01-16 |
Detecting Duplicates
# Check which rows are duplicates (keep='first' marks subsequent occurrences)
df_dup.duplicated()0 False
1 False
2 False
3 True
4 False
5 True
dtype: bool
# Show only the duplicate rows
df_dup[df_dup.duplicated(keep=False)]| customer_id | product | price | date | |
|---|---|---|---|---|
| 0 | 101 | Laptop | 1200 | 2024-01-15 |
| 1 | 102 | Mouse | 25 | 2024-01-16 |
| 3 | 101 | Laptop | 1200 | 2024-01-15 |
| 5 | 102 | Mouse | 25 | 2024-01-16 |
Removing Duplicates
# Drop duplicate rows, keeping the first occurrence
df_clean = df_dup.drop_duplicates()
df_clean| customer_id | product | price | date | |
|---|---|---|---|---|
| 0 | 101 | Laptop | 1200 | 2024-01-15 |
| 1 | 102 | Mouse | 25 | 2024-01-16 |
| 2 | 103 | Keyboard | 80 | 2024-01-16 |
| 4 | 104 | Monitor | 300 | 2024-01-17 |
# Remove duplicates based on specific columns only
df_dup.drop_duplicates(subset=['customer_id', 'product'])| customer_id | product | price | date | |
|---|---|---|---|---|
| 0 | 101 | Laptop | 1200 | 2024-01-15 |
| 1 | 102 | Mouse | 25 | 2024-01-16 |
| 2 | 103 | Keyboard | 80 | 2024-01-16 |
| 4 | 104 | Monitor | 300 | 2024-01-17 |
Type Conversion
Data often arrives with incorrect types — numeric columns stored as strings, dates stored as text, or categorical values stored as integers. Converting types correctly is essential for downstream operations.
# Create a DataFrame with mixed types
df_types = pd.DataFrame({
'id': ['001', '002', '003', '004'],
'price': ['12.50', '8.00', 'invalid', '15.30'],
'quantity': [3, 5, 2, 4],
'date_string': ['2024-01-01', '2024-02-15', '2024-03-10', '2024-04-22'],
'category_code': [1, 2, 1, 3]
})
df_types.dtypesid str
price str
quantity int64
date_string str
category_code int64
dtype: object
Converting with astype()
# Convert 'id' from string to integer
df_types['id'] = df_types['id'].astype(int)
# Convert 'category_code' to categorical dtype
df_types['category_code'] = df_types['category_code'].astype('category')
df_types.dtypesid int64
price str
quantity int64
date_string str
category_code category
dtype: object
Safe Numeric Conversion with to_numeric()
# to_numeric() handles invalid values: coerce turns 'invalid' into NaN
df_types['price'] = pd.to_numeric(df_types['price'], errors='coerce')
# Fill the resulting NaN with the median
df_types['price'] = df_types['price'].fillna(df_types['price'].median())
df_types| id | price | quantity | date_string | category_code | |
|---|---|---|---|---|---|
| 0 | 1 | 12.5 | 3 | 2024-01-01 | 1 |
| 1 | 2 | 8.0 | 5 | 2024-02-15 | 2 |
| 2 | 3 | 12.5 | 2 | 2024-03-10 | 1 |
| 3 | 4 | 15.3 | 4 | 2024-04-22 | 3 |
Converting to Datetime
# Parse string dates into proper datetime objects
df_types['purchase_date'] = pd.to_datetime(df_types['date_string'])
# Extract useful components
df_types['year'] = df_types['purchase_date'].dt.year
df_types['month'] = df_types['purchase_date'].dt.month
df_types['day_of_week'] = df_types['purchase_date'].dt.day_name()
df_types[['date_string', 'purchase_date', 'year', 'month', 'day_of_week']]| date_string | purchase_date | year | month | day_of_week | |
|---|---|---|---|---|---|
| 0 | 2024-01-01 | 2024-01-01 | 2024 | 1 | Monday |
| 1 | 2024-02-15 | 2024-02-15 | 2024 | 2 | Thursday |
| 2 | 2024-03-10 | 2024-03-10 | 2024 | 3 | Sunday |
| 3 | 2024-04-22 | 2024-04-22 | 2024 | 4 | Monday |
Outlier Detection
Outliers are extreme values that deviate significantly from the rest of the data. They can result from measurement errors, data entry mistakes, or genuine rare events. Two common methods for detecting outliers are the IQR method and the Z-score method.
# Generate sample data with outliers
np.random.seed(42)
data = np.random.normal(loc=50, scale=10, size=100).tolist()
data.extend([5, 8, 95, 98]) # Inject outliers
df_out = pd.DataFrame({'value': data})Interquartile Range (IQR) Method
The IQR method identifies values that fall outside 1.5 times the interquartile range below Q1 or above Q3. It is robust to skewed distributions because it relies on percentiles rather than the mean.
Q1 = df_out['value'].quantile(0.25)
Q3 = df_out['value'].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
# Find outliers
outliers_iqr = df_out[(df_out['value'] < lower_bound) | (df_out['value'] > upper_bound)]
print(f"Lower bound: {lower_bound:.2f}, Upper bound: {upper_bound:.2f}")
print(f"Outliers detected: {len(outliers_iqr)}")
outliers_iqr.head()Lower bound: 27.17, Upper bound: 71.71
Outliers detected: 5
| value | |
|---|---|
| 74 | 23.802549 |
| 100 | 5.000000 |
| 101 | 8.000000 |
| 102 | 95.000000 |
| 103 | 98.000000 |
Z-Score Method
The Z-score method measures how many standard deviations a value is from the mean. Values beyond a threshold (commonly 3) are flagged as outliers. This method assumes the data is approximately normally distributed.
from scipy import stats
z_scores = np.abs(stats.zscore(df_out['value']))
df_out['z_score'] = z_scores
# Flag values with |z-score| > 3
outliers_z = df_out[df_out['z_score'] > 3]
print(f"Outliers detected: {len(outliers_z)}")
outliers_z.head()Outliers detected: 4
| value | z_score | |
|---|---|---|
| 100 | 5.0 | 3.519431 |
| 101 | 8.0 | 3.279792 |
| 102 | 95.0 | 3.669737 |
| 103 | 98.0 | 3.909376 |
Handling Outliers
# Option 1: Remove outliers (IQR method)
df_no_outliers = df_out[(df_out['value'] >= lower_bound) & (df_out['value'] <= upper_bound)]
# Option 2: Cap outliers at the bounds (winsorization)
df_out['value_capped'] = df_out['value'].clip(lower=lower_bound, upper=upper_bound)
print(f"Original rows: {len(df_out)}, After removal: {len(df_no_outliers)}")Original rows: 104, After removal: 99
Data Validation
After cleaning, it is prudent to verify that the data meets expected constraints. Validation catches errors that may have been missed and ensures the dataset is ready for analysis.
# Create a validated dataset
df_val = pd.DataFrame({
'age': [22, 34, 28, 120, 45, 19, -5],
'email': ['[email protected]', '[email protected]', '[email protected]', '[email protected]', '[email protected]', '[email protected]', '[email protected]'],
'score': [85, 92, 78, 95, 88, 76, 90],
'status': ['active', 'inactive', 'active', 'pending', 'active', 'inactive', 'unknown']
})
df_val| age | score | status | ||
|---|---|---|---|---|
| 0 | 22 | [email protected] | 85 | active |
| 1 | 34 | [email protected] | 92 | inactive |
| 2 | 28 | [email protected] | 78 | active |
| 3 | 120 | [email protected] | 95 | pending |
| 4 | 45 | [email protected] | 88 | active |
| 5 | 19 | [email protected] | 76 | inactive |
| 6 | -5 | [email protected] | 90 | unknown |
Range Checks
# Validate age is within a reasonable range (0 to 120)
invalid_age = df_val[(df_val['age'] < 0) | (df_val['age'] > 120)]
print(f"Invalid age entries: {len(invalid_age)}")
invalid_ageInvalid age entries: 1
| age | score | status | ||
|---|---|---|---|---|
| 6 | -5 | [email protected] | 90 | unknown |
# Fix: clip ages to valid range
df_val['age'] = df_val['age'].clip(lower=0, upper=120)Unique and Domain Constraints
# Check for duplicate emails
duplicate_emails = df_val[df_val['email'].duplicated(keep=False)]
print(f"Duplicate emails: {len(duplicate_emails)}")
# Ensure status values belong to an allowed set
valid_statuses = {'active', 'inactive', 'pending'}
invalid_status = df_val[~df_val['status'].isin(valid_statuses)]
print(f"Invalid status entries: {len(invalid_status)}")
invalid_statusDuplicate emails: 4
Invalid status entries: 1
| age | score | status | ||
|---|---|---|---|---|
| 6 | 0 | [email protected] | 90 | unknown |
# Fix: drop exact duplicates, replace invalid statuses with a default
df_val = df_val.drop_duplicates(subset=['email'])
df_val['status'] = df_val['status'].apply(
lambda x: x if x in valid_statuses else 'inactive'
)
print("Cleaned dataset:")
df_valCleaned dataset:
| age | score | status | ||
|---|---|---|---|---|
| 0 | 22 | [email protected] | 85 | active |
| 1 | 34 | [email protected] | 92 | inactive |
| 3 | 120 | [email protected] | 95 | pending |
| 5 | 19 | [email protected] | 76 | inactive |
| 6 | 0 | [email protected] | 90 | inactive |
Conclusion
Data cleaning is an iterative process, not a one-time task. The practices covered in this tutorial — handling missing values, removing duplicates, converting types, detecting outliers, and validating data — form the foundation of any robust data pipeline. Some guiding principles to keep in mind:
- Inspect first, clean second. Always profile your data with
.info(),.describe(), and.isna().sum()before modifying anything. - Document your cleaning steps. Whether in code comments or a pipeline log, reproducibility matters.
- Know your domain. A value that looks like an outlier in one context may be perfectly valid in another — consult domain experts before discarding data.
- Validate after cleaning. Re-run your checks after transformations to confirm the fixes were applied correctly.
Clean data leads to reliable insights. Invest the time upfront, and your analyses will be stronger for it.