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
| Stage | What it does | Table it writes |
|---|---|---|
| Ingest | Copy the raw JSON lines into the database untouched | raw_orders |
| Transform | Parse JSON, drop duplicates, reject invalid orders, price each line | fct_order_lines, rejects |
| Check | Run quality checks on the result; stop if they fail | (reads only) |
| Publish | Build the reporting table, only if checks passed | daily_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
- 1Create a folder called
bb-pipelineand open a terminal inside it. - 2Create and activate a virtual environment:
python -m venv .venv, then activate it (source .venv/bin/activateon macOS and Linux, or.venv\Scripts\activatein Windows). - 3Install the packages:
pip install duckdb pytest. - 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 likedata/raw.
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.
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.
python generate_data.pyWrote 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.
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
python pipeline.py 2026-10-01
python pipeline.py 2026-10-02
python pipeline.py 2026-10-03[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 updatedNow 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
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.
python report.pyRevenue 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.
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 == afterFour tests: reasons for rejects, clean table has no bad rows, checks pass on a good day, a re-run does not duplicate rows.
pytest -q.... [100%]
4 passed in 0.4sStage 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
- 1Open
data/raw/orders_2026-10-03.jsonland change theqtyof every order in the first 10 lines to0. Or simply delete 30 lines from the file. - 2Run
python pipeline.py 2026-10-03. - 3The reject rate check fires, the script prints
FAILED checks, not publishing, exits with code 1 and leavesdaily_salesas it was for that day. This is Write, Audit, Publish working. - 4Regenerate the file with
python generate_data.pyand re-run. The day is healthy again.
Making it production-ready
From laptop project to production
| Gap in this project | What a production version does |
|---|---|
| You run each day by hand | Airflow, cron or a cloud scheduler runs it daily and retries on failure. See *Orchestration with Apache Airflow* |
| SQL lives in Python strings | dbt models with tests and docs. See *Transformations with dbt* |
| Local DuckDB file | A warehouse or lakehouse such as BigQuery, Snowflake or Databricks. See *Cloud data platforms* |
| Files come from a script | Files land in object storage, or events arrive through Kafka. See *Streaming with Apache Kafka* |
| Failure prints to the terminal | Alerts to Slack or email with the run date and the failed check |
| No history of runs | A 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.
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
- Raw data is stored untouched
- Each stage deletes its day's rows before inserting
- Running a day twice gives identical results
- Bad rows land in rejects with a reason
- Reporting tables update only when checks pass
- Tests cover the happy path, a bad row and a re-run
- The README explains how to run it in under five minutes
Key takeaways
- Keep raw data untouched so you can fix and replay the transform.
- Make every stage idempotent: delete the day's rows, then insert.
- Reject bad rows with a reason instead of silently dropping them, and alert when the reject rate is high.
- Write, Audit, Publish: only update reporting tables after checks pass.
- Test the failure path as well as the happy path.
- 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.