Python for Data Engineering
You already know enough Python to write a script. This chapter is about the gap between "a script that works once on your laptop" and code that runs in production every night, on data that's bigger than memory, that someone else can trust and change. We'll cover the handful of Python features that close that gap — type hints, error handling, generators, the data libraries, and tests — and end by structuring a real little pipeline.
Beyond notebooks and one-off scripts
Most people learn Python in a notebook: cells you run top to bottom, variables floating in memory, a chart at the end. That's a wonderful way to explore data. It's a terrible way to ship it. A notebook hides state (you ran cell 7 before cell 4, who knows), can't be tested, and is painful to schedule or review. Production data code looks different: it lives in modules (plain .py files), it's organized into functions, and it's versioned in git so every change is reviewable and reversible.
Recall the uv project you set up in Course 2. That layout is the starting point for everything here:
my-pipeline/
├── pyproject.toml # dependencies + project metadata (uv manages this)
├── uv.lock # exact pinned versions, committed to git
├── src/
│ └── my_pipeline/
│ ├── __init__.py # marks this folder as an importable package
│ ├── extract.py # one module per stage
│ ├── transform.py
│ └── load.py
└── tests/
└── test_transform.py # tests live next door, not inside the codeA module is just a file. An import pulls names from one file into another (from my_pipeline.transform import clean_rows). A package is a folder with an __init__.py. None of this is exotic — it's how you turn a wall of cells into named, reusable pieces. The payoff: each function can be tested in isolation, imported by the scheduler, and reviewed in a pull request line by line.
The difference between "data scientist code" and "data engineer code" is rarely cleverness — it's operability. Structured, versioned code can be run unattended at 3am, debugged from a stack trace, rolled back when it breaks, and changed safely by a teammate six months later. That reliability is the job.
Type hints: catching shape drift early
Python doesn't force you to declare types, but you can annotate them. A type hint is a note to readers and to tools about what a function expects and returns. It changes nothing at runtime — Python won't stop you passing the wrong type — but a type checker reads the hints and flags mismatches before you run anything.
This matters enormously in data code, where the bugs are almost always shape and type drift: a column that was an int yesterday arrives as a str today, a function that promised a list quietly returns None, a dict is missing a key. Types make those promises explicit.
from datetime import date
def daily_total(rows: list[dict[str, float]], day: date) -> float:
"""Sum the 'amount' field across all rows for one day."""
total = 0.0
for row in rows:
total += row["amount"] # type checker knows this is a float
return totalRead the signature like a contract: "give me a list of dicts whose values are floats, plus a date; I'll give you back a float." If somewhere else you call daily_total(df, "2026-06-21") — passing a DataFrame and a string — a type checker catches it instantly, instead of you discovering it via a confusing error in production.
Install a checker and point it at your code: uv run mypy src/ (or uv run pyright). It reads the hints, reports mismatches, and never executes your pipeline. Run it in CI and shape bugs get caught at review time, not 3am.
Error handling: fail loudly, not silently
When something goes wrong, Python raises an exception — an object describing the failure that travels up the call stack until something catches it or the program stops. You catch exceptions with try/except, and clean up with finally (which runs no matter what):
def parse_amount(raw: str) -> float:
try:
return float(raw)
except ValueError:
# the WRONG thing to do is `return 0.0` and move on — that
# silently corrupts your data. Re-raise with context instead.
raise ValueError(f"could not parse amount from {raw!r}")
finally:
pass # e.g. close a file or connection here, alwaysThe cardinal sin in data engineering is the silent swallow: except: pass. It turns a loud, debuggable crash into a quiet wrong number that nobody notices for weeks. A pipeline that fails is annoying; a pipeline that succeeds with corrupt output is a disaster, because everyone downstream trusts it. Fail loudly. Let bad data stop the line.
For pipelines, define custom exceptions per stage so failures are self-labeling:
class PipelineError(Exception):
"""Base class for everything our pipeline raises."""
class ExtractError(PipelineError): ...
class TransformError(PipelineError): ...
class LoadError(PipelineError): ...Now a stack trace that ends in TransformError tells you where in the flow it broke before you read a single line. Here's how to decide what to do when something fails:
| Situation | Pattern | Why |
|---|---|---|
| Network blip, API timeout, rate limit | Retry with backoff (then give up) | Transient — likely works on the next try; don't fail on a hiccup. |
| Malformed row, bad schema, missing column | Fail loudly (raise) | The data is wrong; running on means corrupt output downstream. |
| A few junk rows in a million-row file | Quarantine — route bad rows aside, count them, continue | Don't lose 999,999 good rows over a handful; but record the rejects. |
Bug in your own logic (e.g. KeyError) | Let it crash; fix the code | Catching your own bugs hides them. Crash, read the trace, fix. |
| Cleanup (close file, release lock) | finally or a with block | Must happen on both success and failure. |
try: ... except: pass catches everything — including typos, Ctrl-C, and out-of-memory — and throws the evidence away. If you must catch, catch the specific exception you expect, and at minimum log it. Swallowed errors are the number-one cause of "the numbers look weird and nobody knows why."
Iterators & generators: data bigger than memory
Here's a scenario you'll hit constantly: a 50 GB CSV and a laptop with 16 GB of RAM. rows = open("huge.csv").readlines() tries to load all 50 GB into memory at once and crashes (that OOM from the Start Here table). The fix is laziness: process one row at a time, holding only that row in memory, and let the rest stay on disk until you reach it.
Python's tool for this is the generator — a function that uses yield instead of return. Each yield hands back one value and pauses, resuming where it left off when you ask for the next. It produces values one at a time, on demand, never building the whole list.
Let's prove the memory stays flat. This generator yields squares forever; we consume a billion of them and watch memory not grow:
import tracemalloc
def squares(n: int):
"""A generator: yields one value at a time, never builds a list."""
for i in range(n):
yield i * i
tracemalloc.start()
total = 0
for sq in squares(1_000_000_000): # a BILLION values
total += sq
if total > 10_000_000: # stop early; the point is memory, not the sum
break
current, peak = tracemalloc.get_traced_memory()
print(f"peak memory: {peak / 1024:.1f} KB")
print(f"summed up to: {total}")Peak memory in the single-digit KB range — even though we iterated over a billion-element sequence. Constant memory, regardless of n. Compare the eager version, sum(i*i for i in range(1_000_000_000)) built as a list [i*i for i in range(...)] — that would try to allocate tens of gigabytes and die. The generator never holds more than one value at a time.
This is why generators are core to data engineering, not a nice-to-have. Streaming a file row by row, paging through an API, transforming records as they flow — all of it is generators. The pattern below reads any file lazily; it works the same on a 1 KB file and a 1 TB file:
def read_rows(path: str):
"""Yield one line at a time. Memory stays flat for any file size."""
with open(path) as f:
for line in f: # iterating a file object is ALREADY lazy
yield line.rstrip("\n")
# Count non-empty rows in a huge file without ever loading it:
count = sum(1 for row in read_rows("huge.csv") if row)
print(count)The data libraries: which one, when
Plain Python (loops, dicts, generators) is perfect for glue, streaming, and oddly-shaped data. But for tabular work — filtering, joining, grouping millions of rows — you want a library that does the heavy lifting in fast compiled code. Three dominate, and they're good at different things:
| Tool | Ease | Speed | Memory | Best at |
|---|---|---|---|---|
| Plain Python | ★★★★★ | ★☆☆☆☆ | Flexible (lazy if you stream) | Glue, streaming, weird/nested shapes, small data |
| pandas | ★★★★☆ | ★★☆☆☆ | Eager — loads it all | Exploration, small/medium tables, huge ecosystem |
| polars | ★★★★☆ | ★★★★★ | Efficient; can stream lazily | Fast in-process transforms, multi-core, bigger tables |
| DuckDB | ★★★★☆ | ★★★★★ | Spills to disk; out-of-core | SQL-shaped work, joins/aggregations over files, Parquet |
Notice the family resemblance: pandas and polars give you a Python DataFrame API; DuckDB gives you SQL (everything you learned in Chapter 05) that runs straight over CSV and Parquet files. They overlap heavily, and you'll mix them — read with DuckDB, refine with polars, hand a small result to pandas for a chart.
Small data or glue? Plain Python, or pandas/polars if it's tabular. SQL-shaped (joins, GROUP BY, scanning files)? Reach for DuckDB — it's a database engine in a library, no server needed. Genuinely huge (won't fit one machine)? That's distributed processing (Spark and friends), which we get to later. Don't reach for the heavy tool until the light one actually hurts.
Testing with pytest
"It ran without errors" is not the same as "it produced the right numbers." Data engineers test transform logic — the functions that clean, reshape, and compute — because that's where silent correctness bugs hide. A test pins down the expected output for a known input, so when you change the code later, you find out immediately if you broke the meaning.
The standard tool is pytest. The convention: put functions named test_* in files named test_*.py, and assert what should be true. Here's a transform and its test:
def normalize_region(region: str) -> str:
"""Lowercase and strip a region code. The thing we want to trust."""
return region.strip().lower()
def test_normalize_region():
assert normalize_region(" US ") == "us"
assert normalize_region("Eu") == "eu"
assert normalize_region("apac") == "apac"Run the whole suite from your project root:
uv run pytestOutput ending in something like 1 passed in 0.01s, with a green dot or . per test. Now break the function on purpose (return region unchanged) and re-run — pytest prints a clear diff: assert ' US ' == 'us', pointing at the exact failing line. That fast feedback loop is the whole value.
A fixture is reusable test setup — define a function decorated with @pytest.fixture (say, a sample DataFrame or a temp database), name it as an argument in your test, and pytest builds it fresh for each test that asks for it. Great for sharing a known sample dataset across many tests.
Structuring a pipeline: extract, transform, load
Pull the threads together. A pipeline is three jobs — extract (get the data), transform (clean and reshape it), load (write it somewhere) — and the trick is to make each one a separate pure function. A pure function depends only on its inputs and returns a value with no hidden side effects. Pure functions are testable (same input → same output, every time) and they compose cleanly.
def extract(path: str):
"""Yield raw rows lazily — memory stays flat (see #iterators)."""
with open(path) as f:
header = f.readline().rstrip("\n").split(",")
for line in f:
yield dict(zip(header, line.rstrip("\n").split(",")))
def transform(rows):
"""Pure: clean each row. No I/O, no surprises — easy to test."""
for row in rows:
yield {
"region": row["region"].strip().lower(),
"amount": float(row["amount"]),
}
def load(rows, conn) -> int:
"""The ONLY function with a side effect: writing out."""
n = 0
for row in rows:
conn.execute("INSERT INTO sales VALUES (?, ?)",
(row["region"], row["amount"]))
n += 1
return n
# Wire them together — data streams through, one row at a time:
# load(transform(extract("sales.csv")), conn)Two things are happening here. First, it's a stream: extract and transform are generators, so a row flows extract → transform → load and is forgotten before the next one starts — constant memory, any file size. Second, the messy stuff (file I/O, the database) is isolated in extract and load, leaving transform a pure function you can test with a plain list of dicts and no database at all.
An idempotent step produces the same end state whether you run it once or five times — re-running after a crash doesn't duplicate data. Pure transforms are naturally idempotent (same input → same output). The load step is where you must engineer it deliberately: use an upsert (INSERT ... ON CONFLICT) or delete-then-insert for the partition, so a retried run heals rather than double-writes. We'll lean on this hard when we build real pipelines.
✓ Check yourself
- Why is a notebook a poor home for production data code — name two concrete reasons?
- What's the danger of
except: pass, and what should you do instead? - How does a generator keep memory flat on a file bigger than RAM?
- Which would you reach for: a 200-row CSV needing a quick chart vs. a join across three 5 GB Parquet files?
- Why are pure functions easier to test, and how does that connect to idempotency?
Exercise — A cleaning generator and a test for it
Write a generator clean_rows that takes a list of raw dicts and yields cleaned rows: region lowercased/stripped, amount converted to float, and rows with an empty region skipped. Then write a pytest test that feeds it a small list and asserts the result.
def clean_rows(rows: list[dict]):
"""Yield cleaned rows; drop rows with a missing region."""
for row in rows:
region = row.get("region", "").strip().lower()
if not region:
continue # skip junk, don't crash on it
yield {"region": region, "amount": float(row["amount"])}from clean import clean_rows
def test_clean_rows():
raw = [
{"region": " US ", "amount": "120"},
{"region": "eu", "amount": "80.5"},
{"region": "", "amount": "999"}, # should be dropped
]
out = list(clean_rows(raw)) # materialize the generator
assert out == [
{"region": "us", "amount": 120.0},
{"region": "eu", "amount": 80.5},
]
assert len(out) == 2 # the empty-region row was skippedRun uv run pytest. You should see 1 passed. Note how we call list(...) to pull every value out of the generator so we can compare the whole result — in production you'd iterate it lazily instead, one row at a time. You've now written the three pillars of this chapter in one place: a generator (lazy, memory-flat), a pure transform (skips bad data loudly-but-gracefully), and a test that pins down its behavior.
Next
You can write structured, tested, memory-aware Python — now let's make the files it reads and writes fast and compact, from CSV's pitfalls to columnar Parquet. → Files & Serialization at Scale