Beginner Intermediate 13 min read

Chapter 29: Data Acquisition & Wrangling

Data rarely arrives in the form you need. In practice, data scientists spend the majority of their time — commonly cited at 60–80% — not building models, but finding, loading, cleaning, and reshaping data before any analysis can begin. This chapter treats that work as a first-class engineering discipline, not a chore to rush through. Done well, data acquisition and wrangling is where domain knowledge meets software craft: understanding why a timestamp is malformed or what a duplicate row implies is just as important as knowing which function call fixes it.

We cover the full acquisition stack — reading flat files, semi-structured JSON and Parquet, calling REST APIs, scraping HTML, and pulling from relational databases via SQL — and then move into systematic wrangling with pandas (Python) and the tidyverse (R). The two ecosystems share deep conceptual DNA: both center on the idea of a tidy, rectangular data frame as the canonical intermediate representation, and both provide composable, readable operations for filtering, transforming, joining, and reshaping. Seeing them side by side sharpens intuition for what the operations mean, independent of syntax.

By the end of this chapter you will have a repeatable toolkit for ingesting messy real-world data and delivering a clean, validated data frame ready for exploratory analysis or feature engineering. Every technique is illustrated with runnable code and realistic edge cases — the kind you encounter on the job, not in toy datasets.

Runnable companion notebook for this chapter. Download the Jupyter notebook (.ipynb)
Libraries covered: dplyrNumPyPandasSQLAlchemy

Learning Objectives

  • Read data from CSV, JSON, Parquet, REST APIs, HTML tables, and SQL databases in both Python (pandas) and R (tidyverse/readr).
  • Diagnose and repair common data quality problems: missing values, wrong types, inconsistent encodings, and duplicate rows.
  • Parse, convert, and compute with date/time columns using pandas Timestamp machinery and R lubridate.
  • Perform inner, left, right, and anti joins on multiple tables in both pandas and dplyr, and understand the SQL equivalents.
  • Reshape data between wide and long (tidy) formats using melt/pivot<em>wider and pivot</em>longer/pivot_wider.
  • Write SQL extraction queries that push filtering and aggregation into the database before loading into a data frame.
  • Validate a cleaned dataset programmatically using pandera (Python) and basic assertion patterns (R/dplyr).

29.1 Reading Data: Files, APIs, Databases, and the Web Beginner

Reading Data: Files, APIs, Databases, and the Web

The first step in any data project is getting data into memory. Data sources differ in latency, schema enforcement, and reliability — choosing the right ingestion path determines how much wrangling follows.

Flat Files: CSV and TSV

CSV remains the universal interchange format despite its many ambiguities (quoting rules, encodings, line endings). pandas and readr both handle the common cases well, but you must be explicit about encoding and type hints for large or non-ASCII files.

PYTHON
import pandas as pd

# Specify encoding and parse dates at read time
df = pd.read_csv(
    "sales_2024.csv",
    encoding="utf-8",
    parse_dates=["order_date"],
    dtype={"product_id": str},   # prevent integer coercion
    na_values=["", "NA", "N/A", "null", "-"],
)
print(df.dtypes)
print(df.shape)

R
library(readr)
library(dplyr)

df <- read_csv(
  "sales_2024.csv",
  col_types = cols(
    order_date  = col_date(format = "%Y-%m-%d"),
    product_id  = col_character(),
    revenue     = col_double()
  ),
  na = c("", "NA", "N/A", "null", "-")
)
glimpse(df)

Pitfall: pd.read_csv infers dtypes from the first few thousand rows. A column that looks like integers may contain &quot;N/A&quot; on row 500 000. Always inspect df.dtypes immediately after loading.

Parquet: Columnar Storage for Larger Data

Parquet stores data in a columnar, compressed binary format. Reading a Parquet file is typically 5–20x faster than an equivalent CSV for analytical queries because only requested columns are decompressed.

PYTHON
import pyarrow.parquet as pq

# Read only needed columns — IO and memory savings
table = pq.read_table("events.parquet", columns=["user_id", "event_ts", "event_type"])
df = table.to_pandas()

R
library(arrow)

df <- read_parquet("events.parquet", col_select = c(user_id, event_ts, event_type))

JSON: Nested and Semi-Structured Data

JSON is ubiquitous in API responses. The challenge is that JSON allows nesting and arrays where a data frame expects flat, rectangular data.

PYTHON
import json

with open("products.json") as f:
    raw = json.load(f)

# json_normalize flattens nested keys with a separator
df = pd.json_normalize(raw["items"], sep="_")
# nested lists become columns of lists — explode them
df = df.explode("tags").reset_index(drop=True)

R
library(jsonlite)
library(tidyr)

raw  <- fromJSON("products.json", flatten = TRUE)
df   <- as_tibble(raw$items) |>
  unnest_longer(tags)

REST APIs

Most modern data sources expose a REST API. A disciplined pattern handles pagination and HTTP errors cleanly.

PYTHON
import requests
import time

def fetch_all_pages(base_url: str, params: dict, page_key="page") -> list:
    results = []
    page = 1
    while True:
        params[page_key] = page
        r = requests.get(base_url, params=params, timeout=10)
        r.raise_for_status()          # raises on 4xx/5xx
        data = r.json()
        if not data.get("items"):
            break
        results.extend(data["items"])
        page += 1
        time.sleep(0.2)               # be polite to the server
    return results

records = fetch_all_pages("https://api.example.com/orders", {"per_page": 100})
df = pd.DataFrame(records)

Web Scraping: HTML Tables

For data published as HTML (government portals, Wikipedia, legacy dashboards), scraping with BeautifulSoup or rvest is often the only option.

PYTHON
import requests
from bs4 import BeautifulSoup

html = requests.get("https://en.wikipedia.org/wiki/List_of_countries_by_GDP").text
soup = BeautifulSoup(html, "lxml")
# pandas can parse the list of DataFrames that read_html returns
tables = pd.read_html(html)           # returns list of DataFrames
gdp_df = tables[1]                   # inspect index manually

R
library(rvest)

page  <- read_html("https://en.wikipedia.org/wiki/List_of_countries_by_GDP")
table <- page |> html_element("table.wikitable") |> html_table()

SQL Extraction

Pushing aggregation and filtering into the database before pulling data into memory is almost always the right approach — the database engine is optimized for this work.

PYTHON
from sqlalchemy import create_engine
import pandas as pd

engine = create_engine("postgresql+psycopg2://user:pass@localhost/analytics")

query = """
    SELECT
        customer_id,
        DATE_TRUNC('month', order_date) AS month,
        SUM(revenue)                    AS monthly_revenue
    FROM orders
    WHERE order_date >= '2023-01-01'
    GROUP BY 1, 2
    ORDER BY 1, 2
"""
df = pd.read_sql(query, engine)

R
library(DBI)
library(RSQLite)

con <- dbConnect(RSQLite::SQLite(), "analytics.sqlite")
df  <- dbGetQuery(con, "
  SELECT customer_id,
         strftime('%Y-%m', order_date) AS month,
         SUM(revenue)                  AS monthly_revenue
  FROM   orders
  WHERE  order_date >= '2023-01-01'
  GROUP  BY 1, 2
  ORDER  BY 1, 2
")
dbDisconnect(con)

Why it matters: A table with 500 million rows might aggregate to 10 000 rows for your analysis. Pulling all 500 million rows into Python/R memory first is a common, costly mistake.

Code Examples

Reading a multi-sheet Excel workbook

PYTHON
import pandas as pd

# Read all sheets at once into a dict of DataFrames
all_sheets = pd.read_excel("quarterly_report.xlsx", sheet_name=None, engine="openpyxl")

for sheet_name, df in all_sheets.items():
    print(f"{sheet_name}: {df.shape}")

# Concatenate sheets that share a schema
combined = pd.concat(all_sheets.values(), ignore_index=True)
print(combined.shape)
Output
Q1: (1200, 8)
Q2: (1350, 8)
Q3: (1180, 8)
Q4: (1420, 8)
(5150, 8)

Anti-join pattern in SQL to find unmatched rows

The LEFT JOIN / IS NULL pattern is the standard SQL anti-join. It runs efficiently when both join keys are indexed.

SQL
-- Find orders with no matching customer record
SELECT o.order_id, o.customer_id, o.order_date
FROM   orders o
LEFT JOIN customers c ON o.customer_id = c.customer_id
WHERE  c.customer_id IS NULL
ORDER  BY o.order_date DESC
LIMIT  20;

Fetching a JSON API with httr and parsing with jsonlite

R
library(httr)
library(jsonlite)
library(dplyr)

resp <- GET(
  "https://api.coindesk.com/v1/bpi/currentprice.json",
  add_headers(Accept = "application/json")
)
stop_for_status(resp)   # raises on HTTP errors

price_list <- content(resp, as = "text", encoding = "UTF-8") |>
  fromJSON(flatten = TRUE)

# Pull USD rate out of nested list
usd_rate <- price_list$bpi$USD$rate_float
cat("BTC/USD:", usd_rate, "\n")

29.2 Handling Missing Data, Types, and Encodings Beginner

Handling Missing Data, Types, and Encodings

Missing data is almost never truly random. Before deciding how to handle a missing value, you must understand why it is missing — the mechanism determines whether imputation, deletion, or flagging is appropriate.

The Three Missing-Data Mechanisms

Missing Completely at Random (MCAR): Missingness is independent of observed and unobserved data. Rare in practice. Listwise deletion is valid but wastes information.

Missing at Random (MAR): Missingness depends on observed data but not on the missing value itself. Example: senior respondents skip income questions, but we observe their age. Imputation conditioned on age is valid.

Missing Not at Random (MNAR): Missingness depends on the unobserved value. Example: high earners skip income questions. Any imputation introduces bias without a strong model assumption.

A practical first step is to quantify and visualize missingness.

PYTHON
import pandas as pd
import numpy as np

def missing_summary(df: pd.DataFrame) -> pd.DataFrame:
    n = len(df)
    counts = df.isna().sum()
    return (
        counts[counts > 0]
        .rename("missing_count")
        .to_frame()
        .assign(missing_pct=lambda x: 100 * x["missing_count"] / n)
        .sort_values("missing_pct", ascending=False)
    )

print(missing_summary(df))

R
library(dplyr)

missing_summary <- function(df) {
  df |>
    summarise(across(everything(), ~sum(is.na(.)))) |>
    tidyr::pivot_longer(everything(), names_to = "column", values_to = "missing_count") |>
    mutate(missing_pct = 100 * missing_count / nrow(df)) |>
    filter(missing_count > 0) |>
    arrange(desc(missing_pct))
}

missing_summary(df)

Imputation Strategies

For numeric columns, the common strategies are:

  • Drop rows when missingness is MCAR and the fraction is small (< 5%).
  • Fill with median/mean — fast but ignores correlations.
  • Forward/backward fill — appropriate for time series where the last known value is a reasonable estimate.
  • Model-based imputation — use sklearn.impute.IterativeImputer (MICE) for MAR data.

PYTHON
# Simple fill strategies
df["price"] = df["price"].fillna(df["price"].median())
df["status"] = df["status"].fillna("unknown")

# Time-series forward fill
df = df.sort_values("timestamp").set_index("timestamp")
df["sensor_reading"] = df["sensor_reading"].ffill(limit=3)  # fill at most 3 gaps
df = df.reset_index()

R
library(tidyr)
library(dplyr)

df <- df |>
  arrange(timestamp) |>
  tidyr::fill(sensor_reading, .direction = "down")   # equivalent to ffill

Type Conversion and Coercion

Reading data with inferred types is dangerous. The canonical workflow is: read everything as strings (or let the parser make a first guess), then coerce explicitly.

PYTHON
# Numeric coercion with error control
df["revenue"] = pd.to_numeric(df["revenue"], errors="coerce")  # invalid -> NaN

# Categorical: saves memory for low-cardinality string columns
df["region"] = df["region"].astype("category")

# Boolean from string representations
bool_map = {"true": True, "yes": True, "1": True,
            "false": False, "no": False, "0": False}
df["is_active"] = df["is_active"].str.lower().map(bool_map)

# Inspect memory before and after
print(df.memory_usage(deep=True).sum() / 1e6, "MB")

R
library(dplyr)
library(readr)

df <- df |>
  mutate(
    revenue   = parse_number(revenue),        # handles "$1,234.56" gracefully
    region    = as.factor(region),
    is_active = recode(tolower(is_active),
                       "true" = TRUE, "yes" = TRUE, "1" = TRUE,
                       "false" = FALSE, "no" = FALSE, "0" = FALSE)
  )

Encodings and Character Sets

Non-ASCII characters cause silent data corruption when the encoding is wrong. Always confirm the encoding before reading.

BASH
# Detect encoding with chardet (install: pip install chardet)
python -c "
import chardet
with open('mystery_file.csv', 'rb') as f:
    result = chardet.detect(f.read(100_000))
print(result)
"

Common culprits:

  • Latin-1 / ISO-8859-1 — legacy European data, especially from Excel exports.
  • UTF-16 — Windows systems, often with a BOM (\xff\xfe).
  • CP1252 — Windows "ANSI" encoding, subtly different from Latin-1.

PYTHON
# Explicitly handle the BOM
df = pd.read_csv("windows_export.csv", encoding="utf-8-sig")

Sentinel Values and Domain Violations

Beyond NaN, real datasets use sentinel values like -1, 9999, 0, or &quot;unknown&quot; to encode missing or invalid observations. These are not caught by df.isna() and must be handled explicitly.

PYTHON
# Replace domain-invalid sentinels with NaN
df["age"] = df["age"].where(df["age"].between(0, 120))
df["zip_code"] = df["zip_code"].replace(["00000", "99999", "N/A"], np.nan)

Pitfall: Always check descriptive statistics (df.describe()) immediately after loading. Values like age = -1 or revenue = 9999999 stand out clearly in the min/max rows.

Code Examples

Detecting and removing near-duplicate string values with fuzzy matching

PYTHON
from thefuzz import process
import pandas as pd

# Canonical list of known category names
CANONICAL = ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"]

def canonicalize(value: str, threshold: int = 85) -> str:
    match, score = process.extractOne(value, CANONICAL)
    return match if score >= threshold else value

df = pd.DataFrame({"city": ["New york", "los angeles", "Chikago", "Huston", "Phoenix"]})
df["city_clean"] = df["city"].apply(canonicalize)
print(df)
Output
         city    city_clean
0    New york      New York
1  los angeles   Los Angeles
2     Chikago       Chicago
3      Huston       Houston
4     Phoenix       Phoenix

MICE-style multivariate imputation with sklearn

PYTHON
import pandas as pd
import numpy as np
from sklearn.experimental import enable_iterative_imputer  # noqa
from sklearn.impute import IterativeImputer

np.random.seed(42)
df = pd.DataFrame({
    "age":    [25, np.nan, 35, 42, np.nan, 55],
    "income": [50000, 62000, np.nan, 85000, 78000, np.nan],
    "score":  [72, 68, 81, np.nan, 90, 65],
})

imp = IterativeImputer(max_iter=10, random_state=42)
df_imputed = pd.DataFrame(imp.fit_transform(df), columns=df.columns)
print(df_imputed.round(1))
Output
    age   income  score
0  25.0  50000.0   72.0
1  31.4  62000.0   68.0
2  35.0  67200.3   81.0
3  42.0  85000.0   76.8
4  39.7  78000.0   90.0
5  55.0  74985.6   65.0

Visualizing missing data patterns with naniar

R
# install.packages(c("naniar", "ggplot2"))
library(naniar)
library(ggplot2)
library(dplyr)

# Simulate a dataset with structured missingness
set.seed(42)
df <- tibble(
  age     = c(25, NA, 35, 42, NA, 55, 29, NA),
  income  = c(50000, 62000, NA, 85000, 78000, NA, 45000, 91000),
  region  = c("North", "South", "East", NA, "West", "North", NA, "East")
)

# Summary of missingness
miss_var_summary(df)

# Visual: upset plot showing co-missingness patterns
gg_miss_upset(df)

29.3 Dates, Times, and Temporal Data Intermediate

Dates, Times, and Temporal Data

Temporal data is omnipresent — log files, transactions, sensor streams — and consistently treacherous. Timezone confusion, ambiguous formats, and mixed representations are among the most common sources of subtle, hard-to-detect bugs.

Parsing Datetimes in pandas

The single most important rule: never store dates as strings after ingestion. Parse them immediately.

PYTHON
import pandas as pd

# Parse at read time where possible
df = pd.read_csv("events.csv", parse_dates=["event_time"])

# Manual parsing when formats are non-standard
df["event_time"] = pd.to_datetime(df["event_time"], format="%d/%m/%Y %H:%M:%S",
                                   errors="coerce")  # coerce -> NaT on failure

# Inspect failures
bad = df[df["event_time"].isna()]
print(f"{len(bad)} rows failed to parse")

The format argument is critical for performance: without it, pandas falls back to a slow universal parser that tries dozens of formats. For large files, specifying format yields a 10–30x speedup.

Timezone Handling

Timestamps without timezone information (naive timestamps) are a latent bug. Two naive timestamps from different timezones will compare incorrectly.

PYTHON
# Attach timezone to a naive column
df["event_time"] = df["event_time"].dt.tz_localize("UTC")

# Convert to a local timezone
df["event_time_eastern"] = df["event_time"].dt.tz_convert("America/New_York")

# Store a UTC-normalized column for safe arithmetic
df["event_time_utc"] = df["event_time"].dt.tz_convert("UTC")

Best practice: Store all timestamps in UTC internally. Convert to local time only at display time. This avoids DST ambiguity bugs entirely.

Date Arithmetic and Feature Extraction

The dt accessor exposes a rich set of properties and methods for feature engineering.

PYTHON
# Component extraction
df["year"]       = df["event_time"].dt.year
df["month"]      = df["event_time"].dt.month
df["day_of_week"] = df["event_time"].dt.day_name()   # 'Monday', ...
df["hour"]       = df["event_time"].dt.hour
df["is_weekend"] = df["event_time"].dt.dayofweek >= 5

# Duration between events
df = df.sort_values(["user_id", "event_time"])
df["seconds_since_last"] = (
    df.groupby("user_id")["event_time"]
    .diff()
    .dt.total_seconds()
)

Dates in R with lubridate

lubridate wraps base R's date handling in a readable, consistent API.

R
library(lubridate)
library(dplyr)

df <- df |>
  mutate(
    event_time       = ymd_hms(event_time, tz = "UTC"),
    event_time_et    = with_tz(event_time, "America/New_York"),
    year             = year(event_time),
    month            = month(event_time, label = TRUE),
    hour             = hour(event_time),
    day_of_week      = wday(event_time, label = TRUE),
    is_weekend       = wday(event_time) %in% c(1, 7)
  )

# Time between events per user
df <- df |>
  arrange(user_id, event_time) |>
  group_by(user_id) |>
  mutate(secs_since_last = as.numeric(event_time - lag(event_time), units = "secs")) |>
  ungroup()

Period Arithmetic and Business Days

Calendar arithmetic is full of traps: adding 30 days is not the same as adding 1 month; leap years affect annual calculations; business days exclude weekends and holidays.

PYTHON
import pandas as pd
from pandas.tseries.offsets import BDay, MonthEnd

# Add 1 business day
df["due_date"] = df["order_date"] + BDay(1)

# Snap to end of month
df["month_end"] = df["order_date"] + MonthEnd(0)

# Business-day count between two dates
df["bdays_to_ship"] = df.apply(
    lambda r: len(pd.bdate_range(r["order_date"], r["ship_date"])) - 1,
    axis=1
)

Time Series Resampling

Resampling aggregates irregular event data onto a regular time grid — essential before plotting or modeling.

PYTHON
# Resample to hourly counts
hourly = (
    df.set_index("event_time")
    .resample("1h")["event_id"]
    .count()
    .rename("event_count")
    .reset_index()
)

# Fill gaps in the time grid with zeros
hourly = hourly.set_index("event_time").asfreq("1h", fill_value=0).reset_index()

Common pitfall: resample silently drops rows with NaT index values. Always check df[&quot;event_time&quot;].isna().sum() before resampling.

Code Examples

Parsing mixed-format date columns robustly

PYTHON
import pandas as pd

# Mixed formats in the wild
dates = pd.Series([
    "2024-01-15",
    "15/01/2024",
    "January 15, 2024",
    "01-15-2024",
    "bad_date",
    None,
])

# pandas infer_datetime_format handles many formats; errors='coerce' for the rest
parsed = pd.to_datetime(dates, infer_datetime_format=True, errors="coerce")
print(parsed)
print(f"\nFailed to parse: {parsed.isna().sum()} values")
Output
0   2024-01-15
1   2024-01-15
2   2024-01-15
3   2024-01-15
4          NaT
5          NaT
dtype: datetime64[ns]

Failed to parse: 2 values

Aggregating events to daily counts and filling gaps

R
library(dplyr)
library(lubridate)
library(tidyr)

set.seed(7)
events <- tibble(
  event_time = as.POSIXct("2024-01-01") + runif(200, 0, 30 * 86400),
  event_type = sample(c("click", "view", "purchase"), 200, replace = TRUE)
)

daily <- events |>
  mutate(date = as_date(event_time)) |>
  count(date, event_type, name = "n_events") |>
  complete(
    date  = seq(min(date), max(date), by = "day"),
    event_type,
    fill  = list(n_events = 0L)
  ) |>
  arrange(event_type, date)

print(daily, n = 10)

Rolling 7-day average with timezone-aware index

PYTHON
import pandas as pd
import numpy as np

np.random.seed(0)
idx = pd.date_range("2024-01-01", periods=60, freq="D", tz="UTC")
ts  = pd.Series(np.random.poisson(lam=50, size=60), index=idx, name="daily_sales")

rolling_avg = ts.rolling(window=7, min_periods=1).mean().rename("7d_avg")
result = pd.concat([ts, rolling_avg], axis=1)
print(result.head(10).round(1))
Output
                           daily_sales  7d_avg
2024-01-01 00:00:00+00:00           54    54.0
2024-01-02 00:00:00+00:00           57    55.5
2024-01-03 00:00:00+00:00           56    55.7
2024-01-04 00:00:00+00:00           47    53.5
2024-01-05 00:00:00+00:00           51    53.0
2024-01-06 00:00:00+00:00           59    54.0
2024-01-07 00:00:00+00:00           44    52.6
2024-01-08 00:00:00+00:00           51    52.1
2024-01-09 00:00:00+00:00           44    50.3
2024-01-10 00:00:00+00:00           48    49.1

29.4 Joins, Merges, Reshaping, and Deduplication Intermediate

Joins, Merges, Reshaping, and Deduplication

Real analytical datasets are assembled from multiple sources. Mastering joins, reshaping, and deduplication is what separates a practitioner who can answer questions from data from one who cannot.

Join Types and Their SQL Equivalents

Four fundamental joins cover nearly every scenario:

  • Inner join — rows present in both tables. SQL: INNER JOIN.
  • Left join — all rows from the left table; NaN/NULL for unmatched right. SQL: LEFT JOIN.
  • Right join — all rows from the right table. Equivalent to swapping sides in a left join.
  • Full outer join — all rows from both; NaN where unmatched. SQL: FULL OUTER JOIN.
  • Anti join — rows in the left table with no match on the right. Diagnoses data quality gaps.

PYTHON
import pandas as pd

orders = pd.DataFrame({
    "order_id":    [1, 2, 3, 4],
    "customer_id": [10, 11, 12, 99],   # 99 has no customer record
    "amount":      [250, 180, 330, 90],
})
customers = pd.DataFrame({
    "customer_id": [10, 11, 12, 13],   # 13 has no orders
    "name":        ["Alice", "Bob", "Carol", "Dave"],
})

# Inner join: only matched rows
inner = orders.merge(customers, on="customer_id", how="inner")

# Left join: keep all orders
left  = orders.merge(customers, on="customer_id", how="left")

# Anti join: orders with no customer
anti  = orders.merge(customers, on="customer_id", how="left", indicator=True)
anti  = anti[anti["_merge"] == "left_only"].drop(columns="_merge")

print("Anti-join result (orphaned orders):"); print(anti)

R
library(dplyr)

# Inner join
inner <- inner_join(orders, customers, by = "customer_id")

# Left join
left  <- left_join(orders, customers, by = "customer_id")

# Anti join — idiomatic dplyr verb
anti  <- anti_join(orders, customers, by = "customer_id")
print(anti)

Many-to-Many Join Explosions

A many-to-many join multiplies rows. If orders and products each have 10 rows that share a non-unique key, the join produces up to 100 rows. Always inspect row counts before and after any join.

PYTHON
before = len(df_a), len(df_b)
merged = df_a.merge(df_b, on="key", how="inner")
after  = len(merged)
print(f"Before: {before}, After: {after}")
assert after <= min(before), "Possible many-to-many explosion — check key uniqueness"

Reshaping: Wide to Long (Tidy Data)

Tidy data (Wickham 2014) has one observation per row and one variable per column. Wide data stores multiple observations per row, which is convenient for display but awkward for analysis.

$$\text{tidy: } (\text{id}, \text{variable}, \text{value}) \quad \longleftrightarrow \quad \text{wide: } (\text{id}, \text{var}_1, \text{var}_2, \ldots)$$

PYTHON
# Wide to long with melt
wide = pd.DataFrame({
    "subject": ["A", "B", "C"],
    "jan_revenue": [100, 200, 150],
    "feb_revenue": [110, 195, 160],
    "mar_revenue": [105, 210, 145],
})

long = wide.melt(
    id_vars="subject",
    var_name="month",
    value_name="revenue"
)
# Clean the month column
long["month"] = long["month"].str.replace("_revenue", "")
print(long.head(6))

# Long back to wide with pivot
back_to_wide = long.pivot(index="subject", columns="month", values="revenue").reset_index()
back_to_wide.columns.name = None

R
library(tidyr)
library(dplyr)

# Wide to long
long <- wide |>
  pivot_longer(
    cols      = ends_with("_revenue"),
    names_to  = "month",
    values_to = "revenue"
  ) |>
  mutate(month = stringr::str_remove(month, "_revenue"))

# Long back to wide
back_to_wide <- long |>
  pivot_wider(names_from = month, values_from = revenue)

Deduplication

Duplicates arise from repeated ETL loads, outer join artifacts, and data entry errors. They inflate counts and distort aggregations.

PYTHON
# Exact duplicates
print(f"Duplicate rows: {df.duplicated().sum()}")
df = df.drop_duplicates()

# Key-based deduplication: keep latest record per customer
df_deduped = (
    df.sort_values("updated_at", ascending=False)
    .drop_duplicates(subset=["customer_id"], keep="first")
)

R
library(dplyr)

# Exact duplicates
df |> filter(duplicated(across(everything()))) |> nrow()

# Keep the most recent record per customer
df_deduped <- df |>
  arrange(desc(updated_at)) |>
  distinct(customer_id, .keep_all = TRUE)

Concat and Stack

Vertical concatenation (stacking) combines data from multiple files or time periods. Always align schema before stacking.

PYTHON
import glob

files = glob.glob("/data/logs/2024-*.csv")
dfs = [pd.read_csv(f, dtype={"user_id": str}) for f in files]
combined = pd.concat(dfs, ignore_index=True)

# Diagnose schema mismatches
for f, d in zip(files, dfs):
    if set(d.columns) != set(dfs[0].columns):
        print(f"Schema mismatch: {f}")

Code Examples

Merge with multi-column key and suffix disambiguation

PYTHON
import pandas as pd

left = pd.DataFrame({
    "store_id": [1, 1, 2],
    "product_id": ["A", "B", "A"],
    "quantity": [10, 5, 8],
    "price": [9.99, 4.99, 9.99]
})
right = pd.DataFrame({
    "store_id": [1, 2],
    "product_id": ["A", "A"],
    "cost": [6.00, 6.50],
    "price": [9.99, 9.49]   # price may differ per store
})

merged = left.merge(
    right,
    on=["store_id", "product_id"],
    how="left",
    suffixes=("_list", "_actual")
)
print(merged)
Output
   store_id product_id  quantity  price_list  cost  price_actual
0         1          A        10        9.99   6.0          9.99
1         1          B         5        4.99   NaN           NaN
2         2          A         8        9.99   6.5          9.49

Reshaping a wide panel dataset to tidy long format

R
library(tidyr)
library(dplyr)

# Country-level GDP panel: one column per year
gdp_wide <- tibble(
  country = c("USA", "DEU", "JPN"),
  `2021`  = c(23.0, 4.26, 4.94),
  `2022`  = c(25.5, 4.08, 4.23),
  `2023`  = c(27.4, 4.45, 4.21)
)

gdp_long <- gdp_wide |>
  pivot_longer(
    cols      = -country,
    names_to  = "year",
    values_to = "gdp_trillion_usd"
  ) |>
  mutate(year = as.integer(year))

print(gdp_long)

SQL-based deduplication using ROW_NUMBER window function

ROW_NUMBER() is the canonical SQL deduplication pattern. Adjust ORDER BY to pick first/last/highest-quality record based on your domain logic.

SQL
-- Deduplicate orders: keep latest record per (customer_id, product_id)
WITH ranked AS (
    SELECT *,
           ROW_NUMBER() OVER (
               PARTITION BY customer_id, product_id
               ORDER BY created_at DESC
           ) AS rn
    FROM   orders
)
SELECT *
FROM   ranked
WHERE  rn = 1
ORDER  BY customer_id, product_id;

29.5 Data Validation: Ensuring Quality Programmatically Intermediate

Data Validation: Ensuring Quality Programmatically

Cleaning data without validating the result is like testing software without assertions. A validation layer codifies your understanding of the data contract and catches regressions when upstream sources change silently — which they always do.

What to Validate

A good validation suite checks:

  • Schema: correct column names and dtypes.
  • Nullability: which columns may/must not contain nulls.
  • Value ranges: numeric bounds, allowed categories.
  • Uniqueness: primary keys, or unique combinations of columns.
  • Referential integrity: foreign key values exist in the reference table.
  • Statistical consistency: distributions that should be stable over time (mean, standard deviation within expected bands).
  • pandera: DataFrame Validation in Python

pandera provides a declarative, class-based schema that validates a pandas DataFrame and raises an informative error on the first violation.

PYTHON
import pandas as pd
import pandera as pa
from pandera import Column, DataFrameSchema, Check

schema = DataFrameSchema(
    columns={
        "order_id":    Column(int,   nullable=False, unique=True),
        "customer_id": Column(int,   nullable=False),
        "order_date":  Column("datetime64[ns]", nullable=False),
        "revenue":     Column(float, Check.greater_than_or_equal_to(0),
                              nullable=False),
        "status":      Column(str,   Check.isin(["pending", "shipped",
                                                  "delivered", "cancelled"])),
    },
    checks=[
        # Cross-column check: ship_date must be after order_date
        Check(lambda df: (df["ship_date"] >= df["order_date"]).all(),
              error="ship_date must be >= order_date"),
    ],
    coerce=True,    # attempt type coercion before validating
)

try:
    validated_df = schema.validate(df, lazy=True)  # lazy=True collects ALL errors
except pa.errors.SchemaErrors as exc:
    print(exc.failure_cases)

The lazy=True parameter is essential in production: it collects all validation failures rather than stopping at the first, giving you a complete picture of data quality.

Pandera DataFrameModel (Class-Based API)

For larger schemas, the class-based API is more readable and maintainable.

PYTHON
import pandera as pa
from pandera.typing import Series
import pandas as pd

class OrderSchema(pa.DataFrameModel):
    order_id:    Series[int]   = pa.Field(nullable=False, unique=True)
    customer_id: Series[int]   = pa.Field(nullable=False, ge=1)
    revenue:     Series[float] = pa.Field(nullable=False, ge=0.0)
    status:      Series[str]   = pa.Field(isin=["pending", "shipped",
                                                 "delivered", "cancelled"])

    class Config:
        coerce = True

@pa.check_types
def process_orders(df: pa.typing.DataFrame[OrderSchema]) -> pd.DataFrame:
    return df.assign(revenue_tax=df["revenue"] * 0.08)

The @pa.check_types decorator validates the DataFrame automatically every time the function is called — ideal for data pipeline functions.

Validation in R with dplyr Assertions

R does not have a dedicated schema library as mature as pandera, but stopifnot/testthat assertions combined with dplyr checks cover most cases.

R
library(dplyr)

validate_orders <- function(df) {
  # Schema checks
  stopifnot(
    "Missing required columns" =
      all(c("order_id", "customer_id", "revenue", "status") %in% names(df)),
    "order_id must be unique"  = !any(duplicated(df$order_id)),
    "revenue must be non-negative" = all(df$revenue >= 0, na.rm = TRUE),
    "no NAs in order_id"      = !anyNA(df$order_id),
    "valid status values"      = all(df$status %in%
                                     c("pending","shipped","delivered","cancelled"),
                                     na.rm = TRUE)
  )
  message("Validation passed: ", nrow(df), " rows, ",
          ncol(df), " columns")
  invisible(df)
}

df |> validate_orders() |> head()

Statistical Drift Detection

For regularly refreshed data, statistical checks catch distribution shifts that syntactic checks miss.

PYTHON
import pandas as pd
import numpy as np

def check_column_stats(
    current: pd.Series,
    reference: pd.Series,
    mean_tol: float = 0.20,
    std_tol:  float = 0.30,
) -> dict:
    """Assert that mean and std of `current` are within tolerance of `reference`."""
    results = {}
    for stat, fn in [("mean", np.mean), ("std", np.std)]:
        ref_val  = fn(reference.dropna())
        curr_val = fn(current.dropna())
        pct_diff = abs(curr_val - ref_val) / (abs(ref_val) + 1e-9)
        tol      = mean_tol if stat == "mean" else std_tol
        results[stat] = {
            "reference": round(ref_val, 4),
            "current":   round(curr_val, 4),
            "pct_diff":  round(100 * pct_diff, 2),
            "passed":    pct_diff <= tol,
        }
    return results

# Example usage
reference_revenue = pd.Series(np.random.lognormal(5, 0.5, 1000))
current_revenue   = pd.Series(np.random.lognormal(5.2, 0.5, 1000))  # slight drift

for stat, info in check_column_stats(current_revenue, reference_revenue).items():
    flag = "OK" if info["passed"] else "DRIFT DETECTED"
    print(f"{stat}: ref={info['reference']}, curr={info['current']}, "
          f"diff={info['pct_diff']}% [{flag}]")

Building a Validation Report

In production pipelines, validation results should be logged and surfaced, not just raised as exceptions. A common pattern is to accumulate errors in a list and emit a structured report.

PYTHON
failures = []

def soft_check(condition: bool, message: str):
    if not condition:
        failures.append(message)

soft_check(df["order_id"].is_unique,       "order_id is not unique")
soft_check(df["revenue"].ge(0).all(),      "negative revenue values found")
soft_check(df["order_date"].notna().all(), "null order_dates found")

if failures:
    print(f"Validation failed with {len(failures)} error(s):")
    for f in failures:
        print(f"  - {f}")
else:
    print("All checks passed.")

Code Examples

Full pandera pipeline with custom checks and error reporting

PYTHON
import pandas as pd
import pandera as pa
from pandera import Column, DataFrameSchema, Check

schema = DataFrameSchema(
    {
        "age":    Column(float, [
                      Check.greater_than_or_equal_to(0),
                      Check.less_than(130),
                  ], nullable=True),
        "email":  Column(str, [
                      Check(lambda s: s.str.contains(r"@", na=False),
                            element_wise=False,
                            error="email must contain @")
                  ], nullable=False),
        "score":  Column(float, Check.in_range(0, 100), nullable=True),
    },
    coerce=True
)

df_bad = pd.DataFrame({
    "age":   [25, -5, 200, 32],
    "email": ["a@b.com", "not_an_email", "c@d.com", "e@f.com"],
    "score": [85.0, 92.0, 101.0, 78.0],
})

try:
    schema.validate(df_bad, lazy=True)
except pa.errors.SchemaErrors as err:
    print(err.failure_cases[["schema_context", "column", "check", "failure_case"]])
Output
  schema_context column                          check failure_case
0         Column    age  greater_than_or_equal_to(0)         -5.0
1         Column    age          less_than(130)            200.0
2         Column  email              email must contain @  not_an_email
3         Column  score              in_range(0, 100)         101.0

Referential integrity check in R

R
library(dplyr)

orders    <- tibble(order_id = 1:5, customer_id = c(10, 11, 12, 99, 10))
customers <- tibble(customer_id = c(10, 11, 12), name = c("Alice","Bob","Carol"))

# Find orders with no matching customer
orphaned <- anti_join(orders, customers, by = "customer_id")

if (nrow(orphaned) > 0) {
  warning(sprintf(
    "Referential integrity violation: %d order(s) have no matching customer.",
    nrow(orphaned)
  ))
  print(orphaned)
} else {
  message("Referential integrity OK.")
}

Great Expectations-style expectation suite (lightweight version)

PYTHON
import pandas as pd
import numpy as np
from dataclasses import dataclass, field
from typing import List

@dataclass
class ExpectationResult:
    expectation: str
    passed: bool
    details: str = ""

def run_expectations(df: pd.DataFrame) -> List[ExpectationResult]:
    results = []

    def expect(name, cond, detail=""):
        results.append(ExpectationResult(name, bool(cond), detail))

    expect("row_count > 0", len(df) > 0)
    expect("order_id unique", df["order_id"].is_unique)
    expect("revenue non-negative", (df["revenue"] >= 0).all(),
           f"min={df['revenue'].min():.2f}")
    expect("no null order_date", df["order_date"].notna().all(),
           f"null_count={df['order_date'].isna().sum()}")
    return results

np.random.seed(0)
df_test = pd.DataFrame({
    "order_id":   range(100),
    "revenue":    np.random.lognormal(4, 1, 100),
    "order_date": pd.date_range("2024-01-01", periods=100, freq="D")
})

for r in run_expectations(df_test):
    status = "PASS" if r.passed else "FAIL"
    print(f"[{status}] {r.expectation} {r.details}")