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 twice over: the duckdb CLI from Course 2 (duckdb --version) and the Python package in the project venv from Lab 01. The two share one database file, so they must agree on major.minor or the CLI refuses to open what Python wrote. Pin the package to your CLI's version, for example uv add "duckdb==1.5.*" for a v1.5.x CLI. The Postgres scanner extension downloads itself 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. The one thing we add is a loaded_at stamp on every row: when it landed. Lab 06 reads that column to tell whether a feed has gone quiet.
| 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 the Lab 01 Compose file.
PG = os.getenv(
"PG_CONN",
"host=localhost port=5432 dbname=marketplace 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, stamped with the load time."""
con.execute(f"CREATE OR REPLACE TABLE bronze.{table} AS "
f"SELECT *, now() AS loaded_at 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 *, now() AS loaded_at 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 *, now() 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 marketplace, 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.pyExactly the counts Lab 01 generated:
Landing bronze into warehouse.duckdb ...
full-refresh bronze.customers -> 50 rows
full-refresh bronze.gpus -> 10 rows
full-refresh bronze.gpu_prices -> 23 rows
incremental bronze.rentals -> +400 new (watermark rental_id=400, total 400)
Done.Bronze counts equal the source. Cross-check one: duckdb warehouse.duckdb "SELECT count(*) FROM bronze.customers;" should match docker compose exec postgres psql -U griddp -d marketplace -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 -> 50 rows
full-refresh bronze.gpus -> 10 rows
full-refresh bronze.gpu_prices -> 23 rows
incremental bronze.rentals -> +0 new (watermark rental_id=400, total 400)
Done.total 400, not 800. 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? Play the application for a moment and insert five rentals straight into Postgres, then run the extractor again:
docker compose exec postgres psql -U griddp -d marketplace -c "
INSERT INTO rentals (customer_id, gpu_id, hours, started_at)
SELECT customer_id, gpu_id, 2.0, now() FROM rentals ORDER BY rental_id LIMIT 5;"
uv run python ingest/extract_postgres.pyThis time the last line reads +5 new (watermark rental_id=405, total 405): only the new rows crossed the wire. Do not reach for the generator to add rows. It drops and recreates every table, so the ids start again at 1, the watermark still says 400 and the extractor pulls nothing while bronze quietly keeps the old copy. To get back to the 400 rows the later labs assume, rebuild both sides: uv run python generator/generate.py && rm warehouse.duckdb && uv run python ingest/extract_postgres.py.
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 | 400 (your max rental_id). The rentals look exactly like the source plus the loaded_at stamp — 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
Answer from memory before opening each one.
-
A teammate wants the extractor to trim whitespace and drop obviously broken rows on the way into bronze, "to save dbt the work". What is lost if you agree?
Show answer
The record of what the source actually said. If that cleaning rule turns out to be wrong, the discarded detail is gone and you have to re-pull from the source; with a raw bronze you fix the transform and rebuild silver and gold from what already landed. Cleaning belongs in silver. See What "bronze" means.
-
Run the extractor twice with no new source data. What should the rentals line print the second time — and if the total had doubled instead, where would you look?
Show answer
+0 new, with the same watermark and the same total: the stored watermark already equals the largestrental_id, sorental_id > watermarkmatches nothing. A doubled total means the insert is not being filtered — the watermark was never stored or advanced (checkbronze._watermarks), or theINSERTlost itsWHERE. See Run it twice. -
Someone switches the rentals watermark from
rental_idtostarted_at, reasoning that a timestamp is more meaningful. Which rentals does the extractor start missing?Show answer
Any rental inserted later but carrying a
started_atearlier than the stored maximum. A watermark needs a column that only ever increases as rows are added.rental_iddoes;started_atdoes not — the Lab 01 generator writes rentals scattered across the last 90 days in random order — so new rows routinely fall below the watermark and are skipped for good. See Writing the extractor. -
A customer is upgraded from
freetoproin Postgres. After the next extractor run, what doesbronze.customersshow — and what would it show hadcustomersused the same watermark pattern asrentals?Show answer
It shows
pro:CREATE OR REPLACE TABLEre-copies the whole table each run, so updates come along for free (the old value is overwritten, not kept). Acustomer_idwatermark only fetches ids above the stored maximum, so an edit to an existing row would never be picked up and bronze would sayfreeindefinitely. See Writing the extractor. -
The process is killed after
INSERT INTO bronze.rentalsfinishes but beforeset_watermarkruns. What does the next run do, and how would you close the gap?Show answer
It reads the old watermark from
_watermarks, selects the same rows again and inserts them a second time — duplicates. The insert and the watermark update are separate statements with no transaction around them, so "run it twice" only proves idempotency for runs that finish. Wrap both in one transaction, or take the watermark frommax(rental_id)inbronze.rentalsat the start of each run instead of from a separately stored value. See incremental_rentals. -
customersgrows to 50 million rows and the nightly full refresh now takes an hour. What do you weigh before moving it to the watermark pattern used forrentals?Show answer
Size now argues for incremental, but the append-only watermark is only correct for rows that never change — and customers do (a tier upgrade). A plain id watermark would be fast and wrong; you need a way to catch edits too, which means a merge/upsert on the primary key driven by a change timestamp. Full refresh stays the right call for as long as re-copying is cheap, because it is trivially idempotent and picks up updates. See Writing the extractor.
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 insert a few rentals by hand (the psql one-liner from Run it twice) 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.
Takeaway
- Bronze is a near-exact copy of the source plus a load stamp, kept so any downstream bug can be fixed by replaying transforms instead of re-pulling.
- Small reference tables are fully refreshed with
CREATE OR REPLACE, which is idempotent by construction, and the growingrentalstable is pulled incrementally past a stored high watermark. - Idempotency is something you can see: run the extractor twice and the counts stay put (400 rows, not 800) with zero new rentals reported.
- Insert five rentals straight into Postgres and the next run reports five new rows, which proves the incremental path moves only what changed.
- Do not use the generator to add rows after ingestion, because it drops and recreates every table, the ids restart at 1, and the watermark then skips them.
- The warehouse file is generated data, so it goes in
.gitignoreand you commit the code that builds it.
Bronze is a faithful but messy copy, and the next lab cleans it, types it and shapes it into a star schema. → Lab 03 — Transform: Silver & Gold with dbt
Further reading
- DuckDB: PostgreSQL extension — DuckDB docs, accessed 2026. Attaching a Postgres database and reading its tables from SQL.
- DuckDB: CREATE TABLE — DuckDB docs, accessed 2026.
CREATE OR REPLACE TABLEand constraints. - What is a medallion architecture? — Databricks. The vendor definition of the bronze layer's role.
- Designing Data-Intensive Applications — Kleppmann, O'Reilly. The background on why re-runnable, replayable loads matter.