Module 2: SQL
This is the meatiest module in the Data Analyst track, so I’ll go topic by topic with annotated code and the talking points that separate “knows SQL” from “architect-level SQL.”
1. SELECT, Filtering, Sorting, Aggregation Fundamentals
The senior-level point here isn’t the syntax — it’s knowing the logical order of execution, because that’s what explains every “why doesn’t this work” SQL bug:
FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT/OFFSET
This ordering is why:
- You can’t filter on an aggregate in
WHERE(it doesn’t exist yet) — you needHAVING - You can’t reference a
SELECTalias inWHERE(most engines) but you can inORDER BY WHEREfilters rows before grouping (cheap, early);HAVINGfilters groups after aggregation (expensive, late) — so always push filters intoWHEREwhen possible, even if logically they “feel” like a HAVING.
SELECT
customer_id,
COUNT(*) AS order_count,
SUM(order_amount) AS total_spend
FROM orders
WHERE order_date >= '2026-01-01' -- filters rows first (cheap)
GROUP BY customer_id
HAVING SUM(order_amount) > 1000 -- filters groups after aggregation
ORDER BY total_spend DESC
LIMIT 10;
Interview talking point: “A common mistake junior analysts make is filtering on an aggregate in WHERE — it’ll throw an error — or pushing a filterable WHERE condition into HAVING, which forces the engine to aggregate rows it should’ve discarded early. I always ask: can this filter happen before grouping? If yes, it belongs in WHERE.”
NULL handling nuance (frequently tested): COUNT(*) counts all rows; COUNT(column) skips NULLs; aggregates like SUM/AVG silently ignore NULLs (not treat them as 0) — this trips people up in average calculations where NULL ≠ 0.
2. Joins — Inner / Left / Right / Full / Self
| Join type | Returns | When to use |
|---|---|---|
| INNER | Only rows with matches in both tables | Default for “give me records that definitely relate” — e.g., orders that have a valid customer |
| LEFT | All rows from left + matches from right (NULLs if no match) | “Keep everything from my anchor table” — e.g., all customers, even those with zero orders (classic for finding non-converters) |
| RIGHT | Mirror of LEFT | Rare in practice — most people just flip table order and use LEFT; RIGHT JOIN existing in a codebase is often a readability smell |
| FULL OUTER | All rows from both, matched where possible | Reconciliation tasks — e.g., comparing two systems’ records to find mismatches on both sides |
| SELF | Table joined to itself | Hierarchies (employee → manager), sequential comparisons (this row vs. previous row before window functions existed) |
-- LEFT JOIN to find customers with NO orders (classic interview question)
SELECT c.customer_id, c.customer_name
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL; -- the "anti-join" pattern
-- SELF JOIN for employee-manager hierarchy
SELECT e.employee_name, m.employee_name AS manager_name
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.employee_id;
The anti-join pattern is the #1 join-related interview question — “find customers who never ordered,” “find products never sold,” “find users who signed up but never logged in.” The pattern is always: LEFT JOIN + WHERE right.key IS NULL.
Talking point on join cardinality: “Before writing a join, I always ask: what’s the cardinality relationship — one-to-one, one-to-many, many-to-many? A many-to-many join without an intermediate aggregation is the most common cause of silently duplicated/inflated totals in production dashboards — the query runs fine, returns no error, and just quietly multiplies your revenue numbers.” This is genuinely one of the most common real-world bugs and a great thing to volunteer in an interview.
3. Window Functions — RANK, LEAD/LAG, Running Totals, Moving Averages
Window functions are the single highest-leverage SQL topic for analyst interviews because they replace what used to require self-joins or subqueries, and they signal real fluency.
Syntax anatomy:
function() OVER (PARTITION BY ... ORDER BY ... [frame clause])
RANK / DENSE_RANK / ROW_NUMBER — the classic “who’s confused which one” interview trap:
SELECT
customer_id,
order_amount,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_amount DESC) AS rn,
RANK() OVER (PARTITION BY customer_id ORDER BY order_amount DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY customer_id ORDER BY order_amount DESC) AS drnk
FROM orders;
ROW_NUMBER— always unique, no ties (1,2,3,4)RANK— ties share rank, gaps after (1,2,2,4)DENSE_RANK— ties share rank, no gaps (1,2,2,3)
Interview talking point: “If asked for the ‘top 3 orders per customer,’ the right tool is ROW_NUMBER if you want exactly 3 regardless of ties, or RANK if ties should all be included — that distinction is exactly what interviewers are probing when they ask this.”
LEAD/LAG — comparing a row to the previous/next row without a self-join:
SELECT
customer_id,
order_date,
order_amount,
LAG(order_amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_order_amount,
order_amount - LAG(order_amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS delta
FROM orders;
Running totals and moving averages — frame clause is the key concept:
SELECT
order_date,
daily_revenue,
SUM(daily_revenue) OVER (ORDER BY order_date) AS running_total,
AVG(daily_revenue) OVER (ORDER BY order_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS rolling_7day_avg
FROM daily_sales;
Talking point on frame clauses: “The default frame for ORDER BY without an explicit ROWS/RANGE clause is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — which behaves unexpectedly with tied ORDER BY values. I always specify the frame explicitly (ROWS BETWEEN…) when I need precise row-based windows like a 7-day moving average, rather than relying on the default.” This is a genuinely senior-level detail most candidates miss.
4. Subqueries vs CTEs vs Temp Tables
| Subquery | CTE (WITH) |
Temp Table | |
|---|---|---|---|
| Readability | Degrades fast when nested | High — named, sequential, top-to-bottom readable | High, but adds DDL overhead |
| Reusability in same query | No (re-evaluated/duplicated if reused) | Yes — referenced multiple times in same query | Yes, and across multiple queries in a session |
| Materialization | Engine-dependent, usually inlined | Engine-dependent — Postgres pre-17 always materialized CTEs; Postgres 17+ and most modern engines may inline them like subqueries | Always materialized (actual storage, actual stats) |
| Indexing | Not possible | Not possible | Possible — can index a temp table for a complex multi-step pipeline |
| Best for | Simple, one-off, single-use filters | Multi-step logical pipelines, recursive logic | Heavy reuse across many subsequent queries, very large intermediate datasets |
Recursive CTE (commonly tested for hierarchy/graph problems):
WITH RECURSIVE org_chart AS (
SELECT employee_id, employee_name, manager_id, 1 AS level
FROM employees
WHERE manager_id IS NULL -- anchor: the CEO
UNION ALL
SELECT e.employee_id, e.employee_name, e.manager_id, oc.level + 1
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.employee_id
)
SELECT * FROM org_chart ORDER BY level;
Talking point on the materialization myth: “There’s a common assumption that CTEs are always materialized as a performance optimization — that was true historically in Postgres before version 12, but modern Postgres can inline a CTE just like a subquery unless you force materialization with the MATERIALIZED keyword. I don’t assume CTE = automatic performance boundary anymore; I check the execution plan.” This is a great “I stay current” line for an interview.
5. GROUP BY / HAVING Nuances, Set Operations
GROUP BY nuances:
- Every non-aggregated column in
SELECTmust appear inGROUP BY(strict SQL standard — MySQL historically allowed exceptions, which caused production bugs when ported to Postgres/SQL Server). GROUP BYwith multiple columns creates one group per combination — a frequent interview trap is expecting per-column subtotals (ROLLUP/CUBEterritory).
-- ROLLUP for subtotals + grand total (common in BI reporting queries)
SELECT region, product_category, SUM(revenue)
FROM sales
GROUP BY ROLLUP(region, product_category);
Set operations:
| Operator | Behavior | Note |
|---|---|---|
UNION |
Combines, removes duplicates | Forces a dedup pass — slower |
UNION ALL |
Combines, keeps duplicates | Always prefer this unless you specifically need dedup — major performance habit |
INTERSECT |
Rows present in both queries | Useful for “users who did A AND B” |
EXCEPT (or MINUS in Oracle) |
Rows in first query, not in second | Useful for “users who did A but NOT B” — funnel drop-off pattern |
Talking point: “I default to UNION ALL over UNION unless duplicates are a real risk, because UNION forces an implicit DISTINCT across the full result set, which is an expensive sort/hash step that’s easy to forget about until it shows up in a slow query log.”
6. Query Optimization Basics
Indexes:
- A B-tree index speeds up equality and range lookups on the indexed column(s), at the cost of slower writes (every INSERT/UPDATE must also update the index).
- Column order in composite indexes matters — an index on
(customer_id, order_date)serves queries filtering oncustomer_idalone orcustomer_id + order_date, but notorder_datealone (left-prefix rule). This is a very commonly tested nuance. - Indexes help filtering/sorting but don’t help if the query wraps the column in a function (
WHERE YEAR(order_date) = 2026can’t use a plain index onorder_date; rewrite as a range:WHERE order_date >= '2026-01-01' AND order_date < '2027-01-01').
Execution plans:
EXPLAIN(orEXPLAIN ANALYZEin Postgres) shows whether the engine is doing a Seq Scan (full table scan — red flag on large tables) vs. an Index Scan/Index Only Scan (good), and reveals join strategy (Nested Loop vs. Hash Join vs. Merge Join).- Talking point: “On a table with millions of rows, a Nested Loop join where I’d expect a Hash Join is usually a sign that the planner’s row-count estimate is wrong — often because table statistics are stale. I’d run ANALYZE on the table before assuming I need to rewrite the query.” This is a genuinely senior diagnostic instinct.
Avoiding SELECT *:
- Forces the engine to fetch all columns even when unused — defeats index-only scans (where the index alone could answer the query without touching the table) and increases I/O and network transfer unnecessarily.
- Also a maintainability hazard: schema changes (added columns) silently change downstream output shape.
7. Common SQL Interview Problems — Cohort, Retention, Funnel
These three patterns cover the vast majority of “real” analyst SQL interview questions, because they combine joins + window functions + date logic.
Cohort analysis (group users by signup month, track behavior over time):
WITH cohorts AS (
SELECT
user_id,
DATE_TRUNC('month', MIN(signup_date)) AS cohort_month
FROM users
GROUP BY user_id
),
activity AS (
SELECT
c.cohort_month,
DATE_TRUNC('month', a.activity_date) AS activity_month,
COUNT(DISTINCT a.user_id) AS active_users
FROM cohorts c
JOIN user_activity a ON c.user_id = a.user_id
GROUP BY 1, 2
)
SELECT
cohort_month,
activity_month,
active_users,
DATE_PART('month', age(activity_month, cohort_month)) AS months_since_signup
FROM activity
ORDER BY cohort_month, months_since_signup;
Retention (N-day / N-month retention rate):
WITH first_seen AS (
SELECT user_id, MIN(activity_date) AS signup_date
FROM user_activity
GROUP BY user_id
)
SELECT
f.signup_date,
COUNT(DISTINCT f.user_id) AS cohort_size,
COUNT(DISTINCT CASE WHEN a.activity_date = f.signup_date + INTERVAL '1 day' THEN a.user_id END) AS day1_retained,
ROUND(
COUNT(DISTINCT CASE WHEN a.activity_date = f.signup_date + INTERVAL '1 day' THEN a.user_id END)::NUMERIC
/ COUNT(DISTINCT f.user_id), 2
) AS day1_retention_rate
FROM first_seen f
LEFT JOIN user_activity a ON f.user_id = a.user_id
GROUP BY f.signup_date;
Funnel analysis (conversion through sequential steps):
WITH funnel AS (
SELECT
user_id,
MAX(CASE WHEN event_name = 'viewed_product' THEN 1 ELSE 0 END) AS step1,
MAX(CASE WHEN event_name = 'added_to_cart' THEN 1 ELSE 0 END) AS step2,
MAX(CASE WHEN event_name = 'completed_purchase' THEN 1 ELSE 0 END) AS step3
FROM events
GROUP BY user_id
)
SELECT
SUM(step1) AS viewed,
SUM(CASE WHEN step1=1 THEN step2 ELSE 0 END) AS added_to_cart,
SUM(CASE WHEN step1=1 AND step2=1 THEN step3 ELSE 0 END) AS purchased
FROM funnel;
Talking point that ties all three together: “Cohort, retention, and funnel queries are really the same underlying pattern — establishing an anchor event per user (signup, first activity, first funnel step), then joining forward in time and aggregating conditionally. Once you recognize that shape, you can solve almost any ‘how many users did X after Y’ interview question with the same template.” That’s the kind of pattern-recognition statement that signals real depth rather than memorized queries.