04 · Transformation Basics (Cleaning, Type Casting, Deduplication)¶
Transform is where raw, string-typed, messy data becomes something you'd
trust in a report. This lesson covers the three transformations nearly every
pipeline needs — cleaning, type casting, and deduplication — using pandas,
the tool most Python pipelines reach for once data volume grows past a
handful of rows.
What actually ran
This pipeline was reasoned through step by step against the real
pandas API (pandas 2.x behavior) but not executed in a live
interpreter for this lesson — the DataFrame operations, dtypes, and
output shapes shown match documented pandas behavior precisely.
The messy input¶
import pandas as pd
import io
raw_csv = """order_id,customer,amount,order_date,status
1001, alice smith ,120.50,2026-08-01,Paid
1002,BOB JONES,89,2026-08-01,paid
1003,alice smith,45.25,2026-08-02,REFUNDED
1004,carla diaz,,2026-08-02,paid
1005,Alice Smith,120.50,2026-08-01,Paid
"""
df = pd.read_csv(io.StringIO(raw_csv))
print(df.dtypes)
print(df)
order_id int64
customer object
amount float64
order_date object
status object
dtype: object
order_id customer amount order_date status
0 1001 alice smith 120.50 2026-08-01 Paid
1 1002 BOB JONES 89.00 2026-08-01 paid
2 1003 alice smith 45.25 2026-08-02 REFUNDED
3 1004 carla diaz NaN 2026-08-02 paid
4 1005 Alice Smith 120.50 2026-08-01 Paid
pandas.read_csv already infers amount as float64 and the missing value
in row 3 as NaN — but customer and status are still raw, inconsistent
strings, and row 4 (id 1004) is a near-duplicate of row 0 with different
casing and whitespace.
Cleaning: whitespace and casing¶
df["customer"] = df["customer"].str.strip().str.title()
df["status"] = df["status"].str.strip().str.lower()
print(df[["customer", "status"]])
customer status
0 Alice Smith paid
1 Bob Jones paid
2 Alice Smith refunded
3 Carla Diaz paid
4 Alice Smith paid
.str.strip() removes leading/trailing whitespace and .str.title() /
.str.lower() normalize casing — both are vectorized pandas string
operations, meaning they run across the whole column at once rather than in
a Python-level loop, which matters once you're cleaning millions of rows.
Type casting: handling the values that don't convert cleanly¶
# amount is already float64, but real-world CSVs often quote numbers or add
# currency symbols — this shows the defensive pattern for that case:
messy_amount = pd.Series(["120.50", "$89.00", "not_a_number", "45.25"])
cast_amount = pd.to_numeric(
messy_amount.str.replace("$", "", regex=False),
errors="coerce",
)
print(cast_amount)
errors="coerce" turns anything that can't be parsed into NaN instead of
raising an exception — which is exactly what you want in a batch pipeline:
one bad value shouldn't crash the entire run. It does mean you now have a
missing value where there wasn't an obvious one before, which brings up the
next question: what do you do with missing data?
# Row 1004 has a missing amount (NaN) — decide explicitly, don't let it slide
missing_amount = df["amount"].isna().sum()
print(f"Rows with missing amount: {missing_amount}")
df_with_flag = df.copy()
df_with_flag["amount_was_missing"] = df_with_flag["amount"].isna()
df_with_flag["amount"] = df_with_flag["amount"].fillna(0.0)
print(df_with_flag[["order_id", "amount", "amount_was_missing"]])
Rows with missing amount: 1
order_id amount amount_was_missing
0 1001 120.50 False
1 1002 89.00 False
2 1003 45.25 False
3 1004 0.00 True
4 1005 120.50 False
Filling with 0.0 is a business decision, not a neutral default — and
that's exactly why the amount_was_missing flag matters: it lets every
downstream consumer distinguish "this order was genuinely free" from "we
don't actually know the amount." Silently filling without flagging erases
that distinction forever.
Deduplication¶
Rows 0 and 4 are the same order under different casing and whitespace — a
naive .drop_duplicates() on the raw columns would miss it entirely,
because it compares values before cleaning, not after.
# Wrong order: dedupe before cleaning misses the case-different duplicate
before_clean = pd.read_csv(io.StringIO(raw_csv))
print("Duplicates found before cleaning:", before_clean.duplicated(subset=["customer", "order_date", "amount"]).sum())
# Right order: clean first, then dedupe on the cleaned columns
after_clean = df_with_flag.drop_duplicates(subset=["customer", "order_date", "amount"], keep="first")
print(f"Rows before dedup: {len(df_with_flag)}, after dedup: {len(after_clean)}")
print(after_clean)
Duplicates found before cleaning: 0
Rows before dedup: 5, after dedup: 4
order_id customer amount order_date status amount_was_missing
0 1001 Alice Smith 120.50 2026-08-01 paid False
1 1002 Bob Jones 89.00 2026-08-01 paid False
2 1003 Alice Smith 45.25 2026-08-02 refunded False
3 1004 Carla Diaz 0.00 2026-08-02 paid True
Order matters: cleaning before deduplication caught the duplicate;
deduplicating on the raw, uncleaned columns found zero duplicates, because
" alice smith " and "Alice Smith" are different strings. This is one of
the most common real-world data quality bugs — always normalize the columns
you're deduplicating on before comparing them.
Traps¶
- Deduplicating on raw columns. As shown above — always clean casing and whitespace on your dedup key columns first.
fillna()without a flag. Filling missing values erases the fact that they were ever missing, unless you record it in a companion column.- Using
errors="raise"(the default) in a batch job. One malformed value shouldn't crash a pipeline processing 100,000 good rows — useerrors="coerce"and log/count what got turned intoNaN. - Silent precision loss.
floatamounts (like money) can accumulate rounding errors after repeated arithmetic — for real financial pipelines, considerDecimalor integer cents instead offloat64.
Cheat sheet¶
| Task | pandas idiom |
|---|---|
| Strip whitespace | .str.strip() |
| Normalize casing | .str.title() / .str.lower() / .str.upper() |
| Safe numeric cast | pd.to_numeric(col, errors="coerce") |
| Fill missing + flag it | col.isna() then col.fillna(default) |
| Dedup on cleaned keys | .drop_duplicates(subset=[...], keep="first") |
How It Actually Works¶
A transformation step is, mechanically, a sequence of operations applied to an in-memory (or spilled-to-disk) columnar or row-oriented data structure, and which representation the engine uses changes what operations are cheap.
Row-oriented processing (a Python loop over dict rows, or a traditional RDBMS row store) touches an entire record for every operation — even a transform that only rewrites one column still walks the whole row in memory, because row-major layout stores all of a record's fields contiguously. Column-oriented processing (pandas, Polars, Spark DataFrames, Arrow) stores each column as a contiguous typed array, so a transform on one column touches only that array — no other columns are read into cache at all. This is the mechanical reason column-oriented tools vectorize transforms into tight SIMD loops over a single data type instead of dispatching per-row per-field: the CPU can pipeline identical operations over contiguous memory far faster than it can chase pointers through mixed-type row structs.
Null handling is another place where the underlying mechanism leaks through: SQL's
three-valued logic (TRUE/FALSE/UNKNOWN) means NULL = NULL evaluates to UNKNOWN, not
TRUE, so a transform's join or filter condition silently drops rows with nulls in the
join key unless you explicitly handle it with IS NULL or COALESCE. Type coercion during
transformation is also not free: casting a string column to an integer forces the engine to
parse every value's byte representation and validate it, and a single unparseable value
either throws (fails the whole batch) or silently becomes null (corrupts data silently)
depending on the engine's cast semantics — which is why production transforms almost always
wrap type coercion in an explicit try/validate step rather than relying on an implicit cast.
Exercise¶
Using the raw_csv above, add a new column amount_flag that is
"missing" when the original amount was NaN, "suspicious" when the
cleaned amount is greater than 1000 (a plausible outlier for this
dataset), and "ok" otherwise. Then re-run deduplication and confirm the
flag survives correctly on the row that remains after row 1001/1005 are
merged into one.