DSM
200 XP
60 minsIntermediate⚑ 200 XP

πŸ— Project: Build a Data Pipeline in Python

Thirty-five lessons ago you assigned your first variable. Today you build the thing data teams are actually paid to build: a pipeline that takes ugly raw files in one end and pushes a clean, dated, trustworthy report out the other β€” and doesn't fall over when row 4,217 is garbage. No new syntax in this lesson. Only composition.

What you'll learn

  • Decompose a pipeline into read β†’ clean β†’ transform β†’ aggregate β†’ write stages
  • Wrap each stage in a small, testable, single-responsibility function
  • Apply skip-and-count resilience so bad rows are reported, never fatal
  • Compose pathlib, regex, datetime, and dict aggregation into one program
  • Produce dated, idempotent, auditable outputs like a production job

What

An end-to-end pipeline in pure Python: discover input files with pathlib, stream and parse each line, extract fields with regex, convert types defensively, skip-and-count bad rows, aggregate with dictionaries, and write a summary CSV into a dated output folder. Structured as small single-responsibility functions β€” the shape of every real ETL job.

Why

This is the graduation exercise: every module β€” variables, strings, control flow, functions, data structures, error handling, files, paths, dates, regex β€” earns its place in one program. It's also the mental template for everything ahead: pandas, SQL, and ML pipelines all follow this same read β†’ clean β†’ transform β†’ aggregate β†’ write arc, just with bigger engines.

Where it's used

ETL jobs, nightly reports, data-quality monitors, ingestion scripts β€” and interviews, where 'build a small pipeline' is a standard take-home task for data roles.

Where this runs in production

AirbnbAirflow DAGs are this pipeline, scheduled

Airbnb built Airflow to orchestrate thousands of daily read-clean-aggregate-write jobs; each task in those DAGs has exactly the stage structure you're building here.

The New York TimesElection night data pipelines

Results feeds arrive as messy files from thousands of counties; parsing, validating, skip-counting, and aggregating them into live totals is this project's shape under deadline pressure.

StripeFinancial reconciliation jobs

Daily settlement files from card networks get parsed, validated row by row (with every rejection logged and counted), aggregated, and written to dated reports β€” auditability is the product.

Theory

The core ideas, in plain language.

The brief: a folder receives daily sales exports as text files. Lines look like '2026-07-16 | WIDGET-042 | 3 units | €4.50' β€” mostly. Some lines are blank, some corrupted, prices sometimes read 'EUR 4.50'. The job: every CSV-ish file in the inbox becomes rows; rows become per-product revenue totals; totals become a dated summary report β€” with a data-quality count of everything skipped. That spec, or one shaped exactly like it, is a genuine junior-data-engineer ticket.
Key Concept
The five-stage arc

Every pipeline decomposes into: DISCOVER (find input files β€” pathlib glob), EXTRACT (stream lines out of each file β€” the with-loop), TRANSFORM (line β†’ typed record: regex + conversions + validation), AGGREGATE (records β†’ summary: dict accumulation), LOAD (write outputs β€” dated paths, separate tree). Naming the stages isn't ceremony: each becomes a function, each function is testable alone, and when production breaks at 2 a.m. you can localize the failure to one stage in minutes.

def parse_line(line: str) -> tuple | None:
    """'2026-07-16 | WIDGET-042 | 3 units | €4.50' -> (date, code, units, price) or None."""
    m = LINE_PAT.search(line)
    if m is None:
        return None
    return (
        datetime.strptime(m.group(1), '%Y-%m-%d'),
        m.group(2).upper(),
        int(m.group(3)),
        float(m.group(4)),
    )

The heart of the pipeline is one honest function: string in, typed record or None out. The None contract makes bad data a VALUE the caller counts, not an exception that escapes β€” the parse attempt is total. Docstring shows a real example; the caller never needs to know regex is inside.

Analogy: A bottling plant, not a bucket brigade

Beginner scripts are bucket brigades: one long main block where water (data) sloshes between untyped variables and one spill soaks everything. A pipeline is a bottling plant: separate STATIONS (functions) β€” intake inspects crates (discover/extract), the washer rejects cracked bottles into a counted bin (transform + skip-and-count), filling measures precisely (aggregate), labeling stamps the date (load). Each station is inspectable alone, the reject bin is measured β€” because a plant that silently discards bottles is a plant you can't trust β€” and the conveyor (main()) just connects stations.

Key Concept
Resilience policy: skip, count, REPORT

Decide per stage what failure means. A corrupt LINE: skip and count β€” one bad row must not kill a million-row job. A missing input FOLDER: crash loudly with the path in the message β€” that's a deployment error, not data noise. The non-negotiable: every skip is counted and printed in the report. '3 rows skipped of 1,204' is data quality information; silent dropping is how a feed that's 40% garbage goes unnoticed for a quarter. This asymmetry β€” tolerant of rows, strict about structure β€” is the professional default.

def aggregate(records: list) -> dict:
    """records -> {product_code: total_revenue}"""
    totals = {}
    for _date, code, units, price in records:
        totals[code] = totals.get(code, 0.0) + units * price
    return totals

Aggregation is the dict-counting idiom scaled up: .get(key, default) + increment. Tuple unpacking names the fields (underscore marks the unused date). Keeping this a pure function β€” list in, dict out, no prints, no files β€” makes it trivially testable: feed three records, assert the dict.

Key Concept
Outputs: dated, separated, idempotent

The load stage applies the paths lesson verbatim: out_dir = base / 'reports' / run_date.strftime('%Y-%m-%d'), then mkdir(parents=True, exist_ok=True), then write the summary INTO it. Dated folders make history accumulate and runs auditable; the separate output tree makes reruns safe (inputs untouched); mkdir's flags make the job land on a fresh machine without ceremony. Filenames get ISO dates because alphabetical = chronological.

Watch out
Where capstone pipelines go wrong

1) One 80-line main block β€” decompose; if a function needs 'and' to describe, split it. 2) Swallowed skips (except: pass) β€” count and report, always. 3) Outputs written into the input folder β€” the rerun-corruption classic. 4) datetime.now() buried in logic β€” inject the run date; reproducibility dies otherwise. 5) Parsing with split('|') then choking on the one line with a missing pipe β€” the regex + None contract absorbs shape variation. 6) Aggregating floats without a final round for display. 7) No verification step β€” end by reading back what you wrote and printing it; trust nothing you didn't check.

Visual Learning

See the concept, then explore it.

The pipeline, stage by stage

Raw files to dated report β€” each station is one function, each arrow is typed data. Click through the flow.

Worked Examples

Watch it built up, one line at a time.

EasyStage 1–2: discover and stream

Build the intake: create a fixture inbox, then discover and stream every line with file/line counts β€” the pipeline's skeleton before any parsing.

Step 1 of 2

Self-contained fixtures β€” the habit that makes examples (and tests) runnable anywhere. Note the planted defects: a corrupt line and the 'EUR' price variant. Good pipelines are built AGAINST known-bad data, not clean samples.

Code
01from pathlib import Path
02Β 
03inbox = Path('inbox')
04inbox.mkdir(exist_ok=True)
05(inbox / 'day1.txt').write_text(
06 '2026-07-15 | WIDGET-042 | 3 units | \u20ac4.50\n'
07 'corrupted nonsense\n'
08 '2026-07-15 | GIZMO-007 | 1 units | \u20ac12.00\n',
09 encoding='utf-8')
10(inbox / 'day2.txt').write_text(
11 '2026-07-16 | WIDGET-042 | 2 units | EUR 4.50\n',
12 encoding='utf-8')
13print('fixture ready')
Practice Coding

Your turn β€” write the code.

Your task

Build a miniature pipeline for expense lines shaped 'YYYY-MM-DD, CATEGORY, €AMOUNT' (amount has 2 decimals). Write parse_line(line) returning (category, float_amount) or None (regex with 2 groups β€” date matched but not captured; category is uppercase letters). Feed it the lines list, skip-and-count failures, aggregate per-category totals with a dict, and print each 'CATEGORY: total' (sorted, 2 decimals) followed by 'skipped: <n>'.

Expected output
FOOD: 20.75
TRAVEL: 40.00
skipped: 2

Write your solution in the editor on the right, then hit Run.

Exercises

Prove it. Reach 80% to complete the lesson.

Mastery Gate0% / 80% required
Easy0/2 solved

What are the five stages of the pipeline arc, in order?

Why does parse_line return None instead of raising on a bad row?

Medium0/3 solved

Why does run() take inbox, out_base, and run_date as parameters instead of using Path('inbox') and datetime.now() internally?

ScenarioA teammate's pipeline wraps each line's processing in try/except Exception: pass. It has reported clean runs for two months. A stakeholder discovers the revenue report has been missing an entire region β€” whose feed changed its date format nine weeks ago.

What's the root failure?

Write aggregate(records) for records = [('WIDGET', 3, 4.5), ('GIZMO', 1, 12.0), ('WIDGET', 2, 4.5)] returning {code: units*price summed}. Print each 'code: total' sorted, 2 decimals. Expected: GIZMO: 12.00 WIDGET: 22.50

Pure function:aggregate takes the list and returns a dict without printing
Accumulation idiom:Uses .get(code, 0.0) + units * price across duplicate keys
Hard0/2 solved

The report lands at reports/<ISO-date>/summary.csv, in a tree separate from inbox/. Which failure does each choice prevent?

Mini end-to-end: lines = ['A-1, 2, 3.00', 'bad', 'B-2, 1, 10.00', 'A-1, 1, 3.00']. Pattern: ([A-Z]-\d+),\s*(\d+),\s*(\d+\.\d{2}) β†’ (code, int qty, float price) or skip-count. Aggregate qty*price per code, print sorted 'code: total' (2 decimals), then 'ok: <n> skipped: <n>'. Expected: A-1: 9.00 B-2: 10.00 ok: 3 skipped: 1

Full arc in miniature:Parse with groups, type-convert, skip-count, aggregate, report both counts
Quality reporting:Output includes ok and skipped counts alongside the totals
Complete 80% more exercises to unlock.
Interview Prep

How this shows up in real interviews.

Walk me through designing a script that turns a folder of messy text exports into a daily summary report. What's your structure and why?

Show model answer

I'd structure it as five explicit stages, each a small function. DISCOVER: sorted(inbox.glob('*.txt')) β€” sorted for run-to-run determinism; a missing inbox raises loudly with the path (deployment error), an empty one is a valid no-op. EXTRACT: stream each file with `with open(..., encoding='utf-8')` and lazy line iteration, so size never matters. TRANSFORM: a parse_line(line) function owning all the intelligence β€” a compiled regex with capture groups extracts fields, strptime/int/float type them, and the contract is 'typed tuple or None': bad rows become countable values, so the calling loop is a clean if rec is None: skipped += 1 with no exception plumbing. AGGREGATE: a pure function folding records into a dict with .get(key, 0)+increment β€” list in, dict out, trivially unit-testable. LOAD: outputs into a DATED folder (out_base / run_date ISO), mkdir(parents=True, exist_ok=True), skip counts written INTO the report, then read back and printed as verification. Composition: a run(inbox, out_base, run_date) function with injected dependencies (temp dirs and pinned dates in tests; no hidden now()), under an if __name__ == '__main__' guard. The 'why' running through all of it: localize failures to a stage, make bad data visible rather than fatal, and make every run reproducible and auditable.

How do you decide which errors a pipeline tolerates and which should kill it?

Show model answer

By blast radius and meaning. Row-level dirt β€” an unparseable line, a bad number, a malformed date β€” is EXPECTED in real feeds; one row's failure says nothing about the other million, so the policy is skip, count, and report: the parse function returns None (or the loop catches the specific ValueError), a counter increments, and the count ships in the output where humans see it. Structural failures β€” the input folder missing, a file unreadable, an undecodable encoding, the output disk full β€” mean the JOB's premises are false; continuing would produce a plausible-looking but wrong report, so these crash immediately and loudly, with the offending path in the message. Two refinements make this production-grade: thresholds β€” tolerating 0.1% bad rows is resilience, tolerating 60% is negligence, so real jobs fail if skipped/total exceeds a limit (catching upstream format drift the morning it happens); and specificity β€” except ValueError around the parse, never except Exception, because a broad catch converts genuine bugs (a typo'd variable, a None where a list belonged) into 'skipped rows' and hides them. The anti-pattern to name explicitly: except: pass. Silent partial data is worse than a crash β€” a crash gets fixed today; quietly missing data gets discovered in an executive meeting.

This pipeline is pure Python. Where does pandas fit, and when would you still write it this way?

Show model answer

pandas industrializes the middle stages: read_csv replaces the manual extract-and-parse for WELL-FORMED delimited data (with dtype and parse_dates doing the typing), .str.extract applies the same regex thinking column-wise, and groupby().sum() is the aggregate stage in one line β€” the five-stage arc survives intact, each stage just gets a bigger engine. I'd reach for pandas as soon as the data is tabular and fits memory comfortably: less code, fewer bugs, and vectorized speed. Pure Python keeps the job when: the input is NOT table-shaped β€” ragged log lines, mixed record types, formats needing per-line regex dispatch β€” where pandas' rectangular model fights you; the file is huge and the aggregation is streaming-friendly (the line loop holds one row in memory; read_csv holds everything, though chunksize is pandas' middle ground); dependencies must be zero (a cron box or minimal container where the standard library is guaranteed β€” the package-management lesson's concern); or row-level error POLICY is the point β€” per-line skip-and-count with custom rules is explicit in a loop and awkward mid-read_csv. The honest professional answer: prototype the parse in pure Python to UNDERSTAND the mess, then port to pandas once the data proves tabular β€” carrying over the same skip-counting and dated-output discipline, because the engineering values are engine-independent.

Common Mistakes to Avoid

1) One monolithic main block β€” five stages, five functions; testability is the payoff. 2) except: pass β€” the silent-partial-data machine; skip, COUNT, report. 3) Outputs in the input tree β€” reruns corrupt sources; separate, dated trees. 4) datetime.now() buried in logic β€” inject run_date; reproducibility requires it. 5) Broad except Exception around parsing β€” catches your bugs as 'bad rows'; catch ValueError specifically or use the None contract. 6) No skip threshold β€” 60% rejects should fail the job, not pad a counter. 7) Skipping verification β€” read back the report and print it; unverified writes are hope, not engineering. 8) Testing only on clean fixtures β€” plant corrupt lines in your test data on purpose.

Ask the AI Tutor

Try these prompts in the AI Tutor panel: β€’ 'Review my pipeline against the five-stage arc β€” where did I merge stages?' β€’ 'Give me three progressively messier sample feeds and critique my parse function on each.' β€’ 'Quiz me: for ten failure scenarios, do I skip-and-count or crash loudly?' β€’ 'Help me add a skip-rate threshold that fails the run above 5%.' β€’ 'Interview mode: ask me the pipeline-design question and grade my structure.'

Glossary

pipeline / ETL β€” the read β†’ clean β†’ transform β†’ aggregate β†’ write arc (Extract, Transform, Load). stage β€” one arc step, implemented as one function. parse contract β€” 'typed record or None': failure as a countable value. skip-and-count β€” tolerate row dirt, report the tally in the output. skip threshold β€” the reject rate above which the job fails instead of continuing. dependency injection β€” passing paths/dates as parameters instead of reaching for globals or now(). idempotent β€” safe to rerun (separate output trees, exist_ok mkdir). dated outputs β€” ISO-named run folders that accumulate auditable history. verification β€” reading back what you wrote before claiming success. entry guard β€” if __name__ == '__main__': keeping imports side-effect free.

Recommended Resources

β€’ Extend the build: add a skip-rate threshold, a second input format, and a per-date (not just per-product) breakdown β€” each is a one-function change if your stages are clean, which is the test of whether they are. β€’ Read: the csv module docs (your split/regex parsing, industrial-strength) and a first look at pandas' read_csv page to see these stages with a bigger engine. β€’ Compare: sketch this same pipeline as pandas one-liners and note which stages compress and which (error policy!) don't. β€’ Next in DSM: the Python domain is COMPLETE β€” 36 lessons from first variable to working pipeline. The road continues into the data stack: pandas DataFrames, where these exact patterns meet columnar power.

Recap

βœ“ Five stages, five functions: discover (glob) β†’ extract (stream) β†’ transform (parse to typed records) β†’ aggregate (dict fold) β†’ load (dated report). βœ“ The parse contract β€” record or None β€” turns bad rows into countable values; the loop stays exception-free. βœ“ Tolerant of rows, strict about structure: skip-and-count dirt, crash loudly on missing folders β€” and report every count. βœ“ Inject dependencies (paths, run_date); guard the entry point; keep aggregate pure β€” that's what makes it testable. βœ“ Dated, separated, verified outputs: mkdir flags, ISO names, read-back before declaring victory. βœ“ The arc is universal β€” pandas, SQL, and every orchestrator you'll meet run this same shape at scale. Next up: you've completed the Python domain β€” all 36 lessons. The journey continues in the Data Analysis course with pandas DataFrames, where every idiom you built here gets a columnar engine underneath it.

scratchpad β€” preview this lesson's challenge anytime
expense_pipeline.pyPython
Ready
Output

Run your code to see the output here.