Lab 02 — Ingestion — Land Raw to Bronze
Your generator filled Postgres with a living marketplace. Now you'll build the first real pipeline: an extractor that lands that source data into a bronze layer inside a DuckDB warehouse — faithfully, incrementally, and idempotently, so re-running it never doubles your data. This is the front door of every platform you'll ever build.
You need Lab 01 finished: your mini-griddp Compose stack running, with Postgres seeded by the generator (tables customers, gpus, gpu_prices, rentals). Confirm with docker compose ps — the postgres service should be up. You also need DuckDB available to Python; install it into your project venv with uv add duckdb (and the Postgres scanner extension, which DuckDB downloads on first use). Work from your repo root: cd mini-griddp.
The goal
In Lab 01 your sources came alive. In this lab you build ingestion — the ingest/extract_postgres.py script that copies source tables out of Postgres and lands them into a bronze schema inside a single DuckDB file (warehouse.duckdb). That file becomes your warehouse for the rest of the capstone: silver and gold (Lab 03) build on top of bronze.
Two properties matter more than anything else here, and they're what separate a toy script from a pipeline:
- Idempotent — running it twice leaves the warehouse in the same state as running it once. No duplicate rows, ever.
- Incremental — the big, growing table (
rentals) is pulled using a high-watermark, so a second run only fetches rows newer than what you already have. Cheap to re-run.
What "bronze" means
The medallion architecture (bronze → silver → gold) organizes a warehouse into layers of increasing refinement. Bronze is the landing zone: a near-exact copy of the source, captured as-is, with as little transformation as possible. You don't clean it, rename columns, or fix types here — you just get it in, reliably.
| Layer | What it holds | Built in |
|---|---|---|
| Bronze | Raw source rows, landed as-is. The system of record for "what the source said." | This lab |
| Silver | Cleaned, typed, deduplicated; one tidy row per entity. | Lab 03 |
| Gold | Business-ready star schema: dims + facts for analytics. | Lab 03 |
If a downstream transform has a bug, you don't want to have already discarded the source detail — you'd have to re-pull everything. Bronze keeps a faithful copy so you can rebuild silver and gold any time by replaying transforms. It's "immutable-ish": you append and refresh, but you never edit a landed row by hand.
Let's create the warehouse file and the bronze schema. DuckDB is a single file — creating it is just connecting to a path that doesn't exist yet.
# from the repo root, create the schema in a fresh warehouse file
duckdb warehouse.duckdb "CREATE SCHEMA IF NOT EXISTS bronze;"
# confirm it exists
duckdb warehouse.duckdb "SELECT schema_name FROM information_schema.schemata;"A warehouse.duckdb file appear in your repo, and the second command lists bronze alongside DuckDB's built-in schemas (main, information_schema). The schema is empty for now — the extractor fills it.
The .duckdb file is generated data, not source. Add warehouse.duckdb (and *.duckdb.wal) to your .gitignore now so you don't commit a binary blob. You commit the code that builds it, never the file itself.
Writing the extractor
Here's the plan. The three small reference tables (customers, gpus, gpu_prices) change slowly and are cheap, so we full-refresh them: drop and replace on every run. That's trivially idempotent. The rentals table grows continuously, so we pull it incrementally using a high-watermark — we remember the largest rental_id we've already landed, and ask Postgres only for rows above it.
We'll use DuckDB's Postgres scanner extension, which lets DuckDB read Postgres tables directly inside a SQL query — no separate Postgres driver, no row-by-row copying in Python. Create the file:
"""Extract source tables from Postgres and land them in the bronze layer.
Full-refresh for small reference tables; high-watermark incremental for rentals.
Safe to re-run: idempotent by construction.
"""
import os
import duckdb
WAREHOUSE = os.getenv("WAREHOUSE", "warehouse.duckdb")
# Connection string for DuckDB's postgres scanner. Matches your Lab 01 .env.
PG = os.getenv(
"PG_CONN",
"host=localhost port=5432 dbname=griddp user=griddp password=griddp",
)
# Small tables we simply replace every run -> trivially idempotent.
FULL_REFRESH = ["customers", "gpus", "gpu_prices"]
def connect():
con = duckdb.connect(WAREHOUSE)
con.execute("INSTALL postgres; LOAD postgres;")
# Attach Postgres as a read-only catalog called `pg`.
con.execute(f"ATTACH '{PG}' AS pg (TYPE postgres, READ_ONLY);")
con.execute("CREATE SCHEMA IF NOT EXISTS bronze;")
return con
def full_refresh(con, table):
"""Replace a bronze table with a fresh copy from Postgres."""
con.execute(f"CREATE OR REPLACE TABLE bronze.{table} AS "
f"SELECT * FROM pg.public.{table};")
n = con.execute(f"SELECT count(*) FROM bronze.{table}").fetchone()[0]
print(f" full-refresh bronze.{table:12s} -> {n} rows")
def ensure_watermark_store(con):
"""A tiny table that remembers how far we've ingested each source."""
con.execute("""
CREATE TABLE IF NOT EXISTS bronze._watermarks (
source_table VARCHAR PRIMARY KEY,
high_watermark BIGINT
);
""")
def get_watermark(con, table):
row = con.execute(
"SELECT high_watermark FROM bronze._watermarks WHERE source_table = ?",
[table],
).fetchone()
return row[0] if row else 0
def set_watermark(con, table, value):
con.execute("""
INSERT INTO bronze._watermarks VALUES (?, ?)
ON CONFLICT (source_table) DO UPDATE SET high_watermark = excluded.high_watermark;
""", [table, value])
def incremental_rentals(con):
"""Pull only rentals newer than the last high-watermark (max rental_id)."""
con.execute("""
CREATE TABLE IF NOT EXISTS bronze.rentals AS
SELECT * FROM pg.public.rentals WHERE false;
""")
wm = get_watermark(con, "rentals")
new_rows = con.execute(
"SELECT count(*) FROM pg.public.rentals WHERE rental_id > ?", [wm]
).fetchone()[0]
con.execute(
"INSERT INTO bronze.rentals "
"SELECT * FROM pg.public.rentals WHERE rental_id > ?", [wm]
)
new_wm = con.execute(
"SELECT max(rental_id) FROM bronze.rentals"
).fetchone()[0] or 0
set_watermark(con, "rentals", new_wm)
total = con.execute("SELECT count(*) FROM bronze.rentals").fetchone()[0]
print(f" incremental bronze.rentals -> +{new_rows} new "
f"(watermark rental_id={new_wm}, total {total})")
def main():
con = connect()
ensure_watermark_store(con)
print(f"Landing bronze into {WAREHOUSE} ...")
for t in FULL_REFRESH:
full_refresh(con, t)
incremental_rentals(con)
con.close()
print("Done.")
if __name__ == "__main__":
main()Two mechanisms. The small tables use CREATE OR REPLACE TABLE — re-running just rebuilds them from the current source, so there's nothing to duplicate. rentals uses the _watermarks table: each run only inserts rows with rental_id greater than the stored high-watermark, then advances the watermark. Run it again immediately and rental_id > watermark matches nothing — zero new rows.
The PG_CONN and table names above assume the Lab 01 generator (database griddp, schema public, an auto-incrementing rental_id). If your generator used different names, set PG_CONN in your environment or edit the defaults. A watermark needs a monotonically increasing column — rental_id works because rows are only ever appended.
Run it
Run the extractor from the repo root:
uv run python ingest/extract_postgres.pyOutput like this, with counts that match your source tables:
Landing bronze into warehouse.duckdb ...
full-refresh bronze.customers -> 200 rows
full-refresh bronze.gpus -> 12 rows
full-refresh bronze.gpu_prices -> 360 rows
incremental bronze.rentals -> +5000 new (watermark rental_id=5000, total 5000)
Done.Your exact numbers depend on how much data Lab 01 generated, but bronze counts should equal the source. Cross-check one: duckdb warehouse.duckdb "SELECT count(*) FROM bronze.customers;" should match docker compose exec postgres psql -U griddp -d griddp -c "SELECT count(*) FROM customers;".
DuckDB downloads the postgres extension on first use, so you need network access the first time. If ATTACH errors with a connection refused, the host/port are wrong — from your laptop use localhost:5432 (the port Compose published); from inside the Compose network you'd use the service name postgres:5432. We run this script from the host, so localhost is correct.
Run it twice (idempotency)
This is the test that proves your pipeline is safe. Run the exact same command a second time, without generating any new data:
uv run python ingest/extract_postgres.pyThe full-refresh counts are identical to the first run, and rentals pulls zero new rows — the total does not double:
full-refresh bronze.customers -> 200 rows
full-refresh bronze.gpus -> 12 rows
full-refresh bronze.gpu_prices -> 360 rows
incremental bronze.rentals -> +0 new (watermark rental_id=5000, total 5000)
Done.total 5000, not 10000. That +0 new is idempotency you can see. The small tables were rebuilt from scratch (same input, same output), and the watermark blocked any rental re-insert.
Back in the Foundations course and the Course 4 ingestion chapters, idempotency was the headline rule: pipelines fail and get retried, and a retry must not corrupt your data. A scheduler (Dagster, in Lab 05) might re-run this asset after a crash, or you might run it twice by hand. Because every operation here is idempotent, none of that can hurt you. Test it by running twice — that's the cheapest way to catch a non-idempotent pipeline.
Want proof the incremental path isn't just a no-op? Re-run your Lab 01 generator to append more rentals to Postgres, then run the extractor again. This time you'll see +N new where N is exactly the rentals added, and the watermark advances — only the new rows crossed the wire.
Verify the bronze layer
Open the warehouse and look at what landed. DuckDB's CLI gives you a SQL prompt straight against the file:
duckdb warehouse.duckdb-- what tables landed in bronze?
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'bronze' ORDER BY table_name;
-- row counts per bronze table
SELECT 'customers' AS t, count(*) FROM bronze.customers
UNION ALL SELECT 'gpus', count(*) FROM bronze.gpus
UNION ALL SELECT 'gpu_prices', count(*) FROM bronze.gpu_prices
UNION ALL SELECT 'rentals', count(*) FROM bronze.rentals;
-- the watermark we're tracking
SELECT * FROM bronze._watermarks;
-- peek at a few landed rentals
SELECT rental_id, customer_id, gpu_id, started_at
FROM bronze.rentals ORDER BY rental_id DESC LIMIT 5;The bronze schema listing your four tables plus _watermarks; counts matching the source; and a _watermarks row reading rentals | 5000 (or whatever your max rental_id is). The rentals look exactly like the source — bronze is a faithful copy, not a transformation. Type .quit to leave the DuckDB prompt.
Commit Lab 02
You've added the ingestion stage. Commit the code (the warehouse file stays gitignored):
git add ingest/extract_postgres.py .gitignore
git status # confirm warehouse.duckdb is NOT staged
git commit -m "Lab 02: ingest Postgres -> bronze (incremental + idempotent)"A commit containing your extractor and the updated .gitignore — and git status showing warehouse.duckdb as ignored, never staged. Your history now reads "Lab 01 → Lab 02," one honest step at a time.
✓ Check yourself
- Can you explain why bronze lands data raw, with no cleaning, and what you'd lose if you cleaned it here instead?
- Why are
customers/gpus/gpu_pricesfull-refreshed butrentalsincremental — what makes that the right call for each? - What does the
_watermarkstable store, and how does it stop a second run from duplicating rentals? - If you re-ran the extractor right now, how many new rentals would it pull — and why?
Exercise — Make the rentals extract incremental so a second run only pulls new rows
Suppose a colleague's first draft pulled rentals with a plain full copy each run:
def naive_rentals(con):
con.execute("CREATE OR REPLACE TABLE bronze.rentals AS "
"SELECT * FROM pg.public.rentals;")That's actually idempotent (replace, not append), but it re-copies every rental on every run — wasteful as the table grows to millions of rows. Worse, a tempting "fix" of switching CREATE OR REPLACE to INSERT INTO would duplicate rows. Your task: rewrite it to land only rentals newer than the last run, using a high-watermark, and prove a second run pulls +0.
Solution. Track the max rental_id in a watermark table and insert only rows above it — exactly the incremental_rentals pattern from this lab. The essence:
def incremental_rentals(con):
# 1) make sure the target exists (empty, same shape as source)
con.execute("CREATE TABLE IF NOT EXISTS bronze.rentals AS "
"SELECT * FROM pg.public.rentals WHERE false;")
# 2) read how far we've already ingested (0 the first time)
wm = get_watermark(con, "rentals")
# 3) insert ONLY rows newer than the watermark
con.execute("INSERT INTO bronze.rentals "
"SELECT * FROM pg.public.rentals WHERE rental_id > ?", [wm])
# 4) advance the watermark to the new max
new_wm = con.execute(
"SELECT max(rental_id) FROM bronze.rentals").fetchone()[0] or 0
set_watermark(con, "rentals", new_wm)Prove it. Run the extractor, note the total. Run it again with no new source data — the insert's WHERE rental_id > ? now matches nothing, so the total is unchanged and you see +0 new. Then re-run the Lab 01 generator to append rentals and run once more: you'll see exactly the new count land, and the watermark step up. That's a pipeline that's cheap to re-run and safe to retry — the two properties every ingestion job needs.
Some sources update existing rows (a rental's ended_at gets filled in later). A pure append watermark would miss those edits. The fix is a merge/upsert on the primary key — land new rows and update changed ones. You'll meet exactly this idea in Lab 03 when silver handles slowly-changing dimensions.
Next
Bronze is a faithful but messy copy. Time to clean it, type it, and shape it into an analytics-ready star schema. → Lab 03 — Transform — Silver & Gold with dbt