10 · Project — Incremental, Validated Lake Pipeline¶
This capstone for Level 2 ties together the last nine modules into a single pipeline: it loads only what changed since the last run, validates every batch before it lands, writes a partitioned Parquet Silver layer, and records enough state to be safely re-run or backfilled.
What actually ran
This pipeline was reasoned through step by step against the real
sqlite3, pandas, and pyarrow APIs but not executed in a live
interpreter for this lesson — the DataFrame shapes and file layouts shown
match documented behavior precisely.
The scenario¶
A orders source table gets inserts and updates. We need a pipeline that:
- Pulls only rows changed since the last watermark (Module 01).
- Validates the batch — required columns, no negative amounts, no
duplicate
order_id(Module 02). - Writes to a date-partitioned Bronze zone, then a deduplicated Silver zone (Module 03).
- Is idempotent — re-running the same batch twice must not double-count anything (Module 06).
- Logs a run record so failures are visible (Module 08).
Step 1 — source and state setup¶
import sqlite3
import pandas as pd
from pathlib import Path
conn = sqlite3.connect(":memory:")
conn.execute("""
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer TEXT,
amount REAL,
updated_at TEXT
)
""")
conn.executemany(
"INSERT INTO orders VALUES (?, ?, ?, ?)",
[
(1, "alice", 120.50, "2026-08-01T09:00:00"),
(2, "bob", 89.00, "2026-08-01T09:05:00"),
(3, "carla", 45.25, "2026-08-01T10:00:00"),
],
)
conn.commit()
STATE_FILE = Path("pipeline_state.txt")
STATE_FILE.write_text("1970-01-01T00:00:00") # first run: pull everything
Step 2 — extract incrementally¶
def read_watermark() -> str:
return STATE_FILE.read_text().strip()
def extract(conn, watermark: str) -> pd.DataFrame:
return pd.read_sql(
"SELECT * FROM orders WHERE updated_at > ? ORDER BY updated_at",
conn,
params=(watermark,),
)
batch = extract(conn, read_watermark())
print(batch)
order_id customer amount updated_at
0 1 alice 120.50 2026-08-01T09:00:00
1 2 bob 89.00 2026-08-01T09:05:00
2 3 carla 45.25 2026-08-01T10:00:00
Step 3 — validate before it lands¶
class ValidationError(Exception):
pass
def validate(df: pd.DataFrame) -> pd.DataFrame:
required = {"order_id", "customer", "amount", "updated_at"}
missing = required - set(df.columns)
if missing:
raise ValidationError(f"missing columns: {missing}")
if df["order_id"].duplicated().any():
raise ValidationError("duplicate order_id in batch")
bad_amount = df[df["amount"] < 0]
if not bad_amount.empty:
raise ValidationError(f"negative amounts: {bad_amount['order_id'].tolist()}")
null_required = df[required.intersection(df.columns)].isna().any()
if null_required.any():
raise ValidationError(f"nulls in required columns: {null_required[null_required].index.tolist()}")
return df
validated = validate(batch)
print("Validation passed:", len(validated), "rows")
A failed validation raises before a single byte is written — nothing bad lands in Bronze or Silver.
Step 4 — write partitioned Bronze, then upsert into Silver¶
def write_bronze(df: pd.DataFrame, run_date: str, base="lake/bronze/orders"):
out_dir = Path(base) / f"dt={run_date}"
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / "part-0001.parquet"
df.to_parquet(path, index=False)
return path
def upsert_silver(df: pd.DataFrame, base="lake/silver/orders.parquet"):
path = Path(base)
if path.exists():
existing = pd.read_parquet(path)
combined = pd.concat([existing, df])
else:
combined = df
combined = (
combined.sort_values("updated_at")
.drop_duplicates(subset="order_id", keep="last")
.sort_values("order_id")
.reset_index(drop=True)
)
combined.to_parquet(path, index=False)
return combined
bronze_path = write_bronze(validated, "2026-08-01")
silver = upsert_silver(validated)
print(bronze_path)
print(silver)
lake/bronze/orders/dt=2026-08-01/part-0001.parquet
order_id customer amount updated_at
0 1 alice 120.50 2026-08-01T09:00:00
1 2 bob 89.00 2026-08-01T09:05:00
2 3 carla 45.25 2026-08-01T10:00:00
drop_duplicates(subset="order_id", keep="last") after sorting by
updated_at is what makes this an upsert and gives idempotency: running the
exact same batch twice produces the exact same Silver table, because the
"last" row for a given order_id is always the same one regardless of how
many times it appears in the concatenation.
Step 5 — persist watermark and log the run only after success¶
import datetime as dt
def log_run(status: str, rows: int, log_path="pipeline_runs.csv"):
entry = pd.DataFrame([{
"run_at": dt.datetime.now().isoformat(timespec="seconds"),
"status": status,
"rows": rows,
}])
header = not Path(log_path).exists()
entry.to_csv(log_path, mode="a", header=header, index=False)
new_watermark = validated["updated_at"].max()
STATE_FILE.write_text(new_watermark)
log_run("success", len(validated))
print("New watermark:", new_watermark)
The watermark is written last, after Bronze, Silver, and the run log all succeeded. If any earlier step raises, the watermark file is untouched, so the next run naturally retries the same batch instead of skipping it.
Step 6 — a second run proves idempotency¶
conn.execute(
"UPDATE orders SET amount = 50.00, updated_at = ? WHERE order_id = 3",
("2026-08-01T11:00:00",),
)
conn.commit()
batch_2 = extract(conn, read_watermark())
validated_2 = validate(batch_2)
write_bronze(validated_2, "2026-08-01")
silver_2 = upsert_silver(validated_2)
STATE_FILE.write_text(validated_2["updated_at"].max())
log_run("success", len(validated_2))
print(silver_2)
order_id customer amount updated_at
0 1 alice 120.50 2026-08-01T09:00:00
1 2 bob 89.00 2026-08-01T09:05:00
2 3 carla 50.00 2026-08-01T11:00:00
Only order 3 was pulled (watermark now excludes the first three), and the
Silver table shows exactly one row per order_id with the freshest amount —
no duplication, no manual cleanup.
Traps¶
- Writing the watermark before the load finishes. Always persist state last, after every write has succeeded.
- Validating after writing to Silver. Validate the batch in memory first; a bad row should never touch a durable zone.
- Non-idempotent Bronze writes. Using a fixed filename like
part-0001.parquetper partition means a retried run overwrites rather than duplicates — that's intentional here, but a naiveto_parquetwith a timestamped filename would silently accumulate duplicate files on retry.
Cheat sheet¶
| Step | Function |
|---|---|
| Read progress | read_watermark() |
| Pull only new/changed rows | extract(conn, watermark) |
| Fail fast on bad data | validate(df) |
| Durable raw copy | write_bronze(df, run_date) |
| Deduplicated current view | upsert_silver(df) |
| Advance state only on success | STATE_FILE.write_text(new_watermark) |
How It Actually Works¶
Combining incremental extraction, validation, and idempotent loading into one pipeline exposes the mechanical dependency chain between these pieces: the watermark used for extraction and the key used for idempotent loading must refer to the same logical unit of work, or reruns and incremental catch-up interact in ways that silently corrupt state.
Consider what happens when a validation check fails partway through a run: the extraction step has already advanced its watermark (if advanced eagerly) or has not (if advanced only on full success). If the watermark is advanced before validation confirms the batch is good, a failed validation followed by a fix-and-rerun will use a watermark that has already moved past the bad batch — the rerun will pull the next incremental window, permanently skipping the rows that failed validation. This is why correct designs advance the watermark only after the full chain (extract → validate → load) commits successfully for a given run, treating the watermark update itself as the final, atomic "this run is done" signal rather than an independent early step.
The load step's upsert key must also be derived from the same logical partition the watermark advances over — typically the source's own change-tracking column or an explicit batch/run identifier — so that a rerun of a given incremental window is a true overwrite of exactly that window's rows, not an append alongside them and not an accidental overwrite of an adjacent window. Getting this alignment right is what makes the whole pipeline safely rerunnable end-to-end: any stage can fail, and rerunning from that stage forward (using the same run's watermark boundaries and keys) converges to the same correct final state instead of requiring manual cleanup of partial or duplicate data.
Exercise¶
Add a quarantine/ path: when validate() raises, catch the exception in
the run loop, write the offending batch (not just an error message) to
lake/quarantine/orders/dt=<run_date>/, log the run as "failed" with the
error text, and leave the watermark untouched so the next run retries. Then
simulate a batch with a negative amount and confirm it lands in quarantine
instead of Silver.