Cleaning, deduplication, joins, enrichment, and type casting; row vs set-based logic and pushing work to the engine.
⏱ 15 min readTopics chapter readerLevel · Building & Orchestration
01 · Overview
Start here
Clean the rows, shape them, then add them up.
⏱ 4 min · Topic 1 of 11
A transformation changes data from one table into another table. It is the T in ETL (extract, transform, load) and ELT (extract, load, transform), which chapter 4 explained.
A wrong transformation often does not fail. It gives numbers that look fine and are wrong.
This chapter uses the same shop as the SQL chapters. In this chapter you will learn:
The three jobs of a transformation, and why their order matters.
How to clean messy values and keep one row per key.
How to join tables without losing rows.
How to add rows up into a metric.
Why one SQL query beats a row-by-row loop.
Where a transformation can run.
02 · The idea
Clean, shape, aggregate
clean, shape, aggregate.
⏱ 6 min · Topic 2 of 11
A transformation is one step that reads a table and builds a new table. A pipeline runs many of these steps, one after another.
Almost every step does one of three jobs. This table gives each job, with an example from the shop:
Job
The question
In the shop
Clean
Is each value valid?
Remove spaces, fix types, keep one row per order.
Shape
Which rows and columns do I need?
Keep paid orders. Add the country of each customer.
Aggregate
What is the number?
Paid revenue for each country.
To shape means to filter rows, and to join tables so each row gains new columns. To aggregate means to add many rows up into fewer rows, such as one per country. The next sections show each job.
The order matters. There are three reasons:
Clean before shape. A join on a key with a stray space finds no match.
Shape before aggregate. You cannot group by country until the country column is there.
Clean before aggregate. In chapter 2, one copied row gave 160.00 instead of 80.00.
Chapter 2 also named the layers. Cleaning builds silver tables, and aggregating builds gold tables. Chapter 4 showed where each step runs.
03 · Job 1
Making data valid
Clean each value once. Keep the rows you cannot fix.
⏱ 8 min · Topic 3 of 11
Cleaning makes each value valid. A valid value has the right type, one standard spelling and a real meaning.
Real data is messy. Here is a made-up raw table with the six orders, written with common faults. The query cleans it:
Clean text valuesworked example
SQL
Input data
raw_orders_text (made up: a messy copy of the orders)6 rows
The date of order 106 is NULL, because 2026-02-30 does not exist.
TRY_CAST works in Snowflake, SQL Server, Databricks and DuckDB. BigQuery calls it SAFE_CAST. PostgreSQL has no TRY_CAST.
The query makes five fixes:
TRIM removes spaces at the start and the end. Order 102 had a space after its amount.
LOWER makes all letters small. Order 101 (Paid) and order 102 (PAID) now say paid.
NULLIF(text, '') turns an empty text into NULL, which means missing. This fixes order 105.
CAST changes the type, here from text to a number with two decimals.
TRY_CAST changes text to a date, but gives NULL if the date is not real. In most warehouses, plain CAST stops the whole query with an error.
Do not guess a date for order 106. A reject table is a separate table that keeps the rows you could not fix.
Send the row there and count it. Then ask the source to fix it.
Clean each column once, in the silver layer. If three tables each clean the name, they will disagree one day.
Watch out
Never drop bad rows without a count. If 3 rows fail today and 3,000 fail tomorrow, someone must be told. The reject table and its count make this visible.
The next sections use the clean table, silver_orders. It holds the six real orders. The source fixed order 106, so its date is 2026-01-21.
04 · Job 1
One row per key
For each key, keep the newest row.
⏱ 10 min · Topic 4 of 11
Chapters 2 and 5 used DISTINCT. It removes copies that are exactly the same. Copies are not always the same.
A key is a column that is different for each real thing. In orders, the key is order_id.
Here is a made-up raw table where order 103 was loaded twice. It was paid on 2026-01-11 and refunded on 2026-01-12. A count of paid orders shows the problem:
Count paid orders in rawworked example
SQL
Input data
raw_order_changes (made up: order 103 changed status)7 rows
Only 4 orders are paid now. The old copy of order 103 counts too.
DISTINCT cannot help here, because the two copies of order 103 differ in status and updated_at. We need a rule: keep the newest row for each order_id.
ROW_NUMBER() gives each row a number inside its group. PARTITION BY order_id makes one group for each order. ORDER BY updated_at DESC puts the newest row first.
The newest row gets number 1, in a column called rn (short for row number):
Number the rows in each orderworked example
SQL
Input data
raw_order_changes (made up: order 103 changed status)7 rows
Every order has a row with rn = 1. For order 103, it is the refunded copy.
WHERE runs before ROW_NUMBER(), so WHERE cannot see rn. A subquery is a query inside round brackets. Put the numbering in a subquery, then filter outside it:
Keep the newest row for each orderworked example
SQL
Input data
raw_order_changes (made up: order 103 changed status)7 rows
Seven rows became six. Some warehouses support QUALIFY rn = 1, which removes the subquery.
Order 103 now says refunded. A count of paid orders on this table gives 4, which is correct.
Watch out
If two copies have the same updated_at, the database may pick either one. Add a second column to ORDER BY, such as a load number that only grows. Then the pick is the same on every run.
Interview question
"Keep the latest row for each key." Use ROW_NUMBER(). Partition by the key, order by the time with newest first, then keep number 1.
05 · Job 2
Filter, join, enrich
A join can lose rows or add rows.
⏱ 10 min · Topic 5 of 11
Shaping gets the rows and columns you need. A filter keeps some rows. To enrich is to add columns from another table.
Joins and relationships are taught in Joins & Relationship Reasoning. In short, a join puts two tables side by side where a key matches.
Asha moved from IN to DE on 2026-01-08, so customers now says DE for her. IN is India and DE is Germany.
A LEFT JOIN keeps every order, even when no customer matches.
This query keeps the paid orders and adds the name and country of each customer:
The three revenues add up to 160.50, the total of the four paid orders.
COALESCE gives the first value that is not NULL. Dev has no country, so order 106 is counted under unknown. Without COALESCE, GROUP BY would put it in a group with a NULL country.
This query uses each customer’s country from the customers table. Order 101 was placed while Asha lived in IN, but it counts under DE. Chapter 12, Incremental Processing & Backfills, shows how to use the country from the day of the order.
Now the same orders by day. This query counts every order, and adds up the paid amounts:
Orders and paid revenue for each dayworked example
Order 103 (refunded) and order 105 (pending) add 0.00.
CASE WHEN picks which rows to add. It works like an IF inside SUM.
The rule "revenue means paid orders only" is a business rule. Write it in one place, in the gold table. Then every report gives 160.50.
Watch out
If one team adds the refunded order too, its total is 220.50. Both teams see a number called revenue, and nothing fails.
07 · The habit
Row-by-row vs set-based thinking
Say what you want from the whole table, not how to loop.
⏱ 8 min · Topic 7 of 11
Row-by-row code visits one row at a time. Set-based code says what you want from all the rows together. The engine is the program that runs the query, such as the warehouse.
The goal is the total paid revenue of silver_orders. First, a Python loop. Here orders holds the six rows as groups of three values (Python calls them tuples): order_id, status and amount:
Both give 160.50. The loop says how to do the work, step by step. The query says what you want, and the engine decides how.
That freedom is the benefit. The engine can often split the rows across machines, or read only the columns it needs. A loop in your own code cannot be split without a rewrite.
Loops that call a database for each row are the worst case. To add the country to the 4 paid orders, a loop asks the database 4 times. The join earlier asked once and returned the same 4 rows.
Remember
A loop is fine for a small file, or for a step that must call an outside service for each row. For tables, start with one query.
Interview question
"Why not loop over the rows in Python?" Name the worst loop: one that calls the database once for each row.
08 · In practice
Where each transformation runs
Choose the layer first. Then choose the tool.
⏱ 7 min · Topic 8 of 11
Chapter 4 showed that in ELT, the transformation runs inside the warehouse. Here we look at what can run it. This table gives four choices:
Tool
What it is
It fits when
SQL in the warehouse
Queries you run inside the warehouse.
The data is already there, and SQL can say the logic.
dbt
A tool that runs saved SELECT files in the right order.
You have many SQL steps that depend on each other.
Spark
A tool that spreads work across many machines.
The data is too big for one machine.
A Python script
Plain code on one machine.
The data is small, or the logic is hard to write in SQL.
The logic stays the same. Here is the keep-the-newest step from earlier as a dbt model. A model is one SELECT in a file, and dbt builds a table or a view from it:
The same step as a dbt modelworked example
SQL
Input data
raw_order_changes (made up: order 103 changed status)7 rows
The window and the filter are the same ideas as in SQL. Only the spelling changes.
Whatever the tool, keep three rules for the layers:
Never edit raw. It is the copy you can rebuild from.
Clean once, in silver. Do not clean the same column again in gold.
Add up in gold. Put each business rule in one place.
Start with the simplest tool that fits.
09 · Recap
Recap
Clean, shape, aggregate, in that order.
⏱ 4 min · Topic 9 of 11
You can now follow the three jobs of a transformation. Remember these points:
The jobs are clean, shape and aggregate. The order matters, and a wrong order gives wrong numbers with no error.
Cleaning fixes spaces, case, types and NULLs. Keep rows you cannot fix, and count them.
To keep one row per key, number the rows with ROW_NUMBER() and keep number 1.
A LEFT JOIN keeps every row of the left table. An INNER JOIN can drop rows without any error.
Count the rows before and after every join.
A metric is a rule. Write it in one place.
Prefer one set-based query to a loop.
Try these on paper:
In raw_order_changes, order 104 is loaded a second time as refunded, one day later. After you keep the newest row for each order, what do the paid count and the paid revenue become? (Answer: 3 paid orders, and 145.00.)
Run the country query again, but remove order 106. Which rows remain? (Answer: DE with 65.00, and US with 15.50.)
Join paid orders to order_items, then COUNT(*). How many rows come back? (Answer: 6, because 101 and 106 have two items.)
10 · Practice
Practice Lab
Build the design, then check where rows could be lost.
⏱ 4 min · Topic 10 of 11
These exercises use the pipeline canvas. In each one, the bug is in the transformation step.
Some need chapters that come later. Those are marked optional.
How to practise
Start with "The key that does not match". It uses a join, which this chapter just taught.