Focus only on what you'll actually use in pipelines: scripting fundamentals, file/API handling, and the three libraries that connect Python to data (pandas, SQLAlchemy, psycopg2).
def clean_price(raw_price: str) -> float:
"""Convert '$1,299.00' -> 1299.0"""
return float(raw_price.replace('$', '').replace(',', ''))
def load_orders(*, source: str, batch_size: int = 500):
# keyword-only args (after *) make call sites self-documenting
...
-> float, : str) aren't enforced at runtime but massively help readability and IDE support in pipeline code.*args, **kwargs, default values, and keyword-only args (*,) — know all four; ETL functions often take many optional parameters.yield) matter for DE specifically — process large files/row batches without loading everything into memory.Worked example — the difference a generator makes when reading a large file:
# regular function: builds the entire list in memory before returning anything
def read_all_rows(path):
rows = []
with open(path) as f:
for line in f:
rows.append(line.strip().split(","))
return rows # a 5GB file means a 5GB+ list sitting in RAM
# generator: yields one row at a time, nothing is held in memory beyond the current row
def read_rows(path):
with open(path) as f:
for line in f:
yield line.strip().split(",")
# both are consumed the same way, but only the generator scales to arbitrarily large files
for row in read_rows("huge_orders.csv"):
process(row)
Calling read_rows(path) doesn't run the function's code at all — it just hands back a generator object. The code only actually runs, one yield at a time, when something loops over it (a for loop, next(), or a list comprehension). This "wait until it's actually needed" behavior is the same core idea behind Spark's lazy DataFrames (Week 9-10).
class DataSource:
def __init__(self, name: str, base_url: str):
self.name = name
self.base_url = base_url
def fetch(self):
raise NotImplementedError
class OrdersAPI(DataSource):
def fetch(self):
# override the parent method
return requests.get(f"{self.base_url}/orders").json()
__init__, inheritance, and dataclasses.@dataclass is the pragmatic way to model rows/records without boilerplate.from dataclasses import dataclass
@dataclass
class Order:
order_id: int
customer_id: int
total_amount: float
Worked example — inheritance in a real pipeline: two data sources that share a retry/logging pattern but differ in how they actually fetch data.
class DataSource:
def __init__(self, name: str, base_url: str):
self.name = name
self.base_url = base_url
def fetch(self):
raise NotImplementedError # forces every subclass to implement this
def run(self):
# shared behavior every subclass gets for free
print(f"[{self.name}] starting fetch")
data = self.fetch()
print(f"[{self.name}] got {len(data)} records")
return data
class OrdersAPI(DataSource):
def fetch(self):
return requests.get(f"{self.base_url}/orders").json()
class ProductsCSV(DataSource):
def fetch(self):
import csv
with open(self.base_url) as f:
return list(csv.DictReader(f))
for source in [OrdersAPI("orders", "https://api.example.com"), ProductsCSV("products", "products.csv")]:
source.run()
Notice run() is written once, on the parent class, and never rewritten — both subclasses share that exact same logging/orchestration behavior for free. Each subclass only has to write the one method (fetch) that's actually different for them. This is the shape most "Extractor" classes take in a real pipeline: a base class holds the shared behavior, and each subclass just fills in its own specific detail.
A quick note on @dataclass: it writes __init__ and a couple of other useful methods for you automatically, based on the fields you listed. That's why Order(1, 7, 59.99) == Order(1, 7, 59.99) comes out True, and printing an instance actually shows its field values — a plain class won't do either of those unless you write the code by hand.
import time
def fetch_with_retry(url, max_retries=3):
for attempt in range(1, max_retries + 1):
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
if attempt == max_retries:
raise
time.sleep(2 ** attempt) # exponential backoff
except: — swallowing errors silently is how broken pipelines go unnoticed.try / except / else / finally — finally is where you close connections/files regardless of success.Custom exceptions — plain Exception tells a caller nothing about what actually went wrong; a small hierarchy of specific exception types lets calling code react differently to different failure modes:
class PipelineError(Exception):
"""Base class for every error this pipeline can raise."""
class ValidationError(PipelineError):
"""Raised when incoming data fails a data-quality check."""
class SourceUnavailableError(PipelineError):
"""Raised when the upstream API/source can't be reached at all."""
def validate(df):
if df["total_amount"].lt(0).any():
raise ValidationError("Found negative order amounts")
return df
try:
clean = validate(raw_df)
except ValidationError as e:
logger.error("Data quality check failed: %s", e)
send_alert(str(e)) # bad data — alert a human, don't crash silently
except SourceUnavailableError:
logger.warning("Source down, will retry next scheduled run")
# transient — Airflow's retry logic (Week 7-8) will handle this automatically
This is the same thinking Airflow uses to decide whether to retry a failed task. A SourceUnavailableError is temporary and worth retrying. A ValidationError means the data itself is bad — retrying won't fix that, it'll just fail the exact same way again. That one should alert a human instead.
with open("data/raw_orders.txt", "r") as f:
for line in f:
process(line.strip())
# always use context managers ("with") so files close automatically,
# even if an exception is raised mid-read
with open(...) — never manually call .close() and risk leaking file handles..read()ing the whole file into memory.pathlib.Path is the modern way to handle file paths over raw string concatenation.from pathlib import Path
raw_dir = Path("data") / "raw" / "orders" # joins paths with /, works on any OS
raw_dir.mkdir(parents=True, exist_ok=True) # create the folder tree if missing
for file in raw_dir.glob("*.csv"): # iterate matching files
print(file.name, file.stat().st_size) # filename and size in bytes
# reading a huge file in fixed-size chunks instead of line-by-line (e.g. for binary data)
with open("large_export.bin", "rb") as f:
while chunk := f.read(1024 * 1024): # 1 MB at a time; walrus operator assigns + checks in one line
process_chunk(chunk)
Path lets you join paths with /, and gives you the filename, extension, and parent folder without any manual string slicing. It also works the same way on macOS, Linux, and Windows — code that hardcodes / or \ instead tends to break the moment it runs on a different operating system. The chunked-read pattern (while chunk := f.read(size)) is the general fix for any file too big to load all at once that doesn't have convenient line breaks.
import json, csv
with open("orders.json") as f:
orders = json.load(f)
with open("orders.csv", newline="") as f:
reader = csv.DictReader(f)
rows = [row for row in reader]
with open("orders_clean.csv", "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["order_id", "total"])
writer.writeheader()
writer.writerows(rows)
json.load/json.dump for files, json.loads/json.dumps for strings — mixing these up is a common bug.csv.DictReader/DictWriter give you column-name access instead of brittle index-based access.pd.read_csv/read_json) instead of raw csv/json once data needs transformation.Worked example — real API responses are rarely flat. A nested JSON payload like this is extremely common:
order = {
"order_id": 1042,
"customer": {"id": 7, "name": "Asha Rao"},
"items": [
{"sku": "MOUSE-1", "qty": 2, "price": 25.0},
{"sku": "DESK-1", "qty": 1, "price": 350.0}
]
}
# flatten one level of nesting manually
flat = {
"order_id": order["order_id"],
"customer_id": order["customer"]["id"],
"customer_name": order["customer"]["name"],
"item_count": len(order["items"]),
"order_total": sum(i["qty"] * i["price"] for i in order["items"]),
}
# or let pandas do it for a whole batch of these at once
import pandas as pd
df = pd.json_normalize(orders_list, record_path="items", meta=["order_id", ["customer", "name"]])
json_normalize is built exactly for this. record_path tells it which nested list to turn into rows (here, items — one row per line item). meta tells it which parent-level fields to copy onto every one of those rows. This is probably the single most useful pandas function for turning messy, nested API responses into a flat table you can load straight into Postgres.
requests libraryimport requests
response = requests.get(
"https://api.example.com/orders",
headers={"Authorization": f"Bearer {API_TOKEN}"},
params={"page": 1, "page_size": 100},
timeout=10,
)
response.raise_for_status()
data = response.json()
# pagination pattern
all_orders = []
page = 1
while True:
resp = requests.get(url, params={"page": page}, timeout=10).json()
if not resp["results"]:
break
all_orders.extend(resp["results"])
page += 1
timeout — an API call with no timeout can hang a pipeline forever.raise_for_status() (or check status_code) — don't assume 200.json= vs data= in POST requests).Auth patterns you'll actually see — most APIs use one of three schemes, and mixing them up is a common source of 401 errors:
# 1. API key in a header (most common for simple third-party APIs)
requests.get(url, headers={"X-API-Key": API_KEY})
# 2. Bearer token (OAuth2 access token, JWT)
requests.get(url, headers={"Authorization": f"Bearer {access_token}"})
# 3. Basic auth (username/password, base64-encoded automatically by requests)
requests.get(url, auth=(username, password))
# a session reuses the same auth + connection across many calls — faster and less code
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {access_token}"})
resp1 = session.get(f"{base_url}/orders")
resp2 = session.get(f"{base_url}/customers") # auth header already attached
Rate limiting is the other API reality worth planning for up front: most APIs return 429 Too Many Requests once you exceed their limit, often with a Retry-After header telling you exactly how long to wait.
import time
def get_with_rate_limit_handling(url, **kwargs):
resp = requests.get(url, timeout=10, **kwargs)
if resp.status_code == 429:
wait = int(resp.headers.get("Retry-After", 5))
time.sleep(wait)
return get_with_rate_limit_handling(url, **kwargs) # retry once after waiting
resp.raise_for_status()
return resp
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("etl.orders")
logger.info("Starting extraction, batch_size=%d", 500)
logger.warning("Retrying request after timeout")
logger.error("Failed to load batch", exc_info=True)
print() in pipeline code — logging gives you levels, timestamps, and the ability to route output (file, stdout, monitoring system).exc_info=True inside an except block logs the full traceback — essential for debugging failed pipeline runs.Structured (JSON) logging — plain text logs are fine to read by eye, but once logs flow into CloudWatch (Week 5) or another log aggregator, structured JSON lines are far easier to search and alert on:
import logging, json
class JsonFormatter(logging.Formatter):
def format(self, record):
return json.dumps({
"timestamp": self.formatTime(record),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
})
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger = logging.getLogger("etl.orders")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.info("Loaded batch")
# -> {"timestamp": "2026-08-18 10:03:12", "level": "INFO", "logger": "etl.orders", "message": "Loaded batch"}
Each log line is now its own small JSON object. A log tool can filter on level == "ERROR" or search logger == "etl.orders" directly, instead of trying to pattern-match free-form text. This is the format most real pipelines use once they're running somewhere other than your own laptop.
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install pandas sqlalchemy psycopg2-binary requests
pip freeze > requirements.txt
# recreate elsewhere:
pip install -r requirements.txt
requirements.txt (or pyproject.toml with a tool like Poetry/uv) makes the environment reproducible — critical once you're building Docker images in Week 6.Secrets don't belong in code — pair a virtual environment with a .env file (never committed to Git) and python-dotenv to keep credentials out of your source entirely:
# .env (add this filename to .gitignore!)
DB_PASSWORD=supersecret
API_TOKEN=abc123
# pipeline.py
from dotenv import load_dotenv
import os
load_dotenv() # reads .env into the process environment
db_password = os.environ["DB_PASSWORD"]
api_token = os.environ["API_TOKEN"]
Real production systems work the same way — they just swap the .env file for something like AWS Secrets Manager, or environment variables set by Airflow/Docker. The pipeline code itself (os.environ[...]) never changes between your laptop and production — only where those values actually come from changes.
import pandas as pd
df = pd.read_json("orders.json")
df["order_date"] = pd.to_datetime(df["order_date"])
df = df.dropna(subset=["customer_id"])
df["total_amount"] = df["total_amount"].astype(float)
summary = (
df.groupby("customer_id")
.agg(order_count=("order_id", "count"), revenue=("total_amount", "sum"))
.reset_index()
)
df.to_csv("orders_clean.csv", index=False)
read_csv/read_json/read_sql, dropna/fillna, astype, groupby, merge.merge() is pandas' JOIN — know how="inner"/"left"/"outer" just like SQL.Worked example — merge (pandas' JOIN):
orders = pd.DataFrame({
"order_id": [1, 2, 3],
"customer_id": [1, 1, 2],
"total_amount": [59.99, 12.50, 350.00],
})
customers = pd.DataFrame({
"customer_id": [1, 2, 3],
"customer_name": ["Asha", "Ravi", "Meera"],
})
merged = orders.merge(customers, on="customer_id", how="left")
# order_id customer_id total_amount customer_name
# 0 1 1 59.99 Asha
# 1 2 1 12.50 Asha
# 2 3 2 350.00 Ravi
# customer 3 (Meera) never appears — how="left" keeps every row from `orders`, the left frame,
# just like a SQL LEFT JOIN keeps every row from the left table
Vectorization, concretely — this is the single biggest pandas performance mistake beginners make:
# slow: Python-level loop, re-evaluates row by row (~1000x slower on large data)
for i in range(len(df)):
df.loc[i, "total_with_tax"] = df.loc[i, "total_amount"] * 1.08
# fast: vectorized — pandas applies the operation to the whole column at once in C
df["total_with_tax"] = df["total_amount"] * 1.08
# when logic is too complex for a single expression, np.where / np.select beat .apply()
import numpy as np
df["tier"] = np.select(
[df["total_amount"] >= 500, df["total_amount"] >= 100],
["high", "medium"],
default="low",
)
.apply() with a Python function is still going row-by-row behind the scenes, even though it looks tidy. Only reach for it when there's genuinely no vectorized way to do it — np.where/np.select already cover most conditional-column situations, and run much faster.
from sqlalchemy import create_engine, text
engine = create_engine("postgresql+psycopg2://user:pass@localhost:5432/ecommerce")
# write a dataframe straight to Postgres
df.to_sql("orders_clean", engine, if_exists="append", index=False)
# raw SQL via SQLAlchemy Core
with engine.connect() as conn:
result = conn.execute(text("SELECT * FROM orders WHERE total_amount > :amt"), {"amt": 100})
for row in result:
print(row)
create_engine manages the connection pool.df.to_sql(...) is the fastest path from a pandas transform to a Postgres table.text()) covers most pipeline needs.Transactions with SQLAlchemy — engine.connect() alone doesn't commit automatically; wrap writes in engine.begin() so a failure partway through rolls back everything, the same atomicity guarantee from Week 1-2:
with engine.begin() as conn: # begin() auto-commits on success, auto-rolls-back on exception
conn.execute(text("DELETE FROM staging_orders WHERE batch_id = :bid"), {"bid": batch_id})
conn.execute(
text("INSERT INTO staging_orders (order_id, total_amount, batch_id) VALUES (:oid, :amt, :bid)"),
[{"oid": 1, "amt": 59.99, "bid": batch_id}, {"oid": 2, "amt": 12.50, "bid": batch_id}],
)
# if the INSERT raises an exception, the DELETE above is rolled back too — nothing is left half-done
This "delete, then insert, all in one transaction" pattern is how you make a load safe to re-run for a given batch_id (Week 4's idempotency idea) — it always clears out its own previous attempt first, before writing anything new.
import psycopg2
from psycopg2.extras import execute_values
conn = psycopg2.connect(host="localhost", dbname="ecommerce", user="postgres", password="postgres")
cur = conn.cursor()
cur.execute("SELECT customer_id, total_amount FROM orders LIMIT 5;")
rows = cur.fetchall()
execute_values(
cur,
"INSERT INTO orders_clean (order_id, total_amount) VALUES %s",
[(1, 99.5), (2, 150.0)],
)
conn.commit()
cur.close()
conn.close()
execute_values batches inserts — far faster than looping single-row INSERTs.conn.commit() is required or your writes won't persist; always close cursor/connection (or use with blocks).Bulk-loading large files fast — for genuinely large loads, Postgres's COPY command (exposed via copy_expert) is an order of magnitude faster than any row-by-row INSERT, because it streams data directly instead of executing one SQL statement per row:
with open("orders_clean.csv") as f, conn.cursor() as cur:
cur.copy_expert(
"COPY orders_clean (order_id, customer_id, total_amount) FROM STDIN WITH CSV HEADER",
f,
)
conn.commit()
And for reading results too large to fit in memory, a server-side (named) cursor streams rows from Postgres in batches instead of pulling the entire result set into your Python process at once:
with conn.cursor(name="server_side_cursor") as cur: # naming the cursor makes it server-side
cur.itersize = 2000 # fetch 2000 rows at a time under the hood
cur.execute("SELECT * FROM orders")
for row in cur: # iterates lazily, never loads all rows into memory at once
process(row)
In real code, prefer with open(...) as f, conn.cursor() as cur:-style blocks (or a try/finally) over the plain open/close calls shown at the top of this section — those were kept simple on purpose, just to show the core API clearly.
Build the pipeline: REST API → Python → Transform → PostgreSQL
import requests, logging
import pandas as pd
from sqlalchemy import create_engine
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("pipeline")
def extract(url: str) -> pd.DataFrame:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
return pd.DataFrame(resp.json())
def transform(df: pd.DataFrame) -> pd.DataFrame:
df["order_date"] = pd.to_datetime(df["order_date"])
df = df.dropna(subset=["customer_id"])
df["total_amount"] = df["total_amount"].astype(float)
return df
def load(df: pd.DataFrame, engine, table: str):
df.to_sql(table, engine, if_exists="append", index=False)
logger.info("Loaded %d rows into %s", len(df), table)
if __name__ == "__main__":
engine = create_engine("postgresql+psycopg2://postgres:postgres@localhost:5432/ecommerce")
raw = extract("https://api.example.com/orders")
clean = transform(raw)
load(clean, engine, "orders_clean")
Extending it with retry, validation, and idempotency — combining every concept from this page into one small, realistic pipeline:
import time
import requests, logging
import pandas as pd
from sqlalchemy import create_engine, text
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
logger = logging.getLogger("pipeline")
class ValidationError(Exception):
pass
def extract(url: str, max_retries: int = 3) -> pd.DataFrame:
for attempt in range(1, max_retries + 1):
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
return pd.DataFrame(resp.json())
except requests.exceptions.RequestException as e:
logger.warning("Attempt %d/%d failed: %s", attempt, max_retries, e)
if attempt == max_retries:
raise
time.sleep(2 ** attempt)
def transform(df: pd.DataFrame) -> pd.DataFrame:
df["order_date"] = pd.to_datetime(df["order_date"])
df = df.dropna(subset=["customer_id"])
df["total_amount"] = df["total_amount"].astype(float)
return df
def validate(df: pd.DataFrame) -> pd.DataFrame:
if df["total_amount"].lt(0).any():
raise ValidationError("Found negative order amounts")
if df["order_id"].duplicated().any():
raise ValidationError("Found duplicate order_id values")
return df
def load(df: pd.DataFrame, engine, table: str, batch_id: str):
with engine.begin() as conn: # idempotent: same batch_id always replaces cleanly
conn.execute(text(f"DELETE FROM {table} WHERE batch_id = :bid"), {"bid": batch_id})
df["batch_id"] = batch_id
df.to_sql(table, conn, if_exists="append", index=False)
logger.info("Loaded %d rows into %s (batch=%s)", len(df), table, batch_id)
if __name__ == "__main__":
engine = create_engine("postgresql+psycopg2://postgres:postgres@localhost:5432/ecommerce")
batch_id = time.strftime("%Y-%m-%d")
try:
raw = extract("https://api.example.com/orders")
clean = validate(transform(raw))
load(clean, engine, "orders_clean", batch_id)
except ValidationError as e:
logger.error("Data quality check failed, not loading: %s", e)
raise
Everything from this page shows up here, doing real work: extract retries with backoff (Exceptions), validate raises a custom exception on bad data (Exceptions/OOP), load wraps a delete-and-insert in one transaction keyed by batch_id so re-running the same day never double-loads it (SQLAlchemy transactions, Week 4 idempotency), and every step logs instead of printing (Logging).