Skip to course content
Free SQL course

SQL for Data Analysis and AI

Unit 13.01: Building the analysis-ready dataset

Unit ID: SQL-M13-U02 - Estimated active time: 35-45 minutes Objective: assemble one clean, documented dataset that answers all eight questions.

Build it in stages, as CTEs

Module 6's discipline, applied to the whole deliverable:

WITH clean_customers AS (
  -- Normalisation rule, applied once (Module 9)
  SELECT customer_id,
         COALESCE(country, 'UNKNOWN')  AS country,
         LOWER(TRIM(city))             AS city
  FROM customers
),
order_base AS (
  -- One row per order, with customer attributes attached.
  -- Grain check: 1000 in, 1000 out - no fan-out (Module 5)
  SELECT o.order_id, o.status, o.order_total,
         CAST(o.placed_at AS DATE) AS placed_on,
         c.country, c.city
  FROM orders o
  JOIN clean_customers c ON c.customer_id = o.customer_id
),
item_rollup AS (
  -- Collapse the many-side BEFORE joining (Module 5)
  SELECT order_id, COUNT(*) AS item_count, SUM(price * quantity) AS item_value
  FROM order_items
  GROUP BY order_id
)
SELECT b.*,
       COALESCE(r.item_count, 0) AS item_count,
       COALESCE(r.item_value, 0) AS item_value
FROM order_base b
LEFT JOIN item_rollup r ON r.order_id = b.order_id;

Three named stages, each independently runnable, with the two riskiest decisions - normalisation and pre-aggregation - visible in the query rather than assumed.

Verify the grain immediately

-- fragment: runs inside the WITH block above
-- Must be 1000. If it is 3400, the item join was not pre-aggregated.
SELECT COUNT(*) FROM order_base;   -- 1000

This single check is the difference between the correct ₹27,01,463 and the inflated ₹91,47,789.

Answer the questions from the one dataset

-- fragment: runs inside the WITH block above
-- Q1 and Q2
SELECT COUNT(*)                                                        AS all_orders,      -- 1000
       COUNT(*) FILTER (WHERE status = 'completed')                    AS completed,       -- 988
       SUM(order_total)                                                AS booked_revenue,  -- 2701463.00
       SUM(order_total) FILTER (WHERE status = 'completed')            AS completed_revenue -- 2685905.00
FROM order_base;

Answering everything from one prepared dataset is what keeps the figures mutually consistent. Eight separately written queries will eventually disagree with each other.

Practice

Add a stage that flags orders paid in instalments, and confirm it does not change the row count.

Check your answer
-- fragment: one CTE, to be added to the WITH block above
payment_rollup AS (
  SELECT order_id, COUNT(*) AS payment_count, SUM(amount) AS paid
  FROM payments
  GROUP BY order_id
)

Join it with LEFT JOIN and re-check COUNT(*) is still 1,000. Joining payments directly without rolling up would produce 1,185 rows and inflate every total - the second fan-out direction from Module 5.

Takeaway

One documented dataset, built in named stages, with a grain check after every join. Every question is then answered from the same foundation.

---