Why prep comes before pretty
Every BI horror story starts the same way: someone charted a table nobody had profiled. Duplicated keys double the revenue, a text column refuses to sum, a mystery NULL silently drops rows from a join. This session is the vaccine. You will profile every Daybreak table, meet the four kinds of dirt, and learn the six transforms that BI tools - Power Query, Tableau Prep, dbt - all implement under different names.
Profile before you touch 6 min live
Profiling is asking each column four questions: how many rows, how many distinct values, how many nulls, and what is the range? Power BI shows this in Power Query's column quality bar; Tableau shows it in the Data Interpreter and profile pane. We will compute it with SQL so you see exactly what those panes are doing. While profiling, you are hunting four dirt types: wrong types (a date stored as text), missing values, duplicates, and inconsistent categories ("Coffee" vs "coffee" vs "COFFEE").
LiveThe 10-minute profile routine4 min▶
One query pattern answers all four questions for a table. Run it on products, then edit it to profile orders (swap the table and the columns - try status and channel for the distincts).
SELECT COUNT(*) AS row_count,
COUNT(DISTINCT product_id) AS distinct_ids,
COUNT(DISTINCT category) AS distinct_categories,
SUM(CASE WHEN roast IS NULL THEN 1 ELSE 0 END) AS null_roasts,
MIN(price) AS min_price,
MAX(price) AS max_price
FROM products;
distinct_ids = row_count is the headline. When those two numbers match, the column can serve as a key. When they do not, and you assumed they did, every join against this table will fan out and inflate your measures. Ten minutes of this routine has saved careers.
Self-studyNULL is a fact, not dirt3 min read▶
In Daybreak, roast is NULL for every Equipment and Add-on product - and that is correct. A grinder has no roast level. This is the difference between absence of a value (the attribute does not apply) and missing data (the value exists in reality but never got recorded).
- Keep the NULL when the attribute genuinely does not apply. Filling it with "N/A" or "Unknown" pollutes the category list and skews distinct counts.
- Fill or fix when the value should exist: a missing order date, a blank city on a shipped order. Fill from the source system if you can; flag it if you cannot.
- Never fill blindly with zero. A NULL price averaged as 0 drags every mean down. NULL and 0 are different facts.
PL-300 phrases this as "evaluate and transform column data types" and "resolve inconsistencies, unexpected or null values" - note the verb is resolve, not remove. Sometimes resolving a null means documenting why it belongs there.
The transform toolbox: six moves 7 min live
Every prep tool ships hundreds of buttons, but real work is six moves: change type (text to date, text to number), split or derive a column (year out of a date, domain out of an email), filter rows, group and aggregate, pivot or unpivot (reshape wide to tall and back), and merge or append (combine two tables). The last pair is the most-confused pair in all of BI, so let us nail it: merge is a join - it adds COLUMNS by matching keys. Append is a union - it adds ROWS by stacking same-shaped tables.
LivePivot and unpivot - reshaping for the model4 min▶
Spreadsheets love wide data: one column per month, easy to read. BI models want tall data: one row per month, easy to filter and chart. Pivot goes tall to wide; unpivot goes wide to tall. Run both directions below - the first query builds a wide, spreadsheet-style row; the second is the tall shape the rest of this course lives in.
SELECT SUM(CASE WHEN strftime('%m', order_date) = '01' THEN 1 ELSE 0 END) AS jan_orders,
SUM(CASE WHEN strftime('%m', order_date) = '02' THEN 1 ELSE 0 END) AS feb_orders,
SUM(CASE WHEN strftime('%m', order_date) = '03' THEN 1 ELSE 0 END) AS mar_orders
FROM orders;
SELECT strftime('%Y-%m', order_date) AS month,
COUNT(*) AS orders
FROM orders
GROUP BY 1
ORDER BY 1;
Notice the tall version needs no edit when July arrives; the wide one needs a new column. That fragility is why unpivot is the single most common fix applied to spreadsheet exports before modeling.
Keys - the glue the model needs 4 min live
Next session you will relate tables into a star schema, and every relationship needs a key on both sides: unique on the one side, repeating on the many side. The rule that saves you: never trust a key you have not tested. A "unique" ID that quietly duplicates will fan out your joins and double your revenue - the ugliest bug in BI because every individual row still looks right.
LiveVerify uniqueness before you relate3 min▶
The uniqueness test: group by the candidate key and keep only groups with more than one row. An empty result is the good result - it means no duplicates. Run it, then edit it to test order_id in orders, and then try customer_id in orders (that one SHOULD return rows - it is the many side).
SELECT customer_id, COUNT(*) AS rows_per_id FROM customers GROUP BY customer_id HAVING COUNT(*) > 1;
Profile Daybreak end to end ★ 12 min · everyone builds
Run the routine across the whole warehouse. Three passes: how big is everything, what are the real category values, and which nulls mean something. By the end you will know this database better than most teams know their production warehouse.
Row counts per table. Always the first question: how much data am I holding? A UNION ALL of counts gives the whole warehouse in one result.
SELECT 'customers' AS table_name, COUNT(*) AS row_count FROM customers UNION ALL SELECT 'products', COUNT(*) FROM products UNION ALL SELECT 'orders', COUNT(*) FROM orders UNION ALL SELECT 'order_items', COUNT(*) FROM order_items UNION ALL SELECT 'subscriptions', COUNT(*) FROM subscriptions UNION ALL SELECT 'events', COUNT(*) FROM events;
Distinct statuses and channels in orders. Categorical columns hide surprises - see the real values before you filter or group by them. Consistent spelling here is dirt type four not happening.
SELECT 'status' AS field, status AS value, COUNT(*) AS orders FROM orders GROUP BY status UNION ALL SELECT 'channel', channel, COUNT(*) FROM orders GROUP BY channel ORDER BY field, orders DESC;
Nulls that mean something. Two null checks, two different verdicts: roast is NULL where roast does not apply (correct absence), and cancel_date is NULL where the subscription is still active (a null that IS the business logic).
SELECT category,
COUNT(*) AS products,
SUM(CASE WHEN roast IS NULL THEN 1 ELSE 0 END) AS null_roast
FROM products
GROUP BY category;
SELECT SUM(CASE WHEN cancel_date IS NULL THEN 1 ELSE 0 END) AS active_subs,
SUM(CASE WHEN cancel_date IS NOT NULL THEN 1 ELSE 0 END) AS cancelled_subs
FROM subscriptions;
The cancel_date pattern is everywhere. Employee end dates, ticket close times, loan payoff dates - "still open" is routinely encoded as NULL. Filter WHERE cancel_date IS NULL and you have the active book of business. Fill those nulls with a placeholder date and you have just cancelled every live customer in your reporting.
Your turn: three dirt hunts ★ 10 min · build your own
Three suspicious smells, three investigations. Each box is runnable as-is, but read the result and decide: is this dirt to fix, or a fact to keep? That judgment call is the actual skill.
LiveHunt 1 · Products nobody has ever ordered3 min▶
A LEFT JOIN keeps every product and leaves the order side NULL where no match exists - so WHERE oi.order_id IS NULL means "never ordered". Orphan rows like these are either dead catalog entries (dirt) or brand-new launches (fact).
SELECT p.product_id, p.name, p.category FROM products p LEFT JOIN order_items oi ON oi.product_id = p.product_id WHERE oi.order_id IS NULL;
LiveHunt 2 · unit_price vs catalog price3 min▶
order_items.unit_price sometimes differs from products.price. Dirt? No - a feature. The order line stores the price at the time of the order; the catalog stores the price now. Discounts and price changes live in that gap. Delete the "duplicate" column and you delete history.
SELECT p.name,
p.price AS catalog_price,
oi.unit_price AS price_at_order,
COUNT(*) AS order_lines
FROM order_items oi
JOIN products p ON p.product_id = oi.product_id
WHERE oi.unit_price != p.price
GROUP BY p.name, p.price, oi.unit_price
ORDER BY p.name;
Self-studyHunt 3 · Orders per status per month2 min▶
Cross two categoricals - status by month - and patterns pop that neither shows alone. Look closely at March 2026. Interesting, no? File it away: session b9 turns that observation into a full investigation of the revenue dip.
SELECT strftime('%Y-%m', order_date) AS month,
status,
COUNT(*) AS orders
FROM orders
GROUP BY 1, 2
ORDER BY 1, 2;
Try it yourself - this week ◐ 20-30 min total
- Run the 10-minute profile routine on one real table at work: row count, distinct count per interesting column, null counts, min/max on numerics. Write down one thing that surprised you.
- Write down that table's keys - and actually run the GROUP BY ... HAVING COUNT(*) > 1 test before declaring anything unique.
- Find one merge case in your own data (two tables that share a key and need combining side to side) and one append case (two same-shaped tables that need stacking). Naming them cements the distinction.
- Spot one NULL in your data and classify it: correct absence, business logic (like cancel_date), or genuinely missing. Decide keep, flag, or fix.
- Bring to b3: your profiled table. We will decide whether it is a fact or a dimension.
Official sources covered
The "Prepare the data" domain is 25-30% of the PL-300 exam - the largest slice alongside modeling. This page covers its concepts tool-agnostically; the Power Query and Tableau Prep click-paths stay with the vendors.
Three questions before you go 🎯 ◐ 90 seconds
1 · You have January orders and February orders as two identically-shaped tables and want one table. Merge or append?
Same columns + more rows = append (a union). Merge is for adding COLUMNS by matching a key across two different-shaped tables - there is nothing to match here.
2 · roast is NULL on every Equipment product. What should the prep step do?
The attribute does not apply, so NULL is the truthful value. Filling it invents a fake roast category; deleting rows throws away real products. Resolve means understand, not erase.
3 · Why test key uniqueness BEFORE relating two tables in the model?
Joins on duplicated keys happily succeed - and multiply rows. Revenue doubles, every row still looks valid, and nobody notices until the CFO does. The GROUP BY ... HAVING test costs ten seconds.