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.
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.
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.
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.
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;
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_amountderives revenue that the source never stored. Deriving in the transform means every consumer downstream sees the same clean value. - FILTER aggregates:
count(*) FILTER (WHERE ...)andsum(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.
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.
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.
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.
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;
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.
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;
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.
Try it yourself - this week ◐ 20-30 min total
- Take Demo 1's script and add a fourth layer: a
customer_revenuetable (join throughorderstocustomers, aggregate byplan). Keep every tableCREATE OR REPLACE. - Rewrite Q2's broken append version as an idempotent one, then run it three times and confirm the count holds.
- Point at one transform in a pipeline you touch at work. Is it ETL or ELT? Is it idempotent - what happens if it runs twice?
- Estimate the biggest single input a transform of yours processes. Is it under ~100 GB? Then a single node is likely enough - is anyone paying for a cluster it does not need?
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:
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.