Your First Pipeline
This is the capstone. You'll wire everything from this course — the shell, Python, Postgres, DuckDB — into one real extract → transform → load pipeline that moves rental data out of a source database, reshapes it, and lands an analytics table in a warehouse. It's the entire curriculum in miniature. Type it all; by the end you'll have run a genuine data pipeline with your own hands.
You need two things from earlier chapters running and ready:
- Postgres up (Chapter 05) — your Docker Compose stack running, reachable at
localhost:5432with thegriddpdatabase, usergriddp, passwordgriddp. Confirm withdocker compose psin yourmini-griddpfolder. - Your uv project (Chapter 04) — the
mini-griddpproject withuvinitialized. We'll add a couple of packages to it.
From inside mini-griddp, add the libraries this lab uses:
uv add duckdb psycopg[binary] pandasduckdb is our warehouse, psycopg talks to Postgres, and pandas gives us a dataframe to move data through. If uv add succeeds you're ready.
What you'll build
Real data platforms all do the same fundamental thing: take data out of the systems that produce it, reshape it into something useful, and put it where people can analyze it. That three-step move has a name — ETL (extract, transform, load), sometimes ordered ELT. You're going to build a tiny but genuine one.
Our story: a GPU-rental marketplace records every rental in Postgres (the operational source). We want a business question answered — how much revenue did each GPU model earn? — so we'll pull the rentals into DuckDB (our analytics warehouse), compute revenue, aggregate by model, and save the answer as a clean table.
Everything you'll learn over the next courses — orchestration, scale, testing, infrastructure — is in service of running this shape reliably, repeatedly, and big. Once the small version clicks, the rest is amplification.
Seed the source
Before we can extract anything, the source needs data. Let's create a rentals table in Postgres and drop in eight sample rentals. Connect to your running Postgres with psql (the client is bundled in the Postgres container):
docker compose exec postgres psql -U griddp -d griddpAt the griddp=# prompt, paste this SQL:
DROP TABLE IF EXISTS rentals;
CREATE TABLE rentals (
rental_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
gpu_model TEXT NOT NULL,
hours NUMERIC NOT NULL,
price_per_hour NUMERIC NOT NULL,
created_at TIMESTAMP DEFAULT now()
);
INSERT INTO rentals (rental_id, customer_id, gpu_model, hours, price_per_hour) VALUES
(1, 101, 'A100', 10.0, 2.50),
(2, 102, 'H100', 5.0, 4.00),
(3, 101, 'A100', 3.5, 2.50),
(4, 103, 'L4', 20.0, 0.80),
(5, 102, 'H100', 8.0, 4.00),
(6, 104, 'A100', 1.5, 2.50),
(7, 103, 'L4', 12.0, 0.80),
(8, 101, 'H100', 6.0, 4.00);
SELECT * FROM rentals ORDER BY rental_id;A table of 8 rows printed back, with columns rental_id, customer_id, gpu_model, hours, price_per_hour, created_at — a mix of A100, H100, and L4 rentals. Type \q to leave psql. Your source system now has data, just like a production app would.
Extract — read from the source
Extraction means: connect to the source and pull the rows we need. We'll use psycopg to query Postgres and load the result straight into a pandas dataframe. In your mini-griddp folder, create extract.py:
import psycopg
import pandas as pd
PG_CONN = "host=localhost port=5432 dbname=griddp user=griddp password=griddp"
def extract_rentals() -> pd.DataFrame:
"""Connect to Postgres and read the rentals table into a dataframe."""
with psycopg.connect(PG_CONN) as conn:
df = pd.read_sql("SELECT * FROM rentals", conn)
return df
if __name__ == "__main__":
rentals = extract_rentals()
print(f"Extracted {len(rentals)} rows")
print(rentals.head())uv run python extract.pyExtracted 8 rows followed by a printed dataframe — the first five rentals with all their columns. You just pulled live data out of a database with code. (If you get a connection error, your Postgres container isn't running — revisit the Setup box and docker compose up -d.)
Transform — reshape the data
Now the interesting part: turn raw rows into an answer. We need two steps — compute revenue = hours × price_per_hour for each rental, then sum that revenue grouped by gpu_model.
We have a choice of where to do this. Two common options:
| Approach | What it looks like | When you'd pick it |
|---|---|---|
| In pandas (Python) | Add a column, then .groupby() | Small data, Python-heavy logic |
| In DuckDB (SQL) | One SELECT … GROUP BY over the dataframe | Pushes work to the engine; scales far better; reads like the question |
We'll use DuckDB SQL. DuckDB can query a pandas dataframe directly as if it were a table, the transform reads almost exactly like the business question, and this is the pattern real warehouses use — let the analytics engine do the heavy lifting. Create transform.py:
import duckdb
import pandas as pd
def transform(rentals: pd.DataFrame) -> pd.DataFrame:
"""Compute revenue per rental, then aggregate to revenue per GPU model."""
result = duckdb.sql("""
SELECT
gpu_model,
COUNT(*) AS rental_count,
SUM(hours * price_per_hour) AS total_revenue
FROM rentals
GROUP BY gpu_model
ORDER BY total_revenue DESC
""").df()
return result
if __name__ == "__main__":
from extract import extract_rentals
summary = transform(extract_rentals())
print(summary)uv run python transform.pyA small table with one row per GPU model. H100 leads at 76.0 total revenue (3 rentals), then A100 at 40.0 (3 rentals), then L4 at 25.6 (2 rentals). Notice DuckDB read the rentals dataframe by name straight out of your Python scope — no import, no copy. That's the in-process magic that makes it a great local warehouse.
Load — land it in the warehouse
The answer exists, but only in memory — when the script ends it's gone. Loading means persisting the result somewhere durable. We'll write it into a DuckDB database file as a table called gpu_model_revenue, and also drop a Parquet file (the standard columnar format you'll meet everywhere later). Create load.py:
import duckdb
import pandas as pd
WAREHOUSE = "warehouse.duckdb"
def load(summary: pd.DataFrame) -> None:
"""Persist the summary into the DuckDB warehouse and to Parquet."""
con = duckdb.connect(WAREHOUSE)
con.register("summary_df", summary)
con.sql("CREATE OR REPLACE TABLE gpu_model_revenue AS SELECT * FROM summary_df")
con.sql("COPY gpu_model_revenue TO 'gpu_model_revenue.parquet' (FORMAT parquet)")
con.close()
if __name__ == "__main__":
con = duckdb.connect(WAREHOUSE)
print(con.sql("SELECT * FROM gpu_model_revenue").df())
con.close()That __main__ block lets you query the warehouse back after the pipeline has run — proving the data is really on disk:
uv run python load.py # (run this after the full pipeline below)Once the pipeline has populated it, load.py prints the gpu_model_revenue table read straight from warehouse.duckdb — your data survived the program ending. A gpu_model_revenue.parquet file now sits in the folder too. That's a warehouse: query it any time without re-running the whole pipeline.
Run end to end
Three functions, one flow. Let's assemble them into a single pipeline.py that runs the whole extract → transform → load in one command:
from extract import extract_rentals
from transform import transform
from load import load
def run() -> None:
print("→ Extracting from Postgres…")
rentals = extract_rentals()
print(f" got {len(rentals)} rows")
print("→ Transforming…")
summary = transform(rentals)
print("→ Loading into DuckDB warehouse…")
load(summary)
print("✓ Pipeline complete. Result:")
print(summary)
if __name__ == "__main__":
run()uv run python pipeline.pyThree progress lines, then ✓ Pipeline complete. and the final revenue-per-model table — H100 at 76.0, A100 at 40.0, L4 at 25.6. You just ran a complete data pipeline. Data left Postgres, got reshaped, and landed in a queryable warehouse — all from one command.
A first big idea: make it re-runnable
Run uv run python pipeline.py a second time. Notice the result is exactly the same — not doubled. That's not luck; it's by design, and it's one of the most important properties a pipeline can have.
The magic word is idempotent: running the operation twice leaves the system in the same state as running it once. Press an elevator button twice — same result. Our load uses CREATE OR REPLACE TABLE, which rebuilds the table from scratch every run instead of appending to it. So a re-run can't produce duplicates.
If load.py had used INSERT INTO gpu_model_revenue … instead, every run would stack another copy of the rows on top — run it three times, get triple the data. That class of bug is everywhere in real pipelines. The fixes: CREATE OR REPLACE, drop-then-load, or "upsert" (update existing rows, insert new ones). We used the simplest.
Pipelines fail halfway and get retried. Schedulers run them on overlapping triggers. A teammate kicks one off by hand to debug. If a re-run can corrupt or double your data, every one of those is a disaster. Idempotency makes "just run it again" a safe instinct — and you'll meet this idea again in every course ahead.
Reflect — map it to the mental model
Remember the data-platform layers from Chapter 01? You just built one of each:
| Mental-model layer | What you built |
|---|---|
| Source | The Postgres rentals table — the operational system producing data |
| Ingestion | extract_rentals() — code that pulls data out of the source |
| Storage | The DuckDB warehouse (warehouse.duckdb) + the Parquet file |
| Transformation | The SQL aggregation — revenue computed and rolled up per model |
| Serving | The gpu_model_revenue table — the answer, ready for anyone to query |
That's a real platform's spine. But it's a toy, and the gap between this and production is exactly what the rest of the curriculum fills. Here's what's still missing:
| You did (toy) | Real platforms do (and where you'll learn it) |
|---|---|
| Ran the pipeline by hand | Schedule & orchestrate it — run on a timer, in order, with retries and alerts on failure (Courses 4–6) |
| Trusted the output by eyeballing it | Test & check quality — automated assertions that the data is correct before anyone trusts it (later courses) |
| Moved 8 rows in memory | Scale — handle millions/billions of rows, partitioned and distributed (Course 3 explains how this is even possible) |
| Read one table from one database | Integrate many sources — APIs, files, streams, multiple databases |
| Hard-coded a password in the script | Manage secrets & config safely, and define infra as code |
Every item above makes the same extract → transform → load more reliable, bigger, or more automated. You now own the core. Everything ahead is amplification.
Commit your work
You built something real — get it into version control so it's tracked and part of your portfolio. From inside mini-griddp:
# don't commit the generated warehouse/parquet — they're rebuildable
echo "warehouse.duckdb" >> .gitignore
echo "*.parquet" >> .gitignore
git add extract.py transform.py load.py pipeline.py .gitignore pyproject.toml uv.lock
git commit -m "Add first ETL pipeline: Postgres rentals -> DuckDB revenue"
git pushA commit recording your four pipeline files, then a successful push to GitHub. Notice we git-ignored the warehouse and Parquet outputs — they're generated artifacts, rebuildable any time by running the pipeline. That's the reproducibility principle in action: commit the code that makes the data, not the data itself. Your portfolio just grew by one working pipeline.
✓ Check yourself
- Can you name the three stages of the pipeline and what each one does?
- Can you explain, in one sentence, what idempotent means and why it matters?
- Do you know why we committed the code but git-ignored
warehouse.duckdb? - Could you map your pipeline onto the source → ingestion → storage → transformation → serving layers?
Exercise — Add a second metric, and keep it idempotent
Add a second analytics table, customer_hours, holding total hours per customer, loaded into the same warehouse alongside gpu_model_revenue. The whole script must stay idempotent — running it twice produces identical tables, no duplicates.
One clean way: add a second transform, and load both summaries with CREATE OR REPLACE so each run rebuilds its table.
def transform_customer_hours(rentals):
import duckdb
return duckdb.sql("""
SELECT customer_id, SUM(hours) AS total_hours
FROM rentals
GROUP BY customer_id
ORDER BY total_hours DESC
""").df()def load_table(summary, table_name):
con = duckdb.connect(WAREHOUSE)
con.register("df", summary)
# CREATE OR REPLACE = idempotent: rebuilds, never appends
con.sql(f"CREATE OR REPLACE TABLE {table_name} AS SELECT * FROM df")
con.close()from extract import extract_rentals
from transform import transform, transform_customer_hours
from load import load_table
def run():
rentals = extract_rentals()
load_table(transform(rentals), "gpu_model_revenue")
load_table(transform_customer_hours(rentals), "customer_hours")
print("✓ Loaded gpu_model_revenue and customer_hours")
if __name__ == "__main__":
run()Run uv run python pipeline.py twice, then query both tables — same results each time. Because every load uses CREATE OR REPLACE, the whole pipeline is idempotent end to end. If you got two tables that don't grow on re-runs, you've nailed the core skill of this course.
Next
That's a wrap on Course 2. Stop and appreciate what you can now do: you have a real data-engineering workstation, and you've built and committed a working pipeline that extracts from a source database, transforms data with SQL, and loads it into a warehouse — idempotently. That is the actual job, in miniature.
You built the what. Course 3 — Foundations explains the why: how databases really store and find data, how files and storage formats work under the hood, and how distributed systems let a pipeline like yours scale from 8 rows to billions. Once you understand the machinery beneath the tools, you stop following recipes and start engineering.
Course 3 — Foundations — is ready, and it's where you go next: it explains the machinery beneath everything you just built. Congratulations on finishing Course 2.