Module 5 — Integration & Interview Mastery

You know SELECT, JOIN, subqueries, window functions and CTEs individually. This module is entirely about the skill interviewers actually test: reaching for the right combination, in the right order, under pressure — plus the production toolkit (GROUP BY/HAVING, CASE WHEN, dates, strings, UNION, NULLs, EXISTS, performance) that surrounds them in real ETL work.

Foundations

Why "combining" is the real skill

Every SQL interview question looks novel, but almost none of them are testing a technique you haven't seen. They're testing whether you can decompose a messy business question into a sequence of the five tools you already have, in the right order. "Find the second-highest-paid employee per department who joined in the last year and has never been late on a project" isn't a new concept — it's a JOIN to bring tables together, a WHERE to filter dates, a window function to rank within department, and a wrapper to pick rn = 2. The novelty is entirely in the assembly.

AnalogyThink of JOINs, subqueries, CTEs and window functions as five kitchen tools: a knife, a pan, an oven, a whisk, a thermometer. A cooking exam doesn't test "can you use a whisk" in isolation — it hands you a recipe and watches whether you reach for the right tool at the right step, in the right order. SQL interviews work the same way.

The good news: there are only a handful of recurring shapes these combinations take. Once you can recognize the shape, the specific column names stop mattering. This module builds that pattern recognition directly.

Foundations

The query-building mental model

Before writing a single line of SQL in an interview, run the question through this sequence out loud. It's the same five questions every time.

What's the grain of the final answer? One row per customer? Per order? Per customer-per-month? Get this wrong and every join downstream multiplies rows silently.
Which tables hold the facts I need, and how do they relate? This decides your JOINs — and whether a row can legitimately be missing (→ LEFT JOIN) or must always exist (→ INNER JOIN).
Do I need to compare a row to other rows in its own group? ("top N", "compared to previous", "% of group total", "running total") — that's a window function signal.
Do I need to filter based on the existence, absence, or aggregate of a related set of rows? ("customers who never ordered", "orders above their customer's average") — that's a subquery / EXISTS signal.
Does the logic need to happen in stages that build on each other? If step 3 needs the output of step 2, wrap each stage in a CTE instead of nesting — nesting is where correctness and readability both die.
Interview tipSay these five questions out loud as you work. Interviewers are grading your process at least as much as your final query — narrating "I need this at the customer grain, so I'll aggregate orders first in a CTE before joining to customers" earns credit even if your syntax has a typo.
The #1 grain mistakeJoining a one-row-per-customer table to a many-rows-per-customer orders table, then trying to compute a customer-level metric directly on the joined result, is the single most common source of wrong answers. Aggregate to the target grain first (in a CTE), then join — don't join first and hope GROUP BY cleans it up later.
Foundations

Shared schema for this module

Every example below uses this schema so nothing needs to be re-learned. It extends the customers / orders tables from earlier modules with order_items, products, and regions so joins have somewhere real to go.

customers
customer_idcustomer_nameregion_idsignup_date
101Ava Chen12023-02-11
102Marcus Lee22023-05-30
103Priya Nair12024-01-04
regions
region_idregion_name
1APAC
2EMEA
orders
order_idcustomer_idorder_datestatus
50011012024-03-01completed
50021012024-04-14completed
50031022024-04-02cancelled
order_items
order_idproduct_idquantityunit_price
50019001225.00
500290021140.00
products
product_idproduct_namecategory
9001NotebookStationery
9002MonitorElectronics

Revenue for an order is always quantity * unit_price summed across its order_items — this is deliberate, so every worked problem forces you to aggregate before you can talk about "order revenue" at all.

Level 2 · Combining

CTE + JOIN: aggregate first, join second

The most common production shape: pre-aggregate a many-rows table down to the target grain inside a CTE, then join it to a dimension table. This avoids fan-out (duplicated rows from a one-to-many join inflating a SUM).

WITH order_revenue AS (
    SELECT
        o.order_id,
        o.customer_id,
        SUM(oi.quantity * oi.unit_price) AS order_total
    FROM orders o
    JOIN order_items oi ON oi.order_id = o.order_id
    WHERE o.status = 'completed'
    GROUP BY o.order_id, o.customer_id
),
customer_revenue AS (
    SELECT customer_id, SUM(order_total) AS lifetime_value
    FROM order_revenue
    GROUP BY customer_id
)
SELECT c.customer_name, r.region_name, cr.lifetime_value
FROM customer_revenue cr
JOIN customers c ON c.customer_id = cr.customer_id
JOIN regions r   ON r.region_id  = c.region_id
ORDER BY cr.lifetime_value DESC;
The fan-out trapSkipping the first CTE and joining orders → order_items → customers directly, then running SUM(quantity*unit_price) with a GROUP BY customer_id, gives the same answer here only because each order has one item. The moment an order has multiple order_items rows, a naive join-then-aggregate is still usually fine for a straight SUM — but it silently breaks the instant you also try to COUNT(o.order_id) in the same query, because the order row is now duplicated once per item. Aggregate the grain you need in its own CTE before mixing counts and sums from different grains.
Level 2 · Combining

CTE + Window: Top-N per group, the canonical shape

This exact pattern — CTE to establish revenue at the right grain, window function to rank within a partition, outer query to filter the rank — is the single most-repeated shape in SQL interviews. Master this once and a huge fraction of "top N per group" questions become typing exercises.

WITH region_customer_revenue AS (
    SELECT
        c.region_id,
        c.customer_id,
        c.customer_name,
        SUM(oi.quantity * oi.unit_price) AS revenue
    FROM customers c
    JOIN orders o      ON o.customer_id = c.customer_id AND o.status = 'completed'
    JOIN order_items oi ON oi.order_id = o.order_id
    GROUP BY c.region_id, c.customer_id, c.customer_name
),
ranked AS (
    SELECT
        *,
        DENSE_RANK() OVER (
            PARTITION BY region_id ORDER BY revenue DESC
        ) AS rnk
    FROM region_customer_revenue
)
SELECT region_id, customer_name, revenue
FROM ranked
WHERE rnk <= 3
ORDER BY region_id, rnk;
Why the query can't do this in one passThe window function needs the aggregated revenue column to already exist before it can rank by it, and the outer filter needs rnk to already exist before it can filter by it. Both are new columns computed during the query, and logical processing order won't let you filter on something in the same clause that creates it — hence two layers: one CTE to build, one to rank, one final SELECT to filter.
Reach for DENSE_RANK vs ROW_NUMBER here

If two customers in APAC are tied for 3rd-highest revenue, DENSE_RANK lets both through as "rank 3" — you might return 4 rows for "top 3." ROW_NUMBER arbitrarily picks one. Ask the interviewer which behavior they want; naming this tradeoff unprompted is a strong signal.

Level 2 · Combining

Subquery vs JOIN vs EXISTS: how to actually choose

These three overlap heavily and interviewers love asking "could you also write this as a JOIN?" Know the decision rule cold.

Use a JOIN when...

You need columns from the other table in your output, or you're combining rows at a shared key.

Use EXISTS / NOT EXISTS when...

You only care whether a related row exists, not any of its columns, and especially when checking absence — NOT EXISTS handles NULLs correctly where NOT IN silently breaks.

-- "Customers who have never placed an order" — EXISTS is the safe, fast choice
SELECT c.customer_name
FROM customers c
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);

-- The equivalent LEFT JOIN version — useful when you ALSO need order columns
SELECT c.customer_name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;
NOT IN's NULL trapWHERE customer_id NOT IN (SELECT customer_id FROM orders) silently returns zero rows if even one row in the subquery has a NULL customer_id — the whole NOT IN list becomes unknown. NOT EXISTS has no such trap. Default to NOT EXISTS for "doesn't have a matching row" questions.

A correlated subquery in SELECT or WHERE (referencing the outer row) is usually a sign you could rewrite as a window function or a join — and a window function is almost always faster, because it avoids re-running the subquery once per outer row.

Level 2 · Combining

Multi-CTE pipelines: chain, don't nest

Once a question needs three or more transformation stages, chain CTEs in a straight line — each one reads only from the previous — instead of nesting subqueries three deep. It reads like a script, and it's how you'd actually build the equivalent pipeline in dbt or PySpark.

WITH completed_orders AS (
    SELECT * FROM orders WHERE status = 'completed'
),
order_totals AS (
    SELECT co.order_id, co.customer_id, co.order_date,
           SUM(oi.quantity * oi.unit_price) AS order_total
    FROM completed_orders co
    JOIN order_items oi ON oi.order_id = co.order_id
    GROUP BY co.order_id, co.customer_id, co.order_date
),
customer_monthly AS (
    SELECT customer_id,
           DATE_TRUNC('month', order_date) AS order_month,
           SUM(order_total) AS monthly_total
    FROM order_totals
    GROUP BY customer_id, DATE_TRUNC('month', order_date)
),
with_trend AS (
    SELECT *,
           LAG(monthly_total) OVER (
               PARTITION BY customer_id ORDER BY order_month
           ) AS prev_month_total
    FROM customer_monthly
)
SELECT customer_id, order_month, monthly_total,
       monthly_total - prev_month_total AS mom_change
FROM with_trend
ORDER BY customer_id, order_month;
Interview tipName each CTE after what it is, not what it does — customer_monthly, not step3. Interviewers read CTE names as a table of contents for your reasoning while you type.
Level 3 · Advanced

Correlated subquery vs window function, side by side

A classic "why would I use A over B" pairing. Both answer "how does this row compare to its group," but they differ in cost and flexibility.

-- Correlated subquery: re-runs once per outer row
SELECT o.order_id, o.customer_id, ot.order_total,
       (SELECT AVG(ot2.order_total)
        FROM order_totals ot2
        WHERE ot2.customer_id = ot.customer_id) AS customer_avg
FROM order_totals ot;

-- Window function: one pass over the data
SELECT order_id, customer_id, order_total,
       AVG(order_total) OVER (PARTITION BY customer_id) AS customer_avg
FROM order_totals;

Both return identical results. The window version is computed in a single pass by the engine, while the correlated subquery conceptually re-executes for every outer row — on large tables this is a real, measurable difference, and it's why "rewrite this correlated subquery as a window function" is a common follow-up question.

Production Toolkit

GROUP BY & HAVING

WHERE filters rows before grouping; HAVING filters groups after aggregation. This is a logical-order fact, not a style choice — you cannot reference an aggregate like SUM(order_total) in WHERE because it doesn't exist yet at that stage.

SELECT customer_id, COUNT(*) AS order_count, SUM(order_total) AS total_spend
FROM order_totals
WHERE order_date >= '2024-01-01'      -- row-level filter, before grouping
GROUP BY customer_id
HAVING SUM(order_total) > 500;        -- group-level filter, after aggregation
Common mistakePutting SUM(order_total) > 500 in WHERE throws an error in most engines — aggregates aren't valid there. This trips people up specifically because window functions (which look similar) also can't go in WHERE, but for the same underlying reason: both are computed later than WHERE runs.
Production Toolkit

CASE WHEN: conditional logic inline

CASE WHEN is how you bucket, pivot, or branch logic without leaving SQL. It's evaluated top to bottom and stops at the first match, like an if/elif chain.

SELECT
    customer_id,
    total_spend,
    CASE
        WHEN total_spend >= 1000 THEN 'VIP'
        WHEN total_spend >= 300  THEN 'Regular'
        ELSE 'New'
    END AS customer_tier
FROM customer_revenue;

-- Conditional aggregation: CASE WHEN inside SUM/COUNT is the standard "pivot" pattern
SELECT
    region_id,
    SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed_orders,
    SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled_orders
FROM orders o JOIN customers c ON c.customer_id = o.customer_id
GROUP BY region_id;
Why this matters in interviews"Conditional aggregation" — CASE WHEN nested inside SUM/COUNT — is how you turn long/tall data into wide/pivoted columns without a database-specific PIVOT function. It's portable across every SQL dialect, which is exactly why interviewers prefer it over vendor-specific syntax.
Production Toolkit

Date, timestamp & string functions

Names vary by engine (Snowflake, Postgres, MySQL, BigQuery) but the concepts are universal. Know the concept and look up the exact function name for whatever dialect the interview specifies.

Common date/time operations
NeedTypical function
Truncate to month/week/dayDATE_TRUNC('month', col)
Difference between two datesDATEDIFF(day, start, end)
Add/subtract an intervalDATEADD(day, 30, col)
Extract part of a dateEXTRACT(year FROM col)
Current date/timeCURRENT_DATE, CURRENT_TIMESTAMP
Common string operations
NeedTypical function
ConcatenateCONCAT(a, b) or a || b
SubstringSUBSTRING(col, start, len)
Pattern matchcol LIKE '%term%'
Trim/caseTRIM(), UPPER(), LOWER()
Split into partsSPLIT_PART(col, delim, n)
-- "Customers acquired in each of the last 6 months" — date bucketing + window in one query
SELECT
    DATE_TRUNC('month', signup_date) AS cohort_month,
    COUNT(*) AS new_customers
FROM customers
WHERE signup_date >= DATEADD(month, -6, CURRENT_DATE)
GROUP BY DATE_TRUNC('month', signup_date)
ORDER BY cohort_month;
Production Toolkit

UNION / UNION ALL, NULL handling, EXISTS

UNION deduplicates and is slower (it has to sort/hash to find duplicates); UNION ALL keeps everything and is faster. Default to UNION ALL unless you specifically need deduplication — this is a frequent "do you know the performance implication" check.

SELECT customer_id, 'order' AS source, order_date AS event_date FROM orders
UNION ALL
SELECT customer_id, 'signup' AS source, signup_date AS event_date FROM customers;

COALESCE(a, b, c) returns the first non-NULL argument — the standard way to supply defaults. NULLIF(a, b) returns NULL if a = b, otherwise a — most commonly used to avoid divide-by-zero errors.

SELECT
    customer_id,
    COALESCE(phone, email, 'no contact on file') AS contact,
    total_spend / NULLIF(order_count, 0) AS avg_order_value
FROM customer_summary;
Interview tiptotal / NULLIF(count, 0) is a one-liner interviewers specifically look for when a question involves any division — it silently prevents a runtime error instead of wrapping the whole query in a CASE WHEN count = 0 check.
Performance

Basic performance awareness

  • Filter before you join or aggregate — a WHERE that narrows rows early reduces the work every later stage has to do.
  • Don't join tables you don't need — every unused join is wasted I/O and a fan-out risk; only join what the output or a filter actually requires.
  • Prefer window functions over correlated subqueries for "compare row to its group" logic — one pass beats one-subquery-execution-per-row.
  • Aggregate to the smallest necessary grain before joining — join a pre-aggregated CTE to a small dimension table rather than joining raw fact tables and aggregating after.
  • UNION ALL over UNION unless you need deduplication.
  • Avoid functions on indexed/join columns in WHERE (e.g. WHERE YEAR(order_date) = 2024) — this can prevent the engine from using an index; prefer a range: WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'.
Say this in interviewsYou don't need to know your interviewer's exact query planner. Naming these principles out loud — "I'd filter to completed orders before joining, to keep the join smaller" — demonstrates production judgment even when the interviewer never asks about performance directly.
Level 4 · Full Interview Problem

Q1: Top spender per region, tie-safe Hard

Question: "For each region, find the customer with the highest total completed-order revenue. If there's a tie, include all tied customers."

Toolkit used
JOINGROUP BYCTEWindow (RANK)
WITH revenue_by_customer AS (
    SELECT
        c.region_id,
        c.customer_id,
        c.customer_name,
        SUM(oi.quantity * oi.unit_price) AS total_revenue
    FROM customers c
    JOIN orders o       ON o.customer_id = c.customer_id AND o.status = 'completed'
    JOIN order_items oi ON oi.order_id = o.order_id
    GROUP BY c.region_id, c.customer_id, c.customer_name
),
ranked AS (
    SELECT *,
           RANK() OVER (PARTITION BY region_id ORDER BY total_revenue DESC) AS rnk
    FROM revenue_by_customer
)
SELECT region_id, customer_name, total_revenue
FROM ranked
WHERE rnk = 1;

Why RANK, not ROW_NUMBER: the question explicitly says "include all tied customers" — RANK gives tied top rows the same rank 1, so WHERE rnk = 1 naturally returns every tie. ROW_NUMBER would arbitrarily drop all but one.

Level 4 · Full Interview Problem

Q2: Customers with no orders in the last 90 days Hard

Question: "Find customers who placed at least one order historically, but have made no orders in the last 90 days — flag them for a re-engagement campaign."

Toolkit used
EXISTSNOT EXISTSDate functions
SELECT c.customer_id, c.customer_name
FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
)
AND NOT EXISTS (
    SELECT 1 FROM orders o
    WHERE o.customer_id = c.customer_id
      AND o.order_date >= DATEADD(day, -90, CURRENT_DATE)
);

Why two EXISTS clauses, not one: the question has two separate conditions — "has ordered before, ever" and "hasn't ordered recently." Collapsing this into a single subquery with a date filter would silently exclude customers who never ordered at all, which is a different segment than the one asked for.

Level 4 · Full Interview Problem

Q3: Month-over-month revenue growth by category Hard

Question: "For each product category, show monthly revenue and the percent change from the previous month."

Toolkit used
JOINMulti-CTEDate_truncLAGNULLIF
WITH item_revenue AS (
    SELECT
        p.category,
        DATE_TRUNC('month', o.order_date) AS order_month,
        oi.quantity * oi.unit_price AS line_revenue
    FROM order_items oi
    JOIN orders o    ON o.order_id = oi.order_id AND o.status = 'completed'
    JOIN products p  ON p.product_id = oi.product_id
),
monthly_category AS (
    SELECT category, order_month, SUM(line_revenue) AS revenue
    FROM item_revenue
    GROUP BY category, order_month
),
with_prev AS (
    SELECT *,
           LAG(revenue) OVER (PARTITION BY category ORDER BY order_month) AS prev_revenue
    FROM monthly_category
)
SELECT
    category, order_month, revenue,
    ROUND(100.0 * (revenue - prev_revenue) / NULLIF(prev_revenue, 0), 1) AS pct_change
FROM with_prev
ORDER BY category, order_month;

Note the layering: join+aggregate to get monthly revenue at the category grain, then apply LAG — you cannot compute LAG(revenue) until revenue itself exists as a column, which is exactly the "build, then rank/compare" shape from the Top-N pattern earlier in this module.

Level 4 · Full Interview Problem

Q4: Repeat purchase rate within 30 days Hard

Question: "Of customers who placed a first order, what percent placed a second order within 30 days of their first?"

Toolkit used
Window (ROW_NUMBER)Self-join via CTECASE WHENConditional aggregation
WITH numbered_orders AS (
    SELECT
        customer_id, order_id, order_date,
        ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date) AS order_seq
    FROM orders
    WHERE status = 'completed'
),
first_orders AS (
    SELECT customer_id, order_date AS first_order_date
    FROM numbered_orders WHERE order_seq = 1
),
second_orders AS (
    SELECT customer_id, order_date AS second_order_date
    FROM numbered_orders WHERE order_seq = 2
)
SELECT
    ROUND(100.0 * SUM(CASE WHEN DATEDIFF(day, f.first_order_date, s.second_order_date) <= 30
                            THEN 1 ELSE 0 END) / COUNT(*), 1) AS repeat_rate_pct
FROM first_orders f
LEFT JOIN second_orders s ON s.customer_id = f.customer_id;

Why LEFT JOIN: customers with no second order must still count in the denominator (COUNT(*)) — an inner join would silently drop them and inflate the rate. Their DATEDIFF comparison naturally evaluates to NULL/false via the CASE WHEN, contributing 0 to the numerator without special-casing.

Level 5 · FAANG

Revenue funnel drop-off by stage FAANG

Question: "Users move through stages: viewedadded_to_cartpurchased, logged as events with a timestamp. For each stage, report how many distinct users reached it and the drop-off percentage from the previous stage."

Approach

This is a conditional-aggregation problem dressed up as a funnel. Each user's max stage reached determines which later stages they "reached" — model it with MAX(CASE WHEN ...) flags per user, then aggregate.

WITH user_stage_flags AS (
    SELECT
        user_id,
        MAX(CASE WHEN event_type = 'viewed'         THEN 1 ELSE 0 END) AS reached_view,
        MAX(CASE WHEN event_type = 'added_to_cart'   THEN 1 ELSE 0 END) AS reached_cart,
        MAX(CASE WHEN event_type = 'purchased'       THEN 1 ELSE 0 END) AS reached_purchase
    FROM funnel_events
    GROUP BY user_id
),
stage_counts AS (
    SELECT
        SUM(reached_view)     AS viewed,
        SUM(reached_cart)     AS added_to_cart,
        SUM(reached_purchase) AS purchased
    FROM user_stage_flags
)
SELECT
    'viewed' AS stage, viewed AS users, NULL AS dropoff_pct FROM stage_counts
UNION ALL
SELECT
    'added_to_cart', added_to_cart,
    ROUND(100.0 * (viewed - added_to_cart) / NULLIF(viewed, 0), 1)
FROM stage_counts
UNION ALL
SELECT
    'purchased', purchased,
    ROUND(100.0 * (added_to_cart - purchased) / NULLIF(added_to_cart, 0), 1)
FROM stage_counts;

What this tests: whether you'll reach for conditional aggregation instead of trying to self-join the events table three times, and whether you know UNION ALL is the clean way to reshape a single wide row of stage counts into a tall stage-by-stage report.

Level 5 · FAANG

Churn-risk customers, multi-signal FAANG

Question: "Flag customers as churn-risk if: (a) their most recent order is more than 60 days old, AND (b) their order frequency has slowed — their average days-between-orders in the last 3 orders is more than double their historical average."

Approach

Break the two conditions into separate CTEs so each stays testable in isolation, then combine with a final join/filter — resist the urge to write this as one giant nested query.

WITH ordered AS (
    SELECT customer_id, order_date,
           ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn_desc,
           LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_date
    FROM orders WHERE status = 'completed'
),
gaps AS (
    SELECT customer_id, order_date, rn_desc,
           DATEDIFF(day, prev_date, order_date) AS gap_days
    FROM ordered WHERE prev_date IS NOT NULL
),
recent_gap_avg AS (
    SELECT customer_id, AVG(gap_days) AS recent_avg_gap
    FROM gaps WHERE rn_desc <= 3
    GROUP BY customer_id
),
historical_gap_avg AS (
    SELECT customer_id, AVG(gap_days) AS historical_avg_gap
    FROM gaps
    GROUP BY customer_id
),
last_order AS (
    SELECT customer_id, MAX(order_date) AS last_order_date
    FROM orders WHERE status = 'completed'
    GROUP BY customer_id
)
SELECT lo.customer_id
FROM last_order lo
JOIN recent_gap_avg r     ON r.customer_id = lo.customer_id
JOIN historical_gap_avg h ON h.customer_id = lo.customer_id
WHERE DATEDIFF(day, lo.last_order_date, CURRENT_DATE) > 60
  AND r.recent_avg_gap > 2 * h.historical_avg_gap;
Interview tipWhen a question has multiple independent conditions like this, build one CTE per condition and join them at the end. It's slower to type than one mega-query, but it's dramatically easier to debug live, and interviewers explicitly reward incremental, checkable steps over a single opaque block.
Production Data Engineering

SCD Type 2 + dedup + incremental load, combined

Production pipelines chain these same five tools into a repeatable pattern: dedupe a raw landing table, detect what changed since the last load, and write history-preserving rows. Here's the shape end to end.

WITH deduped_source AS (
    -- Step 1: dedup — keep the latest row per natural key from a raw/staging load
    SELECT *
    FROM (
        SELECT s.*,
               ROW_NUMBER() OVER (
                   PARTITION BY customer_id ORDER BY loaded_at DESC
               ) AS rn
        FROM staging_customers s
    ) t
    WHERE rn = 1
),
changed_records AS (
    -- Step 2: incremental — only rows whose tracked attributes differ from the current dim row
    SELECT ds.*
    FROM deduped_source ds
    JOIN dim_customers dc ON dc.customer_id = ds.customer_id AND dc.is_current = TRUE
    WHERE ds.email <> dc.email OR ds.region_id <> dc.region_id
),
new_records AS (
    -- Step 3: brand-new customers with no current dim row at all
    SELECT ds.*
    FROM deduped_source ds
    LEFT JOIN dim_customers dc ON dc.customer_id = ds.customer_id AND dc.is_current = TRUE
    WHERE dc.customer_id IS NULL
)
-- Step 4 (outside this SELECT, in the surrounding pipeline):
--   UPDATE dim_customers SET is_current = FALSE, valid_to = CURRENT_DATE
--     WHERE customer_id IN (SELECT customer_id FROM changed_records);
--   INSERT INTO dim_customers SELECT ... FROM changed_records UNION ALL SELECT ... FROM new_records;
SELECT * FROM changed_records
UNION ALL
SELECT * FROM new_records;

Notice this is the exact same toolbox as the interview problems above — ROW_NUMBER for dedup, a LEFT JOIN to detect absence, a comparison join to detect change, UNION ALL to combine — just aimed at a pipeline instead of a report. This is the real payoff of mastering combinations: interview SQL and production SQL are the same skill wearing different clothes.

Interview Strategy

A repeatable framework for any SQL interview question

Restate the question as a grain. "So the output should be one row per X" — say it out loud, confirm with the interviewer.
Sketch the tables and joins on paper/whiteboard before typing. Mark which joins are INNER (must match) vs LEFT (may be absent).
Identify any "compare to group" language — top N, running total, previous value, percent of total — these are window functions.
Identify any "existence/absence" language — never, always, at least one, none — these are EXISTS/NOT EXISTS.
Build in stages with CTEs, testing each stage mentally (or actually, if you have a scratch environment) before adding the next.
Sanity-check edge cases out loud — ties, NULLs, empty groups, divide-by-zero — even if you don't have time to handle every one in code, naming them is worth real credit.
🧭
If you get stuck mid-question, go back to step 1. The single most common reason a query "won't come together" is that the grain was never nailed down — everything downstream inherits that ambiguity.
Interview Strategy

Rapid-fire Q&A

1.How do you decide between a CTE and a subquery?

Functionally, for a single reference, they're often interchangeable. Reach for a CTE the moment you'd reuse the same derived result more than once, or the moment nesting would go more than one level deep — CTEs read top-to-bottom like a script, nested subqueries read inside-out.

2.When would you prefer a window function over GROUP BY?

When you need the aggregate alongside the detail rows, not instead of them. GROUP BY collapses to one row per group; a window function keeps every row and attaches the group-level value to each one — needed whenever the output grain is still "per transaction" but a value like "% of customer total" is per group.

3.Why does mixing GROUP BY and window functions in one query sometimes look wrong?

Window functions run after GROUP BY/HAVING in logical order, so they see the already-grouped rows — this is actually useful (e.g. ranking group totals), but it surprises people who expect window functions to see raw, ungrouped data.

4.What's the fastest way to spot a fan-out bug in your own query?

Check row counts at each join step. If joining a "one row per order" table to a "many rows per order" table and a COUNT or SUM looks too large, the join multiplied rows before the aggregate ran. Aggregate to the target grain in its own CTE first.

5.How do you explain your query-writing process if the interviewer asks you to narrate?

Walk the five-question mental model from this module: grain → tables/joins → compare-to-group signals → existence signals → staging with CTEs. Naming which signal you noticed and why is exactly what's being evaluated.

6.Why prefer UNION ALL by default?

UNION has to compare every row against every other row to deduplicate, which typically requires a sort or hash step. If you already know the two result sets can't overlap — or duplicates are fine/expected — UNION ALL skips that cost entirely.

7.What's the giveaway that a problem wants conditional aggregation instead of a self-join?

When you're turning categories of one column into separate output columns (e.g. counts of each order status side by side) rather than combining separate physical rows. SUM(CASE WHEN ...) reshapes in place; a self-join is for combining genuinely different rows.

Before you say NEXT

Self-check before you move on

You should be able to do every one of these without looking back at the module:

  • State the five-question mental model from memory and apply it out loud to a new, unfamiliar question.
  • Write the CTE-aggregate-then-join pattern to avoid fan-out, and explain why joining before aggregating can silently inflate a SUM or COUNT.
  • Write the CTE + window-function Top-N-per-group pattern from memory, and justify RANK vs DENSE_RANK vs ROW_NUMBER for a given tie requirement.
  • Explain when to use EXISTS/NOT EXISTS over a JOIN or NOT IN, including the NULL trap with NOT IN.
  • Write conditional aggregation (SUM(CASE WHEN ...)) to pivot categories into columns.
  • Explain why WHERE can't filter on an aggregate or a window-function alias, and what to do instead.
  • Rebuild at least two of the four worked interview problems from memory, narrating your reasoning as you go.
  • Describe how the same toolkit (dedup via ROW_NUMBER, absence via LEFT JOIN, change detection via a comparison JOIN, UNION ALL to combine) shows up in a production SCD2/incremental pipeline.

If any of those feel shaky, jump back using the sidebar — every example in this module shares the same customer/order/product schema, so nothing needs to be re-learned from scratch. Once these are solid, you've completed the SQL Mastery core track.