02 · Python for Data Engineering¶
Data engineering code is mostly the same handful of operations repeated on
different data: load rows, filter, group, join, reshape, write back out. The
pandas library does all of this in memory, and while production pipelines
eventually outgrow it (Level 3 covers Spark for that), it's the right tool to
learn the operations with, because you can see every intermediate result.
What actually ran
Every code block and output on this page was executed locally with
pandas — no database, no files on disk yet (that starts in lesson 4).
Loading data¶
import pandas as pd
import io
csv_text = """order_id,customer,region,amount,status
1,Alice,US,120.50,paid
2,Bob,EU,89.00,paid
3,Alice,US,45.25,refunded
4,Carla,APAC,300.00,paid
5,Bob,EU,15.00,paid
6,Dinesh,APAC,,paid
7,Alice,US,60.00,pending
"""
df = pd.read_csv(io.StringIO(csv_text))
print(df)
print()
print(df.dtypes)
order_id customer region amount status
0 1 Alice US 120.50 paid
1 2 Bob EU 89.00 paid
2 3 Alice US 45.25 refunded
3 4 Carla APAC 300.00 paid
4 5 Bob EU 15.00 paid
5 6 Dinesh APAC NaN paid
6 7 Alice US 60.00 pending
order_id int64
customer str
region str
amount float64
status str
dtype: object
(In real code, pd.read_csv("orders.csv") reads straight from disk — io.StringIO
is used here only so the whole example is copy-pasteable.) Notice row 5:
Dinesh's amount field was empty in the source CSV, and pandas turned it into
NaN (Not a Number) automatically. That silent conversion is convenient and
dangerous in equal measure — more on that below.
Filtering and selecting¶
print("Rows with missing amount:")
print(df[df["amount"].isna()])
print()
paid = df[df["status"] == "paid"].copy()
print("Only 'paid' orders:")
print(paid)
Rows with missing amount:
order_id customer region amount status
5 6 Dinesh APAC NaN paid
Only 'paid' orders:
order_id customer region amount status
0 1 Alice US 120.5 paid
1 2 Bob EU 89.0 paid
3 4 Carla APAC 300.0 paid
4 5 Bob EU 15.0 paid
5 6 Dinesh APAC NaN paid
df["amount"].isna() returns a boolean Series; indexing df[...] with it
keeps only the True rows. This "boolean mask" pattern is the single most
common operation in data-engineering Python — memorize it before anything
else. The .copy() after filtering matters: without it, later edits to paid
can raise a SettingWithCopyWarning because pandas isn't sure if you meant to
edit the original df or a view of it.
Grouping and aggregating¶
print("Revenue by region (paid only):")
print(paid.groupby("region")["amount"].sum())
print()
print("Count + total by customer, across ALL statuses:")
summary = df.groupby("customer").agg(
n_orders=("order_id", "count"),
total_amount=("amount", "sum"),
).reset_index()
print(summary)
Revenue by region (paid only):
region
APAC 300.0
EU 104.0
US 120.5
Name: amount, dtype: float64
Count + total by customer, across ALL statuses:
customer n_orders total_amount
0 Alice 3 225.75
1 Bob 2 104.00
2 Carla 1 300.00
3 Dinesh 1 0.00
Look very closely at the two outputs. APAC revenue shows 300.0 — but that's
wrong. Carla's order (300.00) is real, but Dinesh's paid order in APAC has a
missing amount that silently became 0.00 in .sum(), not an error, not a
warning. .sum() treats NaN as "contributes nothing" by default. If Dinesh's
real order was actually $250, APAC revenue is understated by $250 and nothing
in this output tells you that happened. This is the single most dangerous
default in pandas for a data engineer: missing data disappears silently
instead of failing loudly. Lesson 09 builds the validation habits that catch
this before it reaches a report.
Reshaping and combining¶
customers = pd.DataFrame({
"customer": ["Alice", "Bob", "Carla", "Dinesh"],
"signup_region": ["US", "EU", "APAC", "APAC"],
"tier": ["gold", "silver", "gold", "bronze"],
})
merged = df.merge(customers, on="customer", how="left")
print(merged[["order_id", "customer", "region", "signup_region", "tier"]])
order_id customer region signup_region tier
0 1 Alice US US gold
1 2 Bob EU EU silver
2 3 Alice US US gold
3 4 Carla APAC APAC gold
4 5 Bob EU EU silver
5 6 Dinesh APAC APAC bronze
6 7 Alice US US gold
merge is pandas's JOIN. how="left" keeps every row from df even if no
match exists in customers (those rows would get NaN in the new columns) —
the same semantics as SQL's LEFT JOIN, which lesson 3 covers directly. Data
engineers reach for left joins by default because losing rows silently
during a join (what an inner join does to unmatched rows) is one of the most
common causes of "the numbers don't add up" bugs in production.
Traps¶
NaNpropagates silently through arithmetic and aggregation..sum()and.mean()skipNaNby default (skipna=True); this is usually what you want for.mean(), and usually a bug you don't notice for.sum()on financial data. Checkdf["col"].isna().sum()before trusting any total.innerjoins silently drop unmatched rows.df.merge(other, how="inner")(the default!) removes any row without a match on both sides. If a customer is missing from yourcustomerstable, their entire order history vanishes from the merged result with no error.- Mutating a filtered slice without
.copy().paid["amount"] *= 1.1on an uncopied filter can silently fail to update, or trigger a confusing warning. Always.copy()a DataFrame you intend to modify. - Column dtype surprises. A column that's
int64in one CSV and has one blank cell in another CSV becomesfloat64(becauseNaNcan't live in an integer column) — anorder_idof4can literally become4.0. This bites people doing string comparisons or joins on IDs across files.
Cheat sheet¶
| Operation | Code |
|---|---|
| Load CSV | pd.read_csv(path) |
| Filter rows | df[df["col"] == value] |
| Check for missing | df["col"].isna() |
| Count missing | df["col"].isna().sum() |
| Group + aggregate | df.groupby("col").agg(...) |
| Join (SQL-style) | df.merge(other, on="key", how="left") |
| Safe copy before edit | df[mask].copy() |
| Fill missing | df["col"].fillna(0) |
How It Actually Works¶
pandas (and Polars, DuckDB's DataFrame API) get their speed from the same trick: columns are stored as contiguous, homogeneously-typed arrays (NumPy ndarrays under pandas, Arrow arrays under Polars), so an operation like df['amount'] * 1.1 runs as a single vectorized C loop over contiguous memory instead of a Python-level loop with per-element type dispatch. That is why .apply(lambda row: ...) is slow relative to a column expression: .apply re-enters the Python interpreter once per row, paying object-creation and bytecode-dispatch overhead every single time, while a vectorized op pays that cost once for the whole column.
groupby internally sorts or hashes the grouping key to bucket row indices, then applies the aggregation function to each bucket's slice of the underlying array — this is why groupby on a low-cardinality column (few distinct values) is cheap and on a high-cardinality column (like a UUID) approaches the cost of processing every row individually. merge/join operations build a hash table on the smaller side's join key and probe it with the larger side, which is exactly the hash-join algorithm a SQL engine uses — the difference is pandas does it in-process, in memory, bounded by RAM, with no query optimizer choosing the join strategy for you.
Exercise¶
Using the df from this lesson, write code that computes revenue by region
and reports, alongside each region's total, how many rows in that region
had a missing amount that was excluded from the sum. (Hint: you need two
groupby calls — one on the filled/summed data, one counting isna() per
group — then join them together.) The goal is a report that can never again
silently hide a Dinesh-shaped hole in your numbers.