learn-data-engineering-with-phoebe / Builder session 5 of 10
Learn Data Engineering with Phoebe · Builder track · Session 5 of 10

Batch transformation

You have data landing in files (b4). Now you turn raw into useful: clean the types, derive the columns, join the pieces, aggregate to the grain the warehouse wants. This is the transform stage - and you will build it the modern way, in the engine, as an idempotent script you can re-run a hundred times and get the same answer. Live on DuckDB, against Daybreak's source.

🟠 Builder track Practitioners: DE · analysts · DS · PMs Runs in your browser · DuckDB 45 min
0-3 · Recap 3-20 · ETL/ELT, idempotency, compute at scale 20-42 · Build the transform layer 42-45 · Q&A
Part 0

Where the pipeline stands

Four sessions in, Daybreak's data has a path out of the source system. You connected to the source and extracted it (b2), learned full vs incremental ingestion (b3), and picked file and table formats for the landing zone (b4). The bytes have arrived - but they are still raw: text dates, order lines not yet rolled up, nothing the warehouse can load as-is. Today is the transform stage of the lifecycle: the compute that turns landed raw data into clean, aggregated, trustworthy tables.

Live - presented in session Self-study - read after class ▶ Live pipeline - editable & runnable Official sources covered
★ What you walk out with today A working transform layer for Daybreak built as one idempotent script (clean → join → aggregate), a gut-level feel for why ELT-in-the-engine is the modern default, the discipline to write transforms you can safely re-run, and an honest map of when a single node (DuckDB) is enough and when the job belongs on a distributed cluster (Spark) - which sets up b8.
Part 1 · covers DLAI Source Systems M2 (ETL/ELT), Fundamentals of DE

Transform is where raw becomes useful 6 min live

Every raw table hands you problems: a date stored as text, an order split across line items, statuses you have to filter. Transformation is the compute that fixes all of it - casting types, deriving columns, joining tables, aggregating to a grain. The only real question is where that compute runs: before you load the data, or after. That choice is the ETL-vs-ELT debate, and the modern answer has flipped.

ETL - transform BEFORE load (older default) Extract Transform Load Warehouse transform on a separate box, before it ever lands ELT - transform AFTER load, in the engine (modern default) Extract Load Transform (inside the warehouse engine) the powerful warehouse engine does the heavy lifting - load raw first, shape in place
🔍 Click to zoom - the only difference is where the copper transform box sits
LiveELT vs ETL, and why the default flipped3 min

ETL transforms data before loading it - on a separate processing box, so only clean data ever lands. That made sense when warehouse compute was scarce and expensive. ELT loads the raw data first, then transforms it in place using the warehouse engine's own power. Today ELT is the default, because the engine (Snowflake, BigQuery, DuckDB) is now the most powerful, cheapest-to-scale compute you have.

  • ELT keeps the raw copy: load first means you always have the untouched source to re-transform when requirements change. ETL throws the raw away.
  • The engine is the muscle: pushing transforms into a columnar engine beats hand-rolled row-by-row processing on a middle box. Let the database do what it is best at.
  • DuckDB is doing ELT right now: every playground on this page loads Daybreak's raw source, then transforms in the engine. That is ELT in miniature.
Real world

Why ELT won. A team on ETL had to file a ticket and wait a day whenever analysts wanted a new column - the transform box was a bottleneck owned by engineering. Moving to ELT, analysts wrote their own SQL transforms against raw data already in the warehouse. The raw layer stayed put; the shaping moved to whoever needed it. Throughput went up because the engine, not a middle box, did the work.

Self-studyWhen ETL still earns its place2 min read

ELT is the default, not a law. ETL-before-load still wins in a few spots: when you legally must not land raw PII (mask it before it touches the warehouse), when the source volume is so huge that loading everything is wasteful (filter first), or when a lightweight streaming transform is cheaper mid-flight than a batch pass later. The instinct that matters: default to ELT, reach for pre-load transformation only when a real constraint forces it.

Part 2 · the discipline that makes transforms safe

Idempotent transforms: re-run without fear 5 min live

Pipelines crash. A network blips, a node dies, a source is late - and your transform stops halfway, or fires twice on a retry. An idempotent transform is one you can run again and again and get the exact same result. The trick is simple: rebuild the output table from scratch with CREATE OR REPLACE instead of appending to it with INSERT. Build it here once, then run it twice and watch the count stay identical.

LiveBuild a clean daily table - then run it twice4 min

This transform reads raw orders, casts the text date to a real DATE, filters to completed orders, and aggregates to a daily grain - all with CREATE OR REPLACE. Press ▶ Run. Then press it again. The row count does not move, because the table is rebuilt from source every time, not appended to. That is idempotency.

CREATE OR REPLACE TABLE daily_orders AS
SELECT CAST(order_date AS DATE)                       AS day,
       count(*)                                       AS orders,
       count(*) FILTER (WHERE status = 'completed')   AS completed,
       count(*) FILTER (WHERE status = 'refunded')    AS refunded
FROM orders
GROUP BY day;

SELECT count(*) AS rows_in_table, sum(orders) AS orders_total
FROM daily_orders;
Why FILTER beats a WHERE here FILTER (WHERE status = 'completed') counts a subset without dropping the other rows from the aggregate. One pass over the data gives you total, completed, and refunded side by side - cleaner than three separate queries stitched together.
Self-studyDerived columns and FILTER aggregates2 min read

Two transform moves you will reach for constantly:

  • Derived columns: a transform rarely just copies - it computes. CAST(order_date AS DATE) fixes the text-date sin from the source. quantity * unit_price AS line_amount derives revenue that the source never stored. Deriving in the transform means every consumer downstream sees the same clean value.
  • FILTER aggregates: count(*) FILTER (WHERE ...) and sum(x) FILTER (WHERE ...) let one aggregate query answer several questions at once - completed vs refunded vs total in a single scan. It is faster and clearer than joining three sub-queries.
Part 3 · covers DLAI Storage M3 (◐), Fundamentals of DE

Transform compute at scale: one node vs many 4 min live

DuckDB runs your transform on a single machine - and a single modern machine is astonishingly capable, comfortably handling data up to roughly 100 GB. Only when a dataset genuinely outgrows one machine do you reach for distributed compute like Spark, which spreads the same transform across a cluster. Same logic, very different engine. Knowing where the line sits keeps you from paying for a cluster you do not need - or hitting a wall on a laptop that cannot cope.

Single node (DuckDB, this course) - great to ~100 GB one machine does it all no cluster, no shuffle, no ops - just run it Distributed (Spark) - when data exceeds one machine node node node node work split across the cluster - power, but ops and cost Rule of thumb: reach for a cluster when one machine can no longer hold or chew the data - not before. Most transforms you will write for years fit on one node. Distributed is b8.
🔍 Click to zoom - the same transform, one node vs a cluster of them
Self-studyThe same transform, on Spark (read-only)3 min read

Here is Demo 1's daily aggregate expressed in PySpark - the distributed engine you would use if Daybreak had billions of rows instead of thirty-three. The logic is identical; only the engine and the scale change. This snippet cannot run in a browser - Spark needs a cluster (or at least a local JVM) - so read it, do not run it.

PySpark · runs on a cluster, not here from pyspark.sql import functions as F daily = (spark.read.parquet("s3://daybreak/raw/orders/") .filter(F.col("status") == "completed") .withColumn("day", F.to_date("order_date")) .groupBy("day") .agg(F.count("*").alias("orders")) .orderBy("day")) daily.write.mode("overwrite").parquet("s3://daybreak/marts/daily_orders/")

Note mode("overwrite") - that is Spark's way of staying idempotent, exactly like CREATE OR REPLACE in DuckDB. The discipline travels across engines; the syntax does not.

Demo 1 of 2

Build the transform layer, end to end ★ 11 min · everyone builds

One idempotent script that takes Daybreak's raw source and produces a served daily-revenue table the warehouse could load. Clean the orders (typed date), join the order lines for revenue, aggregate to daily - every table built with CREATE OR REPLACE so the whole script re-runs safely.

Clean: rebuild clean_orders from the raw source with the text date cast to a real DATE.

Join: attach order_items so quantity and unit price become line revenue.

Aggregate: roll up to one row per day - the grain the warehouse wants.

Serve: the final SELECT is the table a warehouse loader would pick up.

-- CLEAN: fix the text date, keep only what we need
CREATE OR REPLACE TABLE clean_orders AS
SELECT order_id, customer_id,
       CAST(order_date AS DATE) AS order_date,
       status, channel
FROM orders;

-- JOIN: bring in line items to compute revenue the source never stored
CREATE OR REPLACE TABLE order_revenue AS
SELECT co.order_id, co.order_date, co.channel,
       SUM(oi.quantity * oi.unit_price) AS revenue
FROM clean_orders co
JOIN order_items oi ON co.order_id = oi.order_id
WHERE co.status = 'completed'
GROUP BY co.order_id, co.order_date, co.channel;

-- AGGREGATE: roll up to a daily grain
CREATE OR REPLACE TABLE daily_revenue AS
SELECT order_date AS day,
       count(*)              AS orders,
       ROUND(SUM(revenue),2) AS revenue
FROM order_revenue
GROUP BY order_date;

-- SERVE: what the warehouse would load
SELECT * FROM daily_revenue ORDER BY day LIMIT 10;
Real world

This layered shape is how transform code stays readable. Each table has one job - clean, join, aggregate - so when a number looks wrong you know exactly which layer to inspect. Real teams give these layers names (staging, intermediate, marts) and let tools like dbt manage the dependency order. The engine and the naming change; the clean-then-shape-then-serve spine you just built does not.

Demo 2 of 2

Your turn: extend it, then break it on purpose ★ 10 min · build your own

Q1 extends the aggregate. Q2 shows you the double-count bug that non-idempotent transforms cause - by running the mistake yourself. Q3 is a thinking exercise about when to leave DuckDB for Spark.

LiveQ1 · Add a channel dimension to the daily aggregate3 min
CREATE OR REPLACE TABLE daily_by_channel AS
SELECT CAST(o.order_date AS DATE) AS day,
       o.channel,
       ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
WHERE o.status = 'completed'
GROUP BY day, o.channel;

SELECT * FROM daily_by_channel ORDER BY day, channel LIMIT 12;
LiveQ2 · See the double-count bug for yourself4 min

This transform appends with INSERT instead of rebuilding. To simulate a pipeline that crashed and got retried, the same load fires twice. Run it and read row_copies: every day appears twice, so any revenue sum off this table is doubled. That is the bug idempotency prevents.

-- NON-idempotent: an empty shell we append into
CREATE TABLE daily_orders (day DATE, orders INTEGER);

-- First load
INSERT INTO daily_orders
SELECT CAST(order_date AS DATE), count(*)
FROM orders WHERE status = 'completed' GROUP BY 1;

-- The pipeline crashed and got re-run - the SAME insert fires again
INSERT INTO daily_orders
SELECT CAST(order_date AS DATE), count(*)
FROM orders WHERE status = 'completed' GROUP BY 1;

-- Every day is now duplicated - revenue would double-count
SELECT day, count(*) AS row_copies
FROM daily_orders GROUP BY day ORDER BY day LIMIT 8;
The fix in one line Swap the empty shell + two INSERTs for a single CREATE OR REPLACE TABLE daily_orders AS SELECT .... Now a retry rebuilds the table from source instead of stacking on top of it - re-run it ten times, the answer never moves.
Self-studyQ3 · When do you push the transform to Spark?3 min

No SQL - reason it through. You leave a single-node engine like DuckDB for a distributed one like Spark when the data no longer fits or no longer chews on one machine: roughly, when a single transform's input pushes past what one big box can hold in memory plus spill (call it the ~100 GB-and-up zone, though hardware keeps moving the line). Below that, a cluster is pure overhead - more ops, more cost, more moving parts, and usually slower for small data because of coordination. The honest default: start on one node, prove you have outgrown it with a real measurement, and only then distribute. Most Daybreak-sized workloads never leave the laptop. Distributed processing is exactly what b8 builds.

Homework

Try it yourself - this week ◐ 20-30 min total

Source material

Official sources covered

This session teaches the transform-compute core of the DeepLearning.AI Data Engineering Professional Certificate (Joe Reis) and Reis & Housley's Fundamentals of Data Engineering, run on a live engine instead of slides. This page covers:

DLAI Source Systems, Ingestion & Pipelines - M2: ETL vs ELTPart 1 · where the transform sits, and why ELT is the modern default
Fundamentals of Data Engineering (Reis & Housley) - transformationParts 1-2 · transform compute, idempotency, derived columns
DLAI Data Storage & Queries - M3: query life, columnar performancePart 3 · single-node vs distributed compute; full model in b8
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What is the difference between ELT and ETL?

ELT extracts, loads the raw data, then transforms it in place using the warehouse engine's power - keeping a raw copy. ETL transforms on a separate box before loading. ELT is today's default because the engine is now the cheapest, most powerful compute you have.

2 · What makes a transform idempotent?

An idempotent transform produces the same output no matter how many times it runs. Rebuilding the output with CREATE OR REPLACE is idempotent; appending with INSERT is not - a retry double-counts, exactly the bug Demo 2 Q2 shows.

3 · When do you move a transform from a single node (DuckDB) to distributed compute (Spark)?

A single modern machine handles data comfortably to around 100 GB. Below that, a cluster is pure overhead and often slower due to coordination. Distribute only once you have measured that one node can no longer cope. That is what b8 builds.

Builder session 5 cheat sheet · pin this

Transform stageRaw → useful: clean types, derive columns, join, aggregate to the grain the consumer wants.
ETLTransform BEFORE load, on a separate box. Older default; throws the raw away.
ELTLoad raw first, transform IN the engine. Modern default; keeps the raw copy, engine does the work.
IdempotentRe-run → same result. Use CREATE OR REPLACE, not INSERT-append. Pipelines crash and retry.
The double-count bugINSERT into a table twice on a retry = every row duplicated. CREATE OR REPLACE prevents it.
FILTER aggregatecount(*) FILTER (WHERE ...) answers several questions in one scan. Derive values, don't re-store raw.
Single nodeDuckDB on one machine handles data comfortably to ~100 GB. Most transforms never need more.
DistributedSpark across a cluster - only when data exceeds one machine. Same logic, more ops. That is b8.