Data Engineering
ProjectIntermediate 60 min· Data for AI and your career · 17 of 18

Project: build an end-to-end data pipeline

Build a Brew & Bean batch pipeline in Python and DuckDB: ingest raw JSON orders, clean and reject bad rows, run quality checks, publish a sales table and report. It is re-runnable, tested and ready to show in interviews.

In this project you will build a complete, small data pipeline for Brew & Bean, the Bengaluru coffee shop used across Tech Tracks. Raw order files arrive every day. Your pipeline will ingest them, clean them, reject bad orders with reasons, check data quality, publish a sales table and print a report. You can re-run any day safely.

The finished result: three days of orders flow through four stages (ingest, transform, check, publish) into a DuckDB database, and python report.py prints revenue per day, best sellers and every rejected order.

Prerequisites: Python 3.10+, SQL basics, and the ideas from *ETL vs ELT and pipeline design*, *Batch pipelines with Python* and *Data quality, testing and observability*. Everything runs on your laptop for free. DuckDB is an embedded analytical database, so there is nothing to install except a Python package.

The design

Four stages, one table family each

StageWhat it doesTable it writes
IngestCopy the raw JSON lines into the database untouchedraw_orders
TransformParse JSON, drop duplicates, reject invalid orders, price each linefct_order_lines, rejects
CheckRun quality checks on the result; stop if they fail(reads only)
PublishBuild the reporting table, only if checks passeddaily_sales

Three design rules from earlier lessons are used here:

- Keep the raw data. raw_orders stores each line as received. If your transform has a bug, you fix it and re-run without asking the source again. This is the ELT idea. - Idempotent loads. Every stage first deletes its own rows for the day, then inserts. Running the same day twice gives the same result, never double counts. - Write, Audit, Publish. The report table is only updated after the checks pass.

Stage 0: set up the project

  1. 1Create a folder called bb-pipeline and open a terminal inside it.
  2. 2Create and activate a virtual environment: python -m venv .venv, then activate it (source .venv/bin/activate on macOS and Linux, or .venv\Scripts\activate in Windows).
  3. 3Install the packages: pip install duckdb pytest.
  4. 4Create the four Python files from the next sections in the folder. All commands in this lesson are run from bb-pipeline, because the scripts use relative paths like data/raw.
Prompt · Ask AI to plan the project
I am building a small batch data pipeline in Python and DuckDB for a coffee shop. Input: one JSON-lines file per day, each line an order with id, customer_name, items (a list of item_id and qty), status and created_at. Output: a clean order-lines table, a rejects table with reasons, and a daily sales table.

Do not write code yet. List the stages, the tables with their columns, the data quality problems I should expect in the raw files, and how to make each stage safe to re-run.

What to expect: Check that the plan keeps raw data untouched, has a rejects table, and deletes by day before inserting. If it suggests Spark or Kafka for this volume, ask why; it is not needed.

Stage 1: sample data

Real raw data is messy, so our generator plants three bad orders in every day's file: a duplicate order id, an item that is not on the menu, and a negative quantity. Your pipeline must catch all three.

generate_data.py
import json
import random
from datetime import datetime, timedelta
from pathlib import Path

MENU = {
    "espresso": 120, "cappuccino": 180, "cold-brew": 220, "filter-coffee": 80,
    "masala-chai": 90, "croissant": 150, "banana-bread": 130, "veg-sandwich": 160,
}
OUT = Path("data/raw")


def make_day(day: str, seed: int, n: int = 40) -> None:
    rng = random.Random(seed)
    prefix = day.replace("-", "")
    start = datetime.fromisoformat(day + "T08:00:00")
    rows = []
    for i in range(1, n + 1):
        items = [
            {"item_id": item, "qty": rng.randint(1, 3)}
            for item in rng.sample(list(MENU), rng.randint(1, 3))
        ]
        rows.append({
            "id": f"{prefix}-{i:03d}",
            "customer_name": rng.choice(["Asha", "Ravi", "Meera", "Imran", "Kavya"]),
            "items": items,
            "status": rng.choice(["picked_up"] * 8 + ["cancelled"]),
            "created_at": (start + timedelta(minutes=rng.randint(0, 14 * 60))).isoformat(),
        })
    # Three deliberately bad rows, so the pipeline has something to catch.
    rows.append(dict(rows[0]))  # duplicate order id
    rows.append({**rows[1], "id": f"{prefix}-900",
                 "items": [{"item_id": "pizza", "qty": 1}]})  # item not on the menu
    rows.append({**rows[2], "id": f"{prefix}-901",
                 "items": [{"item_id": "espresso", "qty": -2}]})  # negative quantity
    OUT.mkdir(parents=True, exist_ok=True)
    with open(OUT / f"orders_{day}.jsonl", "w", encoding="utf-8") as f:
        for r in rows:
            f.write(json.dumps(r) + "\n")


if __name__ == "__main__":
    for k, day in enumerate(["2026-10-01", "2026-10-02", "2026-10-03"]):
        make_day(day, seed=k)
    with open("data/menu.json", "w", encoding="utf-8") as f:
        json.dump(MENU, f)
    print("Wrote 3 days of raw orders to data/raw/")

Writes data/raw/orders_2026-10-01.jsonl (and 02, 03) plus data/menu.json. Each file has 43 lines: 40 normal orders and 3 bad ones. The random generator is seeded, so you get the same data every time.

Terminal
python generate_data.py
Output
Wrote 3 days of raw orders to data/raw/

Open data/raw/orders_2026-10-01.jsonl in an editor. Each line looks like this (shortened): {"id": "20261001-001", "customer_name": "Ravi", "items": [{"item_id": "cold-brew", "qty": 2}], "status": "picked_up", "created_at": "2026-10-01T11:42:00"}.

Stage 2: the pipeline

Now the main file. Read the code once, then use the explanation below to understand each stage.

pipeline.py
import json
import sys
from pathlib import Path

import duckdb

DB = "warehouse.duckdb"
RAW_DIR = Path("data/raw")
MAX_REJECT_RATE = 0.10


def ingest(con, day: str) -> int:
    """Stage 1: load one day of raw JSON lines, exactly as received."""
    con.execute("""
        CREATE TABLE IF NOT EXISTS raw_orders (
            load_day VARCHAR, line_no INTEGER, payload VARCHAR
        )""")
    con.execute("DELETE FROM raw_orders WHERE load_day = ?", [day])  # makes re-runs safe
    path = RAW_DIR / f"orders_{day}.jsonl"
    with open(path, encoding="utf-8") as f:
        rows = [(day, n, line.strip()) for n, line in enumerate(f, 1) if line.strip()]
    con.executemany("INSERT INTO raw_orders VALUES (?, ?, ?)", rows)
    return len(rows)


def load_menu(con) -> None:
    with open("data/menu.json", encoding="utf-8") as f:
        menu = json.load(f)
    con.execute("CREATE OR REPLACE TABLE dim_menu (item_id VARCHAR, price INTEGER)")
    con.executemany("INSERT INTO dim_menu VALUES (?, ?)", list(menu.items()))


def transform(con, day: str) -> None:
    """Stage 2: parse the raw JSON into clean tables. Bad orders go to rejects."""
    con.execute("""
        CREATE TABLE IF NOT EXISTS fct_order_lines (
            load_day VARCHAR, order_id VARCHAR, customer_name VARCHAR, status VARCHAR,
            created_at TIMESTAMP, item_id VARCHAR, qty INTEGER, price INTEGER, line_total INTEGER
        )""")
    con.execute("""
        CREATE TABLE IF NOT EXISTS rejects (
            load_day VARCHAR, order_id VARCHAR, reason VARCHAR
        )""")
    con.execute("DELETE FROM fct_order_lines WHERE load_day = ?", [day])
    con.execute("DELETE FROM rejects WHERE load_day = ?", [day])

    # One row per raw line, with the JSON fields pulled out.
    con.execute("""
        CREATE OR REPLACE TEMP TABLE parsed AS
        SELECT
            load_day,
            payload::JSON ->> '$.id'            AS order_id,
            payload::JSON ->> '$.customer_name' AS customer_name,
            payload::JSON ->> '$.status'        AS status,
            CAST(payload::JSON ->> '$.created_at' AS TIMESTAMP) AS created_at,
            from_json(payload::JSON -> '$.items',
                      '[{"item_id": "VARCHAR", "qty": "INTEGER"}]') AS items,
            row_number() OVER (
                PARTITION BY payload::JSON ->> '$.id' ORDER BY line_no
            ) AS seen_nth
        FROM raw_orders
        WHERE load_day = ?""", [day])

    # One row per item in each first-seen order.
    con.execute("""
        CREATE OR REPLACE TEMP TABLE lines AS
        SELECT load_day, order_id, customer_name, status, created_at,
               item.item_id AS item_id, item.qty AS qty
        FROM (SELECT *, unnest(items) AS item FROM parsed WHERE seen_nth = 1)""")

    con.execute("""
        INSERT INTO rejects
        SELECT load_day, order_id, 'duplicate order id' FROM parsed WHERE seen_nth > 1""")
    con.execute("""
        INSERT INTO rejects
        SELECT DISTINCT load_day, order_id, 'unknown item_id' FROM lines
        WHERE item_id NOT IN (SELECT item_id FROM dim_menu)""")
    con.execute("""
        INSERT INTO rejects
        SELECT DISTINCT load_day, order_id, 'qty must be positive' FROM lines
        WHERE qty <= 0""")

    # A single bad line rejects the whole order.
    con.execute("""
        INSERT INTO fct_order_lines
        SELECT l.load_day, l.order_id, l.customer_name, l.status, l.created_at,
               l.item_id, l.qty, m.price, l.qty * m.price
        FROM lines l
        JOIN dim_menu m USING (item_id)
        WHERE l.order_id NOT IN (SELECT order_id FROM rejects WHERE load_day = ?)""", [day])


def check(con, day: str) -> list[str]:
    """Stage 3: return a list of problems. An empty list means the data is healthy."""
    problems = []
    n = con.execute(
        "SELECT count(*) FROM fct_order_lines WHERE load_day = ?", [day]).fetchone()[0]
    if n == 0:
        problems.append("no rows loaded for the day")
    dup = con.execute("""
        SELECT count(*) FROM (
            SELECT order_id, item_id FROM fct_order_lines WHERE load_day = ?
            GROUP BY order_id, item_id HAVING count(*) > 1)""", [day]).fetchone()[0]
    if dup:
        problems.append(f"{dup} order lines repeat the same item within an order")
    bad = con.execute(
        "SELECT count(*) FROM fct_order_lines WHERE load_day = ? AND line_total <= 0",
        [day]).fetchone()[0]
    if bad:
        problems.append(f"{bad} lines with a non-positive total")
    total = con.execute(
        "SELECT count(*) FROM raw_orders WHERE load_day = ?", [day]).fetchone()[0]
    rejected = con.execute(
        "SELECT count(*) FROM rejects WHERE load_day = ?", [day]).fetchone()[0]
    if total and rejected / total > MAX_REJECT_RATE:
        problems.append(f"reject rate too high: {rejected} of {total} orders")
    return problems


def publish(con, day: str) -> None:
    """Stage 4: build the reporting table. Only runs after checks pass."""
    con.execute("""
        CREATE TABLE IF NOT EXISTS daily_sales (
            load_day VARCHAR, item_id VARCHAR, units INTEGER, revenue INTEGER
        )""")
    con.execute("DELETE FROM daily_sales WHERE load_day = ?", [day])
    con.execute("""
        INSERT INTO daily_sales
        SELECT load_day, item_id, sum(qty), sum(line_total)
        FROM fct_order_lines
        WHERE load_day = ? AND status = 'picked_up'
        GROUP BY load_day, item_id""", [day])


def run(day: str) -> None:
    con = duckdb.connect(DB)
    loaded = ingest(con, day)
    load_menu(con)
    transform(con, day)
    problems = check(con, day)
    if problems:
        print(f"[{day}] FAILED checks, not publishing: {problems}")
        sys.exit(1)
    publish(con, day)
    rejected = con.execute(
        "SELECT count(*) FROM rejects WHERE load_day = ?", [day]).fetchone()[0]
    print(f"[{day}] ok: {loaded} raw lines, {rejected} rejected, report updated")


if __name__ == "__main__":
    run(sys.argv[1])

The whole pipeline. Run one day with python pipeline.py 2026-10-01.

How each function works

ingest creates raw_orders if needed, deletes any rows for the given day, then inserts every line as plain text. Storing the JSON as text means a malformed field cannot break the load.

transform parses the JSON using DuckDB's JSON functions. row_number() OVER (PARTITION BY id ORDER BY line_no) numbers repeated ids, so seen_nth = 1 is the first copy and anything higher is a duplicate. unnest(items) turns the list of items inside one order into one row per item. Rejects are inserted with a reason, and any order with a rejected id is kept out of fct_order_lines. Prices come from joining dim_menu, and the price is stored on the line, so a future menu price change does not rewrite history.

check returns a list of problems. It fails if nothing was loaded, if the same item repeats in one order, if any total is not positive, or if more than 10 percent of the lines were rejected. A few rejects are expected; a flood means something upstream broke.

publish builds daily_sales, counting only picked_up orders as revenue. run ties the stages together and exits with code 1 if checks fail, which is how a scheduler knows to stop.

SQL dialects differ

The JSON functions (->>, from_json, unnest) are DuckDB features. If you later move this to another warehouse such as Postgres, BigQuery or Snowflake, the same ideas apply but the syntax differs. Keep your SQL in one place (like transform) to make that easy.

Stage 3: run it for three days

Terminal
python pipeline.py 2026-10-01
python pipeline.py 2026-10-02
python pipeline.py 2026-10-03
Expected output
[2026-10-01] ok: 43 raw lines, 3 rejected, report updated
[2026-10-02] ok: 43 raw lines, 3 rejected, report updated
[2026-10-03] ok: 43 raw lines, 3 rejected, report updated

Now prove idempotency. Run python pipeline.py 2026-10-01 a second time. You should see the same message, and the numbers in the report below must not change. If revenue doubled, a delete-before-insert step is missing.

Stage 4: the report

report.py
import duckdb

con = duckdb.connect("warehouse.duckdb", read_only=True)

print("Revenue by day (picked-up orders only)")
for day, revenue in con.execute(
    "SELECT load_day, sum(revenue) FROM daily_sales GROUP BY load_day ORDER BY load_day"
).fetchall():
    print(f"  {day}  Rs {revenue}")

print("\nBest sellers, all days")
for item, units in con.execute(
    "SELECT item_id, sum(units) AS u FROM daily_sales GROUP BY item_id ORDER BY u DESC LIMIT 3"
).fetchall():
    print(f"  {item:<14} {units} units")

print("\nRejected orders")
for day, order_id, reason in con.execute(
    "SELECT load_day, order_id, reason FROM rejects ORDER BY load_day, order_id"
).fetchall():
    print(f"  {day}  {order_id}  {reason}")

Reads the published tables and the rejects, read-only.

Terminal
python report.py
Expected output (your totals may differ slightly if your Python version's random generator differs; the rejected orders and the shape must match)
Revenue by day (picked-up orders only)
  2026-10-01  Rs 16340
  2026-10-02  Rs 16080
  2026-10-03  Rs 16040

Best sellers, all days
  espresso       49 units
  cold-brew      48 units
  masala-chai    47 units

Rejected orders
  2026-10-01  20261001-001  duplicate order id
  2026-10-01  20261001-900  unknown item_id
  2026-10-01  20261001-901  qty must be positive
  ...

Stage 5: tests

A pipeline without tests is a pipeline you are afraid to change. These tests use an in-memory database, so they do not touch warehouse.duckdb.

test_pipeline.py
import duckdb
import pytest

import pipeline


@pytest.fixture
def con():
    c = duckdb.connect(":memory:")
    pipeline.ingest(c, "2026-10-01")
    pipeline.load_menu(c)
    pipeline.transform(c, "2026-10-01")
    return c


def test_bad_orders_are_rejected_with_reasons(con):
    reasons = {r[0] for r in con.execute("SELECT reason FROM rejects").fetchall()}
    assert reasons == {"duplicate order id", "unknown item_id", "qty must be positive"}


def test_clean_table_has_no_bad_rows(con):
    bad = con.execute(
        "SELECT count(*) FROM fct_order_lines WHERE qty <= 0 OR price IS NULL").fetchone()[0]
    assert bad == 0


def test_checks_pass_on_good_day(con):
    assert pipeline.check(con, "2026-10-01") == []


def test_rerun_does_not_duplicate(con):
    before = con.execute("SELECT count(*) FROM fct_order_lines").fetchone()[0]
    pipeline.ingest(con, "2026-10-01")
    pipeline.transform(con, "2026-10-01")
    after = con.execute("SELECT count(*) FROM fct_order_lines").fetchone()[0]
    assert before == after

Four tests: reasons for rejects, clean table has no bad rows, checks pass on a good day, a re-run does not duplicate rows.

Terminal
pytest -q
Output
....                                                                     [100%]
4 passed in 0.4s

Stage 6: break it on purpose

Good engineers test the failure path too. Make the data bad enough that checks must stop the pipeline.

Trigger a failed run

  1. 1Open data/raw/orders_2026-10-03.jsonl and change the qty of every order in the first 10 lines to 0. Or simply delete 30 lines from the file.
  2. 2Run python pipeline.py 2026-10-03.
  3. 3The reject rate check fires, the script prints FAILED checks, not publishing, exits with code 1 and leaves daily_sales as it was for that day. This is Write, Audit, Publish working.
  4. 4Regenerate the file with python generate_data.py and re-run. The day is healthy again.

Making it production-ready

From laptop project to production

Gap in this projectWhat a production version does
You run each day by handAirflow, cron or a cloud scheduler runs it daily and retries on failure. See *Orchestration with Apache Airflow*
SQL lives in Python stringsdbt models with tests and docs. See *Transformations with dbt*
Local DuckDB fileA warehouse or lakehouse such as BigQuery, Snowflake or Databricks. See *Cloud data platforms*
Files come from a scriptFiles land in object storage, or events arrive through Kafka. See *Streaming with Apache Kafka*
Failure prints to the terminalAlerts to Slack or email with the run date and the failed check
No history of runsA pipeline_runs table with day, status, row counts and duration

How to present this in an interview

Put the project on GitHub with a short README: the problem, an architecture picture of the four stages, how to run it and a sample of the report. In an interview, walk through your decisions rather than your code: why you keep the raw table, how you made it idempotent, what you reject and why, and what you would change at 100 times the volume (a scheduler, object storage, Spark or a warehouse). Say honestly that the data is synthetic. Interviewers value a small project you understand deeply over a big one you copied.

Prompt · Review my pipeline like a senior engineer
You are a senior data engineer doing a code review. Review the pipeline below for correctness, idempotency, failure handling and data quality gaps. List the 5 most important problems, ordered by risk, and for each give a concrete fix.

<code>
[paste pipeline.py]
</code>

What to expect: Expect points such as: no transaction around delete and insert, no alert, no schema check on raw JSON, and the reject threshold being a constant. Decide yourself which are worth fixing; do not apply every suggestion blindly.

Try it: Stretch 1: wrap it in a transaction

Right now ingest deletes and inserts in two steps. If the script crashes between them, the day is empty. Use con.execute("BEGIN") and con.execute("COMMIT") (or ROLLBACK on error) so the delete and insert succeed or fail together. Add a test that proves it.

Try it: Stretch 2: add a run log and a freshness check

Create a pipeline_runs table (load_day, status, raw_lines, rejected, finished_at) and write one row at the end of every run, including failures. Then write freshness.py that exits 1 if any of the last three days has no successful run.

Try it: Stretch 3: schedule it

Run the pipeline for the next day automatically. Either write an Airflow DAG with one task per stage, or a cron entry or Windows Task Scheduler job that calls python pipeline.py with yesterday's date. Add a retry and an alert on failure.

Try it: Stretch 4: add a second source

Brew & Bean also gets payments from a UPI app. Invent a small payments_2026-10-01.csv with order_id and amount. Ingest it, and add a consistency check that every order's total equals its payment amount. Report mismatches in rejects-style table.

Project checklist

  1. Raw data is stored untouched
  2. Each stage deletes its day's rows before inserting
  3. Running a day twice gives identical results
  4. Bad rows land in rejects with a reason
  5. Reporting tables update only when checks pass
  6. Tests cover the happy path, a bad row and a re-run
  7. The README explains how to run it in under five minutes

Key takeaways

  1. Keep raw data untouched so you can fix and replay the transform.
  2. Make every stage idempotent: delete the day's rows, then insert.
  3. Reject bad rows with a reason instead of silently dropping them, and alert when the reject rate is high.
  4. Write, Audit, Publish: only update reporting tables after checks pass.
  5. Test the failure path as well as the happy path.
  6. A small, understood project with tests and a clear README is a strong portfolio piece.

Check yourself

Finishing the quiz marks the lesson as done.

1. Why does every stage in this project delete its rows for the day before inserting?

2. Why is the raw JSON stored as text in raw_orders before parsing?

3. The reject rate check fails for a day. What should the pipeline do?

4. Why is the price stored on each order line instead of looked up from the menu when reporting?

5. Which change moves this project closest to production?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free