Course 6 · Capstone

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.

Setup — do this first

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.

SOURCE (Postgres) INGEST WAREHOUSE (DuckDB) ┌──────────────────┐ ┌─────────────────┐ ┌──────────────────────┐ │ customers │ │ extract_postgres│ │ bronze.customers │ │ gpus │─────▶│ .py │────▶│ bronze.gpus │ │ gpu_prices │ │ • full refresh │ │ bronze.gpu_prices │ │ rentals (grows) │ │ • incremental │ │ bronze.rentals │ └──────────────────┘ │ • idempotent │ │ bronze._watermarks │ └─────────────────┘ └──────────────────────┘ ───────────────────────────────────────────────────────────────────────────▶ re-running is SAFE: small tables get replaced; rentals only pulls NEW rows

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.

LayerWhat it holdsBuilt in
BronzeRaw source rows, landed as-is. The system of record for "what the source said."This lab
SilverCleaned, typed, deduplicated; one tidy row per entity.Lab 03
GoldBusiness-ready star schema: dims + facts for analytics.Lab 03
Why land raw first?

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.

terminal
# 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;"
✓ You should see

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.

Gitignore the warehouse

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:

ingest/extract_postgres.py
"""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()
How the idempotency is built in

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.

Match the connection details to your stack

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:

terminal
uv run python ingest/extract_postgres.py
✓ You should see

Output like this, with counts that match your source tables:

output
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;".

If the postgres scanner fails to load

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:

terminal
uv run python ingest/extract_postgres.py
✓ You should see

The full-refresh counts are identical to the first run, and rentals pulls zero new rows — the total does not double:

output
  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.

Why this matters — the Foundations connection

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.

See incremental actually pull new rows

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:

terminal
duckdb warehouse.duckdb
DuckDB SQL
-- 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;
✓ You should see

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):

terminal
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)"
✓ You should see

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_prices full-refreshed but rentals incremental — what makes that the right call for each?
  • What does the _watermarks table 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:

before — duplicates on every 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:

after — incremental + idempotent
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.

Going further

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