description: "Data Engineering — Data engineering is about moving and reshaping data reliably: extracting it from sources, transforming it into a usable shape, and…"---
06 · Data Engineering¶
Data engineering is about moving and reshaping data reliably: extracting it
from sources, transforming it into a usable shape, and loading it somewhere
useful (a database, a file, a dashboard). pandas and numpy are the core
tools for this in the Python ecosystem.
Installing¶
numpy — fast numerical arrays¶
numpy arrays are fixed-type and stored contiguously in memory, which makes
vectorized operations on them far faster than equivalent pure-Python loops.
import numpy as np
prices = np.array([19.99, 5.50, 3.25, 42.00])
quantities = np.array([2, 10, 5, 1])
totals = prices * quantities # element-wise multiplication, no loop needed
print(totals) # [ 39.98 55. 16.25 42. ]
print(totals.sum()) # 153.23
print(totals.mean(), totals.std()) # average and standard deviation
matrix = np.array([[1, 2], [3, 4]])
print(matrix.T) # transpose
print(matrix @ matrix) # matrix multiplication
pandas — the DataFrame¶
A DataFrame is a labeled, 2-dimensional table — think "a spreadsheet you can
script."
import pandas as pd
data = {
"product": ["Book", "Pen", "Notebook", "Book"],
"price": [12.50, 1.50, 5.00, 12.50],
"quantity": [3, 20, 8, 1],
}
df = pd.DataFrame(data)
print(df)
# product price quantity
# 0 Book 12.50 3
# 1 Pen 1.50 20
# 2 Notebook 5.00 8
# 3 Book 12.50 1
print(df.dtypes)
print(df.describe()) # count, mean, std, min, max, quartiles for numeric columns
Reading and writing common formats¶
df = pd.read_csv("sales.csv")
df.to_csv("output.csv", index=False)
df = pd.read_json("data.json")
df.to_json("output.json", orient="records", indent=2)
df = pd.read_excel("report.xlsx") # requires: pip install openpyxl
Selecting and filtering¶
print(df["price"]) # a single column (a Series)
print(df[["product", "price"]]) # multiple columns
print(df[df["quantity"] > 5]) # filter rows by a boolean condition
print(df.loc[df["product"] == "Book"]) # filter by label-based indexing
print(df.iloc[0]) # the first row by position
Adding and transforming columns¶
df["total"] = df["price"] * df["quantity"]
df["price_category"] = df["price"].apply(
lambda p: "expensive" if p > 10 else "cheap"
)
print(df)
Grouping and aggregating¶
grouped = df.groupby("product")["total"].sum()
print(grouped)
# product
# Book 50.0
# Notebook 40.0
# Pen 30.0
summary = df.groupby("product").agg(
total_revenue=("total", "sum"),
avg_price=("price", "mean"),
orders=("product", "count"),
)
print(summary)
Handling missing data¶
df_with_gaps = pd.DataFrame({"a": [1, None, 3], "b": [None, 2, 3]})
print(df_with_gaps.isna()) # boolean mask of missing values
print(df_with_gaps.dropna()) # drop any row containing a missing value
print(df_with_gaps.fillna(0)) # replace missing values with 0
print(df_with_gaps["a"].fillna(df_with_gaps["a"].mean())) # fill with the column mean
Merging/joining data¶
orders = pd.DataFrame({"order_id": [1, 2, 3], "customer_id": [1, 2, 1]})
customers = pd.DataFrame({"customer_id": [1, 2], "name": ["Ada", "Grace"]})
merged = orders.merge(customers, on="customer_id", how="left")
print(merged)
# order_id customer_id name
# 0 1 1 Ada
# 1 2 2 Grace
# 2 3 1 Ada
Designing a small ETL pipeline¶
A basic Extract-Transform-Load pipeline, structured as small, testable functions.
import pandas as pd
def extract(csv_path: str) -> pd.DataFrame:
"""Extract: read raw sales data from a CSV file."""
return pd.read_csv(csv_path)
def transform(df: pd.DataFrame) -> pd.DataFrame:
"""Transform: clean and enrich the raw data."""
df = df.dropna(subset=["product", "price", "quantity"])
df = df[df["quantity"] > 0] # drop invalid rows
df["total"] = df["price"] * df["quantity"]
return df.groupby("product", as_index=False).agg(
total_revenue=("total", "sum"),
units_sold=("quantity", "sum"),
)
def load(df: pd.DataFrame, output_path: str) -> None:
"""Load: write the final, aggregated result somewhere durable."""
df.to_csv(output_path, index=False)
def run_pipeline(input_path: str, output_path: str) -> None:
raw = extract(input_path)
clean = transform(raw)
load(clean, output_path)
print(f"processed {len(raw)} rows into {len(clean)} product summaries")
if __name__ == "__main__":
run_pipeline("sales.csv", "sales_summary.csv")
Splitting extract/transform/load into separate functions makes each
stage independently testable — transform in particular can be tested with
an in-memory DataFrame, no file I/O required.
Cheat sheet¶
| Task | Code |
|---|---|
| Read CSV/JSON/Excel | pd.read_csv/json/excel(path) |
| Filter rows | df[df["col"] > value] |
| New column from existing ones | df["new"] = df["a"] * df["b"] |
| Group and summarize | df.groupby("col").agg(...) |
| Join two tables | df1.merge(df2, on="key", how="left") |
| Handle missing data | df.dropna() / df.fillna(value) |
How It Actually Works¶
A numpy array's speed comes from a fundamentally different memory layout than a
Python list: a list is an array of pointers to separately heap-allocated
objects scattered across memory, while a numpy array is one contiguous block of
raw, fixed-width machine values (all float64, all int32, etc., decided once at
creation) with no per-element Python object overhead at all. prices * quantities
doesn't loop in Python bytecode calling __mul__ per element — it dispatches once
into a compiled C (or SIMD-vectorized) loop that walks both underlying memory
buffers directly, multiplying raw values using the CPU's native arithmetic
instructions, often processing several elements per CPU cycle via vector
instructions. This is precisely why "vectorized" numpy code that looks like Python
runs at compiled-language speed: the actual arithmetic never touches the Python
interpreter's per-instruction dispatch overhead at all, only the call into numpy
does.
A pandas DataFrame is built on top of exactly this: each column is stored as a
numpy array (or an Arrow-backed block, in newer pandas), and the DataFrame itself
is essentially a dict-like collection of these column arrays sharing a common row
index. This is why df["price"] is cheap (it just returns a view referencing the
existing underlying array, wrapped as a Series) while adding a differently-typed
column can trigger a new array allocation. df[df["quantity"] > 5] works in two
steps mechanically: df["quantity"] > 5 runs the same vectorized comparison as
numpy to produce a boolean array the same length as the DataFrame, and the outer
df[...] then uses that boolean array as a mask — a single vectorized selection
pass over each column's underlying array picking out only the True positions —
rather than a Python-level loop checking each row one at a time.
df.groupby("product") doesn't recompute per-group results directly — it first
performs a hash-based partition: it computes a hash of the grouping column's
values (the same hashing mechanism behind Python's own dict/set) to bucket row
positions by their product value into an internal grouping object, without
copying the underlying data yet. Only when you call .sum() or .agg(...) does it
apply the requested reduction separately over the row-index buckets already
computed, materializing a new, smaller result — which is why chaining multiple
aggregations off one groupby(...) call (as .agg(total_revenue=..., avg_price=...,
orders=...) does) is more efficient than calling groupby fresh for each one: the
expensive bucketing step happens exactly once and is reused for every aggregate
computed from it.
orders.merge(customers, on="customer_id", how="left") implements a relational
join the same way a SQL database's join operator does — for reasonably sized data,
pandas builds a hash table keyed by the join column from the smaller side (customers), then does a single pass over orders looking up each customer_id in
that hash table (an O(1) average lookup per row, the same hash-table mechanism as
Python dict) rather than comparing every row of orders against every row of
customers (which would be the much slower, naive O(n×m) nested-loop join a
hand-written merge might accidentally implement).
Exercise¶
Build the ETL pipeline above into a real script with a tests/test_transform.py
file that constructs a small in-memory DataFrame with a missing value, a
zero-quantity row, and two rows for the same product — and asserts the
transformed output drops the bad rows and correctly sums the duplicate
product's revenue and units.