Capstone: build a mini data pipeline on your PC

Everything from this course comes together in one program you can run. You will land three days of messy orders, store them as Parquet partitioned by date, clean them in a silver layer with a quality gate and masked phone numbers, build gold business tables, reconcile every row, break idempotency on purpose, and see how the same design runs on Spark, Delta Lake and Airflow.

Module 7 · Capstone Project⏱ 45 minBeginner → AdvancedRuns in your browser (Python)3 real-time scenariosFull capstone for your PC9 practice problems
1 The story

Show us something you built

Kabir’s full-time interview at TiffinGo ends with a surprise: “Before the offer, show us a small pipeline you built yourself, end to end, on your own laptop.” Riya, who has followed every lesson with him, wants to build one too for her portfolio. Arjun Sir gives them both the same brief.

rawJSON linesbronzeParquet by datesilverclean · dedup · maskedgoldbusiness tablesreportrevenue by citycapstone_pipeline.py · runs on your laptop in seconds Every idea from the course, in one pipeline you can run. myphatshala.in
Arjun Sir

Three days of order data. Land it raw, store it as Parquet partitioned by date, clean it with quality checks, mask phone numbers, build gold tables, print a report. And it must be safe to run twice.

Riya

On a laptop? I thought big data needs a cluster.

Arjun Sir

The design is what matters, Riya. Bronze, silver, gold, partitions, a quality gate, idempotent writes: these are exactly what you’d build on Spark and a cloud lake. You’ll just run it with Python and Parquet first, and swap the engine later.

Kabir

What will the reviewers look at?

Arjun Sir

Whether it runs with one command, whether the numbers are right and explained, whether bad data is handled, whether a re-run creates duplicates, and whether your README explains the decisions. Today you’ll build it step by step, run it, break it on purpose, and see how each piece maps to production tools.

2 Definition

The capstone: a mini lakehouse pipeline

Formal definition (write this in exams)

A capstone project is an end-to-end project that integrates the skills of a course into one working system. Here it is a batch data pipeline implementing the medallion architecture: raw ingestion, a bronze layer of source data in a columnar format partitioned by date, a silver layer of deduplicated, validated and privacy-protected records, and a gold layer of aggregated business tables, with a data quality gate, idempotent partition overwrites and reproducible outputs.

In simple words

You build a small but complete data pipeline on your own computer: messy raw data goes in, clean trustworthy business numbers come out, and running it again never creates duplicates. It uses the same design real companies use, only smaller.

Words you’ll meet in this lesson

Medallion architectureBronze (raw), silver (clean), gold (business-ready) layers in a lake or lakehouse.
Partition overwriteReplacing one partition (one date folder) completely on each run, which makes the step idempotent.
Quarantine tableWhere rows that fail quality checks are kept for review instead of being dropped silently.
ReproducibilityAnyone running the same code on the same inputs gets the same outputs.
pyarrowThe Python library for Apache Arrow tables and Parquet files, used here as a small local engine.
Hive-style partitionsFolders named column=value (for example date=2026-10-19) that engines read as a column.
BackfillRe-running the pipeline for past dates, safe only when steps are idempotent.
READMEThe front page of the project: what, why, how to run, results and decisions.

The brief

Input3 days of food-delivery orders as JSON lines, about 20,000 rows a day, with duplicates, missing cities and invalid quantities
OutputGold tables: revenue and order count by city per day; revenue by dish; plus a printed report
RulesQuality gate at 2%; no raw phone numbers after bronze; identical results on re-run; one command to run
ToolsPython 3 and pyarrow (pip install pyarrow); optional: DuckDB, PySpark, Airflow for extensions

The capstone at three levels

Pick your level. New to this? Start at Beginner and climb.

Beginner Build it and run it

  • Generate three days of raw orders with realistic problems (duplicates, missing cities, zero quantities).
  • Store raw data as files, then as Parquet in folders named date=YYYY-MM-DD (bronze).
  • Clean it (silver): remove duplicates, quarantine bad rows, replace phone numbers with keys.
  • Summarise it (gold): revenue by city per day and revenue by dish, then print a report.

Intermediate Make it reliable

  • Idempotency: each step overwrites its own date partition, so re-running gives identical results.
  • Quality gate: the run stops if more than 2% of rows fail checks; quarantined rows are kept for review.
  • Repeatability: fixed random seeds per day, so anyone can reproduce your numbers.
  • Logging: each step logs row counts, so a reviewer can follow where rows went.
  • README: problem, architecture diagram, how to run, results, decisions and limitations.

Advanced Take it towards production

  • Swap pyarrow for PySpark or DuckDB; store tables in Delta Lake or Iceberg for ACID MERGE and time travel.
  • Schedule each step as a task in an Airflow DAG with retries, backfills and alerts.
  • Ingest from Kafka instead of files for a streaming bronze layer; keep the batch gold for finance.
  • Move storage to object storage (S3, ADLS, GCS or local MinIO), add tests and CI, data contracts and cost notes.

Exam tip: Describe an end-to-end pipeline using the medallion architecture: the purpose of each layer, the data quality checks in silver, how partitioning and idempotent overwrites make re-runs safe, and how the design maps to Spark, Delta Lake and Airflow in production.

3 Types and ways to use it

Layers, folders and grading

LayerFolderWhat happensCourse lessons used
Rawlake/raw/The app’s export lands unchanged as JSON lines2.1 data types · 2.2 file formats
Bronzelake/bronze/orders/date=…/Same rows, stored as Parquet, one folder per date2.2 Parquet · 3.2 partitioning
Silverlake/silver/orders/date=…/Deduplicated, validated, phones replaced by keys; bad rows to quarantine5.1 quality and privacy · 4.5 idempotency
Goldlake/gold/Revenue by city per day; revenue by dish4.x lakehouse and SQL engines
Reportterminal outputThe numbers a manager would read1.1 value from data

How reviewers grade a capstone

Runs first time

One command, clear requirements, fixed seeds, no hard-coded personal paths.

20%

Correct and explained

Row counts reconcile at every step; numbers are checked and described in the README.

30%

Reliable

Quality gate, quarantine, idempotent re-runs, sensible errors.

30%

Communicated

README with architecture, decisions, limitations and next steps; clean code.

20%

Write the README while you build, not after. Each time you make a decision (“overwrite the partition instead of appending”), add one line explaining why. That list becomes your best interview material.

4 Step by step

Building the pipeline

  1. Extract: land the raw data

    extract(day) writes one JSON-lines file per day into lake/raw/, with duplicates, missing cities and zero quantities, exactly like a real export.

    Keeping raw data unchanged means you can always rebuild every later layer if a bug is found.
  2. Bronze: Parquet partitioned by date

    day = "2026-10-19"
    print(f"lake/bronze/orders/date={day}/part-0.parquet")
    lake/bronze/orders/date=2026-10-19/part-0.parquet
    Columnar files compress well and read fast; date folders let later steps and queries touch only the days they need. The folder is deleted and rewritten each run, so it is idempotent.
  3. Silver: deduplicate

    ids = ["A", "B", "A", "C", "B"]
    seen, keep = set(), []
    for i in ids:
        if i not in seen:
            seen.add(i); keep.append(i)
    print(keep, "removed", len(ids) - len(keep))
    ['A', 'B', 'C'] removed 2
    Retries create duplicate orders. Keeping the first occurrence of each order_id removes them; in Spark or Delta you would use dropDuplicates or MERGE.
  4. Silver: quality gate and masking

    rows, bad = 21_429 - 150, 100
    print(f"failure rate {bad / rows:.2%}", "-> continue" if bad / rows <= 0.02 else "-> STOP")
    failure rate 0.47% -> continue
    Rows with qty ≤ 0 or no city go to quarantine. Above 2% the pipeline stops. Phone numbers become salted-hash customer keys, so analysts never see them.
  5. Gold: business tables

    gold() reads all silver partitions, groups by date and city and by dish, and writes two small Parquet tables for dashboards.

    Gold tables are small, fast and named in business language, the only layer most users ever query.
  6. Prove it: re-run and compare

    The script runs the whole pipeline a second time and checks that the gold table is identical.

    This one check demonstrates idempotency, the property interviewers ask about most for pipelines.
5 See it happen

One run: the terminal, and the lake folder

Step through the six frames. Left: the terminal output. Right: files appearing in the lake.

What Kabir sees in the terminal, and what appears in the lake folder 6 steps
What you see $ python capstone_pipeline.py[extract] 2026-10-17: 20,005 raw rows$ python capstone_pipeline.py[extract] 2026-10-17: 20,005 raw rows[silver ] 19,755 clean, 150 dup, 100 bad$ python capstone_pipeline.py[extract] 2026-10-17: 20,005 raw rows[silver ] 19,755 clean, 150 dup, 100 bad[extract] 2026-10-18: 18,998 raw rows[silver ] 18,748 clean, 150 dup, 100 bad[extract] 2026-10-19: 21,429 raw rows[silver ] 21,179 clean, 150 dup, 100 bad$ python capstone_pipeline.py[extract] 2026-10-17: 20,005 raw rows[silver ] 19,755 clean, 150 dup, 100 bad[extract] 2026-10-18: 18,998 raw rows[silver ] 18,748 clean, 150 dup, 100 bad[extract] 2026-10-19: 21,429 raw rows[silver ] 21,179 clean, 150 dup, 100 bad[gold ] 59,682 orders -> 2 gold tables$ python capstone_pipeline.py[extract] 2026-10-17: 20,005 raw rows[silver ] 19,755 clean, 150 dup, 100 bad[extract] 2026-10-18: 18,998 raw rows[silver ] 18,748 clean, 150 dup, 100 bad[extract] 2026-10-19: 21,429 raw rows[silver ] 21,179 clean, 150 dup, 100 bad[gold ] 59,682 orders -> 2 gold tablesMumbai 11,17,740 · Thane 11,05,820 …$ python capstone_pipeline.py[extract] 2026-10-17: 20,005 raw rows[silver ] 19,755 clean, 150 dup, 100 bad[extract] 2026-10-18: 18,998 raw rows[silver ] 18,748 clean, 150 dup, 100 bad[extract] 2026-10-19: 21,429 raw rows[silver ] 21,179 clean, 150 dup, 100 bad[gold ] 59,682 orders -> 2 gold tablesre-run …Gold identical after re-run: True myphatshala.in
Behind the screen ▸ lake/▸ raw/· orders_2026-10-17.jsonlDay 1 raw file written: 20,005 JSON lines.▸ lake/▸ raw/· orders_2026-10-17.jsonl▸ bronze/orders/▸ date=2026-10-17/· part-0.parquet▸ silver/▸ orders/date=2026-10-17/▸ quarantine/date=2026-10-17/Bronze Parquet, then silver orders and quarantine for day 1.▸ lake/▸ raw/ (3 files)▸ bronze/orders/ date=17, 18, 19▸ silver/orders/ date=17, 18, 19▸ silver/quarantine/ date=17, 18, 19All three days in every layer, one folder per date.▸ lake/▸ raw/ (3 files)▸ bronze/orders/ date=17, 18, 19▸ silver/… date=17, 18, 19▸ gold/· daily_city_revenue.parquet· dish_revenue.parquetGold reads all silver partitions and writes 2 small tables.▸ lake/· gold/daily_city_revenue.parquet· date | city | amount_sum | count· 2026-10-19 | Mumbai | 1117740 | 5352The report reads gold only: small, fast, business language.▸ lake/▸ bronze/orders/date=2026-10-17/· deleted and rewritten▸ silver/orders/date=2026-10-17/· deleted and rewritten· still 14 files in totalRe-run overwrites each partition: no duplicates, same totals. myphatshala.in
6 Real-world use

Real-time scenarios: reconcile, break and scale

Three live scenarios: reconciling every row of one day (real numbers from the program below), breaking idempotency on purpose, and an advanced map from the capstone to production tools.

Real-time scenario 1

Account for every row

These figures come from the program in “Run it on your PC” below. Reviewers trust pipelines whose row counts add up at every step. Watch one day reconcile.

Row reconciliation 5 steps
Animated example Day 2026-10-19: where every row wentraw21,429Day 3 arrives: 21,429 raw rows,including retries and broken records.Day 2026-10-19: where every row wentraw21,429− duplicates150Dedup on order_id removes 150 duplicates(the same orders sent twice by retries).Day 2026-10-19: where every row wentraw21,429− duplicates150− quarantined100100 rows fail checks (qty 0 or no city):0.47% of rows, below the 2% gate. Quarantined.Day 2026-10-19: where every row wentraw21,429− duplicates150− quarantined100= silver21,17921,429 − 150 − 100 = 21,179 clean rows.Every row is accounted for.Day 2026-10-19: where every row wentraw21,429− duplicates150− quarantined100= silver21,179Across 3 days: 59,682 clean orders in gold.Reconciliation like this belongs in your README. myphatshala.in
Real-time scenario 2

Break it on purpose: append vs overwrite

The fastest way to understand idempotency is to break it. Change the write to append and watch retries and backfills multiply revenue.

Idempotent writes 5 steps
Animated example Gold revenue for 2026-10-19 after each run (₹)run 1₹44.0 Lmode: appendRun 1: revenue for 19 October is about ₹44 lakh.Correct.Gold revenue for 2026-10-19 after each run (₹)run 1₹44.0 Lrun 2₹87.9 Lmode: appendAirflow retries the task after a timeout.With append, the day is written twice: ₹88 lakh.Gold revenue for 2026-10-19 after each run (₹)run 1₹44.0 Lrun 2₹87.9 Lrun 3₹131.9 Lmode: appendA backfill runs it again: ₹1.3 crore.The dashboard is now three times wrong.Gold revenue for 2026-10-19 after each run (₹)run 1₹44.0 Lrun 2₹44.0 Lrun 3₹44.0 Lmode: overwrite partitionSame three runs with partition overwrite:the date folder is replaced each time.Gold revenue for 2026-10-19 after each run (₹)run 1₹44.0 Lrun 2₹44.0 Lrun 3₹44.0 Lmode: overwrite partitionRetries and backfills are now safe.In Delta: replaceWhere or MERGE on order_id. myphatshala.in
Real-time scenario 3 · advanced

From your laptop to a production lakehouse

Reviewers often ask: “How would this work at 1,000 times the data?” Watch each part of the capstone map to its production equivalent.

Scaling the design 5 steps
Animated example Your capstoneProductionJSON files in lake/rawKafka topic / app export to object storagepyarrow on a laptopPySpark on Databricks, EMR or DataprocThe same design, scaled: files become a streamor cloud export; pyarrow becomes Spark.Your capstoneProductionJSON files in lake/rawKafka topic / app export to object storagepyarrow on a laptopPySpark on Databricks, EMR or DataprocParquet foldersDelta Lake / Iceberg (ACID, MERGE)Parquet folders become Delta or Iceberg tables,adding ACID commits, MERGE and time travel.Your capstoneProductionJSON files in lake/rawKafka topic / app export to object storagepyarrow on a laptopPySpark on Databricks, EMR or DataprocParquet foldersDelta Lake / Iceberg (ACID, MERGE)for day in DATESAirflow DAG: daily schedule, retries, backfillThe for-loop over dates becomes an Airflow DAGwith one task per layer, retries and backfills.Your capstoneProductionJSON files in lake/rawKafka topic / app export to object storagepyarrow on a laptopPySpark on Databricks, EMR or DataprocParquet foldersDelta Lake / Iceberg (ACID, MERGE)for day in DATESAirflow DAG: daily schedule, retries, backfillprint() logsLogs, metrics, alerts; data quality toollocal lake/ folderS3 / ADLS / GCS (or MinIO locally)Logs become metrics and alerts; the laptop folderbecomes object storage with access controls.Your capstoneProductionJSON files in lake/rawKafka topic / app export to object storagepyarrow on a laptopPySpark on Databricks, EMR or DataprocParquet foldersDelta Lake / Iceberg (ACID, MERGE)for day in DATESAirflow DAG: daily schedule, retries, backfillprint() logsLogs, metrics, alerts; data quality toollocal lake/ folderS3 / ADLS / GCS (or MinIO locally)Nothing in the design changes: bronze, silver,gold, quality gate, idempotent writes. myphatshala.in

Extend your capstone

SQL

1. Query the lake with DuckDB

Install DuckDB and run SQL directly on the Parquet folders: SELECT city, SUM(amount) FROM 'lake/silver/orders/*/*.parquet' GROUP BY city.

Parquet files→DuckDB SQL→Ad-hoc answers

2. Rewrite silver in PySpark

Replace the pyarrow code with Spark DataFrames: dropDuplicates, filter, withColumn, partitionBy("date"). Compare speed and code.

Bronze→Spark DataFrames→Silver

3. Orchestrate with Airflow

Turn extract, bronze, silver and gold into four tasks in a daily DAG with retries and a backfill for October.

Schedule→DAG with 4 tasks→Retries + backfill

4. Add tests and CI

Write pytest tests for dedup, the quality gate and masking; run them on every push with GitHub Actions; add the badge to your README.

Code→pytest + CI→Trust

README template for your capstone

SectionWhat to write
ProblemOne paragraph: daily food-delivery orders need trustworthy revenue reporting.
ArchitectureDiagram: raw → bronze → silver (+ quarantine) → gold → report, with tools.
How to runpip install pyarrow then python capstone_pipeline.py; expected output.
Data and qualityRow reconciliation table per day; quality rules and the 2% gate.
DecisionsWhy Parquet, why date partitions, why overwrite, why hashed keys.
ResultsRevenue by city and dish; run time.
Limitations and next stepsSingle machine, no ACID tables, no scheduler: how you would add Spark, Delta, Airflow.
7 Run the code

The whole pipeline in miniature

This browser version uses only plain Python: raw records go through bronze, silver and gold as lists and dictionaries, with deduplication, a quality gate and masking. Then try the challenges: idempotent partitions, a new quality rule, a new gold table, a broken gate and a reconciliation check.

import hashlib
raw = [  # order_id, date, city, qty, price, phone
    ("A1", "10-19", "Pune", 1, 140, "9876543210"), ("A2", "10-19", "Thane", 2, 120, "9123456780"),
    ("A1", "10-19", "Pune", 1, 140, "9876543210"), ("A3", "10-19", None, 1, 220, "9988776655"),
    ("A4", "10-19", "Pune", 0, 60, "9090909090"), ("A5", "10-19", "Thane", 1, 220, "9811122233"),
]
bronze = {"10-19": list(raw)}                       # one partition per date

seen, silver, quarantine = set(), [], []
for oid, day, city, qty, price, phone in bronze["10-19"]:
    if oid in seen:
        continue                                    # duplicate from a retry
    seen.add(oid)
    if city is None or qty <= 0:
        quarantine.append(oid)
        continue
    key = hashlib.sha256(("salt:" + phone).encode()).hexdigest()[:8]
    silver.append((oid, day, city, qty * price, key))

gold = {}
for oid, day, city, amount, key in silver:
    gold[city] = gold.get(city, 0) + amount
print("silver:", len(silver), "| quarantine:", quarantine)
print("gold revenue by city:", gold)
silver: 3 | quarantine: ['A3', 'A4'] gold revenue by city: {'Pune': 140, 'Thane': 460}
  • if oid in seen:Deduplicate on the business key before anything else.
  • key = hashlib.sha256(("salt:" + phone).encode()).hexdigest()[:8]Phones never reach silver; a salted hash keeps customers countable.

Run it on your PC: the full capstone pipeline

Install pyarrow once (pip install pyarrow), save this as capstone_pipeline.py in an empty folder and run it. It creates a lake/ folder with raw, bronze, silver (orders and quarantine) and gold layers for three days, prints a report, then re-runs everything and proves the results are identical. Open the folders to see the partitions.

Raw → bronze → silver → gold, then an idempotency test

"""Capstone: a mini lakehouse pipeline on your PC.
raw JSON lines -> bronze Parquet (partitioned by date) -> silver (clean, dedup,
quality gate, masked PII) -> gold (daily city revenue, top dishes) -> report.
Every step is idempotent: run the script twice and the results are identical.
Needs: pip install pyarrow"""
import hashlib, json, os, random, shutil, time
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.parquet as pq

LAKE = "lake"
DATES = ["2026-10-17", "2026-10-18", "2026-10-19"]
CITIES = ["Pune", "Mumbai", "Thane", "Nashik"]
DISHES = {"Veg thali": 140, "Paneer roll": 120, "Dal khichdi": 110, "Chicken biryani": 220, "Poha": 60}


def inr(n):
    """Indian digit grouping: 1117740 -> 11,17,740"""
    s = str(int(n))
    head, tail = s[:-3], s[-3:]
    groups = []
    while len(head) > 2:
        groups.insert(0, head[-2:])
        head = head[:-2]
    return ",".join(([head] if head else []) + groups + [tail])


def log(step, msg):
    print(f"[{step:7}] {msg}")


def extract(day):
    """Simulate the app's raw export: JSON lines with real-world problems."""
    rnd = random.Random(day)                      # same day -> same data (repeatable)
    path = f"{LAKE}/raw/orders_{day}.jsonl"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    rows = []
    for i in range(rnd.randint(18_000, 22_000)):
        dish = rnd.choice(list(DISHES))
        rows.append({"order_id": f"{day[-5:].replace('-', '')}-{i:06d}", "ts": f"{day}T{rnd.randint(7, 22):02d}:{rnd.randint(0, 59):02d}:00",
                     "city": rnd.choice(CITIES), "dish": dish, "qty": rnd.choice([1, 1, 1, 2, 3]),
                     "price": DISHES[dish], "phone": f"9{rnd.randint(100000000, 999999999)}"})
    for r in rnd.sample(rows, 60):
        r["qty"] = 0                              # invalid quantity
    for r in rnd.sample(rows, 40):
        r["city"] = None                          # missing city
    rows += rnd.sample(rows, 150)                 # retries create duplicates
    with open(path, "w") as f:
        for r in rows:
            f.write(json.dumps(r) + "\n")
    return path, len(rows)


def bronze(day, raw_path):
    """Raw copy as Parquet, one folder per date. Overwrite = idempotent."""
    rows = [json.loads(line) for line in open(raw_path)]
    table = pa.Table.from_pylist(rows)
    out = f"{LAKE}/bronze/orders/date={day}"
    shutil.rmtree(out, ignore_errors=True)
    os.makedirs(out)
    pq.write_table(table, f"{out}/part-0.parquet")
    return table


def silver(day, table):
    """Deduplicate, check quality, mask PII. Stop if too many rows fail."""
    seen, keep = set(), []
    ids = table.column("order_id").to_pylist()
    for i, oid in enumerate(ids):
        if oid not in seen:
            seen.add(oid)
            keep.append(i)
    dedup = table.take(keep)
    dupes = table.num_rows - dedup.num_rows
    good_mask = pc.and_(pc.greater(dedup["qty"], 0), pc.is_valid(dedup["city"]))
    good, bad = dedup.filter(good_mask), dedup.filter(pc.invert(good_mask))
    fail_rate = bad.num_rows / dedup.num_rows
    if fail_rate > 0.02:
        raise RuntimeError(f"quality gate failed for {day}: {fail_rate:.2%}")
    keys = [hashlib.sha256(("salt:" + p).encode()).hexdigest()[:10] for p in good["phone"].to_pylist()]
    good = good.drop(["phone"]).append_column("customer_key", pa.array(keys))
    good = good.append_column("amount", pc.multiply(good["qty"], good["price"]))
    for name, t in [("orders", good), ("quarantine", bad.drop(["phone"]))]:
        out = f"{LAKE}/silver/{name}/date={day}"
        shutil.rmtree(out, ignore_errors=True)
        os.makedirs(out)
        pq.write_table(t, f"{out}/part-0.parquet")
    return good, dupes, bad.num_rows, fail_rate


def gold():
    """Business tables built from all silver partitions."""
    orders = pq.read_table(f"{LAKE}/silver/orders", columns=["date", "city", "dish", "amount", "customer_key"])
    orders = orders.set_column(0, "date", pc.cast(orders["date"], pa.string()))   # partition column -> plain text
    by_city = orders.group_by(["date", "city"]).aggregate([("amount", "sum"), ("amount", "count")])
    by_city = by_city.sort_by([("date", "ascending"), ("amount_sum", "descending")])
    by_dish = orders.group_by("dish").aggregate([("amount", "sum")]).sort_by([("amount_sum", "descending")])
    os.makedirs(f"{LAKE}/gold", exist_ok=True)
    pq.write_table(by_city, f"{LAKE}/gold/daily_city_revenue.parquet")
    pq.write_table(by_dish, f"{LAKE}/gold/dish_revenue.parquet")
    return by_city, by_dish, orders.num_rows


def run():
    start = time.time()
    for day in DATES:
        raw_path, n_raw = extract(day)
        log("extract", f"{day}: {n_raw:,} raw rows")
        t = bronze(day, raw_path)
        good, dupes, bad, rate = silver(day, t)
        log("silver", f"{day}: {good.num_rows:,} clean, {dupes} duplicates removed, {bad} quarantined ({rate:.2%})")
    by_city, by_dish, n = gold()
    log("gold", f"{n:,} orders across {len(DATES)} days -> 2 gold tables")
    return by_city, by_dish, round(time.time() - start, 1)


if __name__ == "__main__":
    shutil.rmtree(LAKE, ignore_errors=True)
    by_city, by_dish, secs = run()
    print("\nRevenue by city, 2026-10-19 (Rs):")
    for r in by_city.to_pylist():
        if r["date"] == "2026-10-19":
            print(f"  {r['city']:7} {inr(r['amount_sum']):>11}  ({inr(r['amount_count'])} orders)")
    print("Top dishes, all 3 days (Rs):")
    for r in by_dish.to_pylist()[:3]:
        print(f"  {r['dish']:16} {inr(r['amount_sum']):>11}")
    first = pq.read_table(f"{LAKE}/gold/dish_revenue.parquet").to_pylist()
    print("\nRe-running the whole pipeline to test idempotency...")
    run()
    second = pq.read_table(f"{LAKE}/gold/dish_revenue.parquet").to_pylist()
    print("Gold identical after re-run:", first == second)
    files = sum(len(fs) for _, _, fs in os.walk(LAKE))
    print(f"Lake folder: {files} files under ./{LAKE}/")

# In a terminal:  pip install pyarrow
#                  python capstone_pipeline.py
[extract] 2026-10-17: 20,005 raw rows [silver ] 2026-10-17: 19,755 clean, 150 duplicates removed, 100 quarantined (0.50%) [extract] 2026-10-18: 18,998 raw rows [silver ] 2026-10-18: 18,748 clean, 150 duplicates removed, 100 quarantined (0.53%) [extract] 2026-10-19: 21,429 raw rows [silver ] 2026-10-19: 21,179 clean, 150 duplicates removed, 100 quarantined (0.47%) [gold ] 59,682 orders across 3 days -> 2 gold tables Revenue by city, 2026-10-19 (Rs): Mumbai 11,17,740 (5,352 orders) Thane 11,05,820 (5,350 orders) Nashik 10,89,740 (5,265 orders) Pune 10,82,230 (5,212 orders) Top dishes, all 3 days (Rs): Chicken biryani 41,99,580 Veg thali 26,64,900 Paneer roll 22,73,160 Re-running the whole pipeline to test idempotency... [extract] 2026-10-17: 20,005 raw rows [silver ] 2026-10-17: 19,755 clean, 150 duplicates removed, 100 quarantined (0.50%) [extract] 2026-10-18: 18,998 raw rows [silver ] 2026-10-18: 18,748 clean, 150 duplicates removed, 100 quarantined (0.53%) [extract] 2026-10-19: 21,429 raw rows [silver ] 2026-10-19: 21,179 clean, 150 duplicates removed, 100 quarantined (0.47%) [gold ] 59,682 orders across 3 days -> 2 gold tables Gold identical after re-run: True Lake folder: 14 files under ./lake/

Recorded from a real run with Python 3.13 and pyarrow 26. Data is generated with a fixed seed per day, so your counts will match. It takes a few seconds on a normal laptop. Delete the lake/ folder any time; the script rebuilds it.

8 Line by line

Silver, one record at a time

Four raw records pass through deduplication and the quality check. Watch the seen set, the clean list and the quarantine fill up.

Worked examples: real problems, solved and traced in memory

Four tasks you will meet while building and reviewing the capstone. Each runs live and is traced.

Example 1BeginnerPartitions · paths

Build Hive-style partition paths

Problem statement

Generate the bronze folder path for each date in a list, in the date=YYYY-MM-DD format engines understand.

Input
3 dates
Expected output
lake/bronze/orders/date=2026-10-17/part-0.parquet
lake/bronze/orders/date=2026-10-18/part-0.parquet
lake/bronze/orders/date=2026-10-19/part-0.parquet

Approach

  1. Loop over dates.
  2. Format the path with an f-string.
  3. Print each path.

Solution: run it live

What happens in memory, step by step

Recorded from a real Python run: press Next to execute one line at a time. Watch variables change in each frame, and lists and dictionaries (numbered #1, #2 …) fill up as objects.

Example 2IntermediateSilver · dedup by latest

Keep the latest version of each order

Problem statement

Sometimes duplicates are updates. Keep the record with the latest timestamp for each order_id.

Input
(order_id, ts, status)
Expected output
{'A1': ('10:20', 'delivered'), 'A2': ('10:05', 'placed')}

Approach

  1. Track the best record per order_id in a dictionary.
  2. Replace it when a later timestamp arrives.
  3. Print the final records.

Solution: run it live

What happens in memory, step by step

Recorded from a real Python run: press Next to execute one line at a time. Watch variables change in each frame, and lists and dictionaries (numbered #1, #2 …) fill up as objects.

Example 3IntermediateGold · aggregation

Daily revenue and order count by city

Problem statement

From silver rows, build a gold table of (date, city) → revenue and number of orders, sorted by revenue.

Input
(date, city, amount) rows
Expected output
('10-19', 'Pune') revenue 360 orders 2
('10-19', 'Thane') revenue 300 orders 2

Approach

  1. Use a dictionary keyed by (date, city).
  2. Add revenue and increment the count.
  3. Sort by revenue, highest first.

Solution: run it live

What happens in memory, step by step

Recorded from a real Python run: press Next to execute one line at a time. Watch variables change in each frame, and lists and dictionaries (numbered #1, #2 …) fill up as objects.

Example 4AdvancedReliability · idempotency test

Prove a step is idempotent

Problem statement

Write a test that runs a step twice on the same input and checks that the stored result is identical, for both an append step and an overwrite step.

Input
a day of rows, two write functions
Expected output
write_append idempotent: False
write_overwrite idempotent: True

Approach

  1. Write two versions of the step: append and overwrite.
  2. Run each twice into a fresh store.
  3. Compare the store after one run and after two.

Solution: run it live

What happens in memory, step by step

Recorded from a real Python run: press Next to execute one line at a time. Watch variables change in each frame, and lists and dictionaries (numbered #1, #2 …) fill up as objects.

9 Quiz and revision

Check yourself

Quick revision

Lesson 7.1 in one screen

  • Raw (unchanged) → bronze (Parquet by date) → silver (dedup, quality gate, quarantine, masked PII) → gold (business tables).
  • Hive-style partitions: lake/bronze/orders/date=YYYY-MM-DD/ enable pruning and per-day rewrites.
  • Idempotency: overwrite the date partition (or MERGE); never blind append. Prove it by re-running.
  • Reconcile: raw = clean + duplicates + quarantined, for every day.
  • Quality gate at 2%: continue and alert below it, stop above it.
  • Production map: Kafka/exports, Spark, Delta/Iceberg, Airflow, monitoring, object storage.
  • README: problem, diagram, how to run, reconciliation, decisions, results, limitations.

Interview and viva questions

What is a good capstone project for big data or data engineering?

An end-to-end pipeline that ingests realistic messy data, stores it in layers (bronze, silver, gold) using a columnar format partitioned by date, applies deduplication and data quality checks with quarantine, protects personal data, produces business tables, and is idempotent and reproducible. It should run with one command and have a README with architecture, results and decisions.

Can I build a big data pipeline without a cluster?

Yes. The architecture matters more than the size: Python with pyarrow, DuckDB or Polars can process millions of rows on a laptop using the same layers, partitions and quality rules. You can later run the same design on Spark and cloud storage, and explain how it would scale in interviews.

What is the medallion architecture?

A layered lakehouse design: bronze holds raw data in a better storage format, silver holds cleaned, deduplicated and conformed data, and gold holds aggregated, business-ready tables. Each layer is rebuilt from the previous one, which makes debugging and reprocessing straightforward.

How do I make a data pipeline idempotent?

Design each step so that re-running it for the same input gives the same result: overwrite the output partition for the processed date, or use MERGE/upsert on a business key, deduplicate inputs, and avoid side effects such as blind appends or sending duplicate notifications. Test it by running the step twice and comparing outputs.

What should a data engineering project README include?

The problem, an architecture diagram, exact steps to run, the data sources and quality rules, a row reconciliation or validation summary, key design decisions and trade-offs, the results, and limitations with next steps. Add screenshots or sample output and a test or CI badge if available.

Practice problems

Small exercises that extend the capstone. The last two are business-style reviews.

P1Easy

Partition path

Print the silver path for date 2026-10-20.

Input
2026-10-20
Expected output
lake/silver/orders/date=2026-10-20/part-0.parquet
Show solution and explanation
print("lake/silver/orders/date=2026-10-20/part-0.parquet")
lake/silver/orders/date=2026-10-20/part-0.parquet

How it works: Consistent paths make partitions easy to read and overwrite.

P2Easy

Count duplicates

Print how many duplicates are in ["A", "B", "A", "C", "A"].

Input
IDs
Expected output
2
Show solution and explanation
ids = ["A", "B", "A", "C", "A"]
print(len(ids) - len(set(ids)))
2

How it works: Total minus unique = duplicates.

P3Easy

Order amount

qty 3, price 140. Print the amount.

Input
3 × 140
Expected output
420
Show solution and explanation
print(3 * 140)
420

How it works: Derived columns such as amount are computed in silver.

P4Medium

Failure rate

18,898 rows after dedup, 100 bad. Print the failure rate to two decimals and the gate decision at 2%.

Input
100 / 18,898
Expected output
0.53% continue
Show solution and explanation
r = 100 / 18_898
print(f"{r:.2%}", "continue" if r <= 0.02 else "stop")
0.53% continue

How it works: The gate protects gold from bad batches.

P5Medium

Reconcile three days

Raw [20005, 18998, 21429], 150 dupes and 100 bad per day. Print total clean rows.

Input
as given
Expected output
59682
Show solution and explanation
print(sum(r - 150 - 100 for r in [20_005, 18_998, 21_429]))
59682

How it works: Matches the 59,682 orders in gold.

P6Advanced

Indian number format

Print 1117740 with Indian digit grouping (11,17,740).

Input
1117740
Expected output
11,17,740
Show solution and explanation
s = str(1117740)
head, tail = s[:-3], s[-3:]
parts = []
while len(head) > 2:
    parts.insert(0, head[-2:]); head = head[:-2]
print(",".join(([head] if head else []) + parts + [tail]))
11,17,740

How it works: Lakh/crore grouping: last three digits, then pairs.

P7Advanced

Latest status wins

From [("A1","10:00","placed"),("A1","10:30","delivered")], print the latest status.

Input
2 versions
Expected output
delivered
Show solution and explanation
rows = [("A1", "10:00", "placed"), ("A1", "10:30", "delivered")]
print(max(rows, key=lambda r: r[1])[2])
delivered

How it works: Upserts keep the most recent version of each key.

P8Business problem

Revenue share

City revenue: Mumbai 11,17,740, Thane 11,05,820, Nashik 10,89,740, Pune 10,82,230. Print Mumbai’s share.

Input
4 cities
Expected output
25.4%
Show solution and explanation
rev = {"Mumbai": 1117740, "Thane": 1105820, "Nashik": 1089740, "Pune": 1082230}
print(f"{rev['Mumbai'] / sum(rev.values()):.1%}")
25.4%

How it works: Gold tables answer business questions like this instantly.

P9Business problem

Storage estimate

In the capstone run, one day (about 20,000 rows) is about 2.9 MB as raw JSON and 0.4 MB as bronze Parquet. Print a year of each in MB and the compression ratio.

Input
2.9 MB vs 0.4 MB per day
Expected output
raw 1058 MB, bronze 146 MB, 7.2x smaller
Show solution and explanation
raw, parquet = 2.9, 0.4
print(f"raw {raw * 365:.0f} MB, bronze {parquet * 365:.0f} MB, {raw / parquet:.1f}x smaller")
raw 1058 MB, bronze 146 MB, 7.2x smaller

How it works: Measured file sizes from your own run make the best README evidence for choosing Parquet.

Real-world scenario questions (asked in company interviews)

Questions reviewers and interviewers ask about capstone projects. Decide your answer first, then open the model answer.

Capstone reviewFresher

“Walk me through your pipeline.”

A reviewer opens your capstone repository and asks for a two-minute walkthrough.

The interviewer is checking: can you explain design and data flow clearly?

Show a strong answer

Problem → layers → numbers → decisions: “Three days of orders land as JSON in raw. Bronze stores them as Parquet partitioned by date. Silver removes 150 duplicates a day by order_id, quarantines about 0.5% invalid rows behind a 2% gate and replaces phones with hashed keys. Gold has revenue by city and dish. Every step overwrites its date partition, so re-runs are identical; the script proves it by re-running. Next I’d move to Delta Lake and Airflow.”

Avoid: reading the code line by line.

Reliability1–3 years

“What happens if the silver step fails halfway?”

The reviewer asks about partial failures.

The interviewer is checking: do you understand atomicity and recovery?

Show a strong answer

In the capstone, a failed write may leave a partly written folder, but the next run deletes and rewrites that date, so it self-heals. In production, write to a temporary location and move atomically, or use Delta Lake or Iceberg, where a commit is atomic: readers see the old version until the new one is complete. Orchestrator retries are safe because the step is idempotent.

Avoid: saying “it won’t fail”.

Scale1–3 years

“What if each day had 50 crore rows?”

The reviewer asks how the design changes at 25,000 times the volume.

The interviewer is checking: can you separate design from engine?

Show a strong answer

The layers, partitioning, quality gate and idempotent writes stay. Changes: run on Spark with many partitions instead of one Python process; dedup with dropDuplicates or MERGE; use Delta or Iceberg tables; partition by date (and maybe city) with file compaction; read silver incrementally for gold; store in object storage; add monitoring and cost controls.

Avoid: proposing a different architecture for every tool.

PrivacyFresher

“Why hash phone numbers instead of deleting them?”

The reviewer asks about the customer_key column.

The interviewer is checking: can you explain pseudonymisation and its limits?

Show a strong answer

Analysts need to count distinct and repeat customers without seeing phone numbers. A salted hash gives the same key for the same phone, so joins and counts work. It is pseudonymisation, not anonymisation: with the salt someone could re-identify customers, so the salt must be secret (a secrets manager, not the code) and access to bronze, which still has phones, must be restricted.

Avoid: claiming hashing makes the data anonymous.