SQL Interview Crash Course
Structured for ML, Data, and Backend engineering interviews. Six modules of progressive difficulty, built around one unified e-commerce/analytics schema, capped off with a timed 10-question mock interview.
→How to use this course
Work through modules 1–6 in order — each builds on the last. Every module ends with practice problems; try them yourself before tapping "Show Answer". When you've drilled all six, take the timed mock interview and score yourself with the guide at the bottom. Tick off modules in the sidebar as you finish them to track progress — it's saved locally in your browser.
☰Modules
DBPractice Schema
Every example and problem in this course uses the same unified e-commerce/analytics schema:
name
signup_date
country
user_id
amount
status
created_at
name
category
price
product_id
quantity
unit_price
user_id
event_type
event_time
session_id
Core Foundations
SELECT, WHERE, ORDER BY, GROUP BY, HAVING, DISTINCT, LIMIT
SQL runs in this order: FROM -> JOIN -> WHERE -> GROUP BY -> HAVING -> SELECT -> DISTINCT -> ORDER BY -> LIMIT
1.1Basic SELECT
-- All columns
SELECT * FROM users;
-- Specific columns + alias
SELECT id, name, country AS user_country FROM users;
-- Computed column
SELECT id, name, UPPER(email) AS email_upper FROM users;
1.2WHERE — filtering rows
-- Equality, comparison
SELECT * FROM orders WHERE status = 'completed';
SELECT * FROM orders WHERE amount > 500;
-- Multiple conditions
SELECT * FROM orders WHERE status = 'completed' AND amount > 1000;
SELECT * FROM orders WHERE status = 'pending' OR status = 'cancelled';
-- IN (cleaner than multiple ORs)
SELECT * FROM orders WHERE status IN ('pending', 'cancelled');
-- BETWEEN (inclusive both ends)
SELECT * FROM orders WHERE amount BETWEEN 100 AND 1000;
-- LIKE (pattern match)
SELECT * FROM users WHERE email LIKE '%@ex.com'; -- ends with
SELECT * FROM users WHERE name LIKE 'A%'; -- starts with
SELECT * FROM users WHERE name LIKE '_ob'; -- _ = exactly one char
-- NULL checks — always use IS NULL, never = NULL
SELECT * FROM orders WHERE user_id IS NOT NULL;
1.3ORDER BY
SELECT * FROM orders ORDER BY amount DESC;
SELECT * FROM orders ORDER BY created_at ASC, amount DESC; -- multi-column sort
1.4DISTINCT
-- Unique countries
SELECT DISTINCT country FROM users;
-- Count of unique countries
SELECT COUNT(DISTINCT country) AS unique_countries FROM users;
1.5GROUP BY + Aggregate Functions
Aggregate functions: COUNT, SUM, AVG, MIN, MAX
-- Orders per user
SELECT user_id, COUNT(*) AS order_count FROM orders GROUP BY user_id;
-- Revenue per user (completed only)
SELECT user_id, SUM(amount) AS total_revenue, AVG(amount) AS avg_order, MAX(amount) AS biggest_order, COUNT(*) AS order_count
FROM orders
WHERE status = 'completed'
GROUP BY user_id
ORDER BY total_revenue DESC;
1.6HAVING — filter after aggregation
WHERE filters individual rows (before GROUP BY). HAVING filters groups (after GROUP BY).
-- Users with more than 1 completed order
SELECT user_id, COUNT(*) AS completed_orders
FROM orders
WHERE status = 'completed'
GROUP BY user_id
HAVING COUNT(*) > 1
ORDER BY completed_orders DESC;
Wrong:
HAVING completed_orders > 1Right:
HAVING COUNT(*) > 11.7LIMIT / OFFSET (pagination)
-- Top 3 biggest orders
SELECT * FROM orders ORDER BY amount DESC LIMIT 3;
-- Page 2 (rows 4-6)
SELECT * FROM orders ORDER BY id LIMIT 3 OFFSET 3;
✎Practice Problems
JOINs Deep Dive
INNER, LEFT, RIGHT, FULL, CROSS, SELF joins & anti-join patterns
JOINs combine rows from two tables based on a condition. Think of it as a Venn diagram: INNER JOIN = intersection only LEFT JOIN = left table + intersection (NULLs for right missing rows) RIGHT JOIN = right table + intersection (rare in practice, flip to LEFT) FULL JOIN = everything, NULLs where no match CROSS JOIN = cartesian product (every combo) -- expensive! SELF JOIN = table joined to itself
2.1INNER JOIN — only matching rows
Orders with user names (only users who have orders).
SELECT o.id AS order_id, u.name, o.amount, o.status
FROM orders o
INNER JOIN users u ON o.user_id = u.id;
2.2LEFT JOIN — all left rows, NULLs for missing right
All users, even those without orders.
SELECT u.id, u.name, o.id AS order_id, o.amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
2.3Finding rows with NO match (anti-join pattern)
Classic interview question: "Find users who have never placed an order."
SELECT u.id, u.name
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;
-- Alternative using NOT IN (careful with NULLs)
SELECT id, name
FROM users
WHERE id NOT IN (SELECT user_id FROM orders);
-- Alternative using NOT EXISTS (safest with NULLs)
SELECT id, name
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
2.4Multiple JOINs
Order details: user name + product name + quantity.
SELECT u.name AS buyer, p.name AS product, oi.quantity, oi.unit_price,
(oi.quantity * oi.unit_price) AS line_total
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.status = 'completed';
2.5SELF JOIN — table joined to itself
Useful for hierarchical data (employee->manager) or comparing rows. Here: users who signed up on the same day as another user.
SELECT a.name AS user1, b.name AS user2, a.signup_date
FROM users a
JOIN users b ON a.signup_date = b.signup_date AND a.id < b.id;
2.6CROSS JOIN — cartesian product
Every user x every product.
SELECT u.name, p.name AS product
FROM users u
CROSS JOIN products p;
2.7Aggregation across JOINed tables
Total revenue per country (completed orders only).
SELECT u.country,
COUNT(DISTINCT u.id) AS user_count,
COUNT(o.id) AS order_count,
SUM(o.amount) AS total_revenue,
AVG(o.amount) AS avg_order
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'completed'
GROUP BY u.country
ORDER BY total_revenue DESC NULLS LAST;
ON — filter during join; missing rows still appear (with NULLs).
WHERE — filter after join; missing rows get eliminated.
2.8JOIN vs WHERE — execution mental model
-- IMPLICIT JOIN (old style -- avoid)
SELECT u.name, o.amount
FROM users u, orders o
WHERE u.id = o.user_id;
-- EXPLICIT JOIN (preferred)
SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id;
✎Practice Problems
Window Functions
ROW_NUMBER, RANK, LAG/LEAD, running totals, NTILE, FIRST_VALUE, PERCENT_RANK
Window functions compute values ACROSS rows related to the current row WITHOUT collapsing them -- unlike GROUP BY, every input row survives in the output.
3.1ROW_NUMBER, RANK, DENSE_RANK
-- ROW_NUMBER: sequential, no ties
SELECT user_id, amount, status,
ROW_NUMBER() OVER (ORDER BY amount DESC) AS rn
FROM orders;
-- RANK: ties get same rank, then gap
-- DENSE_RANK: ties get same rank, no gap
SELECT user_id, amount,
RANK() OVER (ORDER BY amount DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY amount DESC) AS dense_rnk,
ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num
FROM orders;
Add PARTITION BY to rank within groups instead of across the whole table.
-- Per-partition ranking: rank each user's orders by amount
SELECT user_id, id AS order_id, amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY amount DESC) AS user_rank
FROM orders;
-- Get each user's MOST RECENT order
SELECT user_id, order_id, amount, created_at
FROM (
SELECT user_id, id AS order_id, amount, created_at,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders
) t
WHERE rn = 1;
3.2LAG / LEAD — access previous/next row
Revenue vs previous order for each user.
SELECT user_id, id AS order_id, amount, created_at,
LAG(amount) OVER (PARTITION BY user_id ORDER BY created_at) AS prev_order_amount,
LEAD(amount) OVER (PARTITION BY user_id ORDER BY created_at) AS next_order_amount,
amount - LAG(amount) OVER (PARTITION BY user_id ORDER BY created_at) AS delta
FROM orders
WHERE status = 'completed'
ORDER BY user_id, created_at;
3.3Running totals and moving averages
-- Running total of revenue per user
SELECT user_id, created_at, amount,
SUM(amount) OVER (
PARTITION BY user_id ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
WHERE status = 'completed';
-- 3-order moving average
SELECT user_id, created_at, amount,
AVG(amount) OVER (
PARTITION BY user_id ORDER BY created_at
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3
FROM orders
WHERE status = 'completed';
Cumulative % of total revenue — combine a running-total frame with an unbounded (whole-partition) frame in the same query.
SELECT user_id, amount,
SUM(amount) OVER (
ORDER BY amount DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS cumulative,
SUM(amount) OVER () AS grand_total,
ROUND(
100.0 * SUM(amount) OVER (
ORDER BY amount DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) / SUM(amount) OVER (),
1
) AS cum_pct
FROM orders
WHERE status = 'completed';
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW → running total
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW → 3-row moving window
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING → whole partition
3.4NTILE — buckets/percentiles
Divide users into 3 revenue tiers.
SELECT user_id, SUM(amount) AS total,
NTILE(3) OVER (ORDER BY SUM(amount) DESC) AS tier
FROM orders
WHERE status = 'completed'
GROUP BY user_id;
3.5FIRST_VALUE / LAST_VALUE
Each order annotated with the user's first order amount.
SELECT user_id, id AS order_id, amount, created_at,
FIRST_VALUE(amount) OVER (
PARTITION BY user_id ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS first_order_amount
FROM orders;
3.6PERCENT_RANK / CUME_DIST
SELECT user_id, amount,
PERCENT_RANK() OVER (ORDER BY amount) AS pct_rank,
CUME_DIST() OVER (ORDER BY amount) AS cum_dist
FROM orders;
✎Practice Problems
Aggregations, Subqueries & CTEs
Scalar, inline & correlated subqueries, EXISTS / NOT EXISTS, CTEs, recursive CTEs, ROLLUP
A subquery is just a query nested inside another. Three shapes: - Scalar -> returns ONE value, usable anywhere an expression is (SELECT list, WHERE) - Inline -> returns a table, used in FROM/JOIN like a temp table - Correlated -> references the OUTER query, re-runs once PER ROW (can be slow) CTEs (WITH ...) don't change what's possible -- they just name a subquery so the query reads top-to-bottom.
4.1Scalar Subquery (returns a single value)
Orders above the average completed-order amount.
SELECT id, user_id, amount
FROM orders
WHERE amount > (
SELECT AVG(amount) FROM orders WHERE status = 'completed'
);
Annotate each order with the overall average, right in the SELECT list.
SELECT
id, user_id, amount,
(SELECT AVG(amount) FROM orders WHERE status = 'completed') AS avg_amount,
amount - (SELECT AVG(amount) FROM orders WHERE status = 'completed') AS above_avg
FROM orders
WHERE status = 'completed';
4.2Inline View (subquery in FROM)
Top users by spend, using a derived table.
SELECT u.name, t.total
FROM users u
JOIN (
SELECT user_id, SUM(amount) AS total
FROM orders
WHERE status = 'completed'
GROUP BY user_id
) t ON u.id = t.user_id
ORDER BY t.total DESC;
4.3Correlated Subquery (references the outer query)
Users who spent more than the average spend across ALL users.
SELECT u.id, u.name, user_total
FROM (
SELECT user_id, SUM(amount) AS user_total
FROM orders
WHERE status = 'completed'
GROUP BY user_id
) t
JOIN users u ON u.id = t.user_id
WHERE user_total > (
SELECT AVG(sub_total)
FROM (
SELECT user_id, SUM(amount) AS sub_total
FROM orders
WHERE status = 'completed'
GROUP BY user_id
) avg_t
);
Each user's last order date (true correlated subquery — the inner query references u.id from the outer row).
SELECT u.name,
(SELECT MAX(created_at) FROM orders o WHERE o.user_id = u.id) AS last_order
FROM users u;
4.4EXISTS / NOT EXISTS
Users who have at least one completed order.
SELECT id, name
FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id AND o.status = 'completed'
);
Users who have NEVER placed an order.
SELECT id, name
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
SELECT 1 is idiomatic because the actual columns don't matter.4.5CTEs — the WITH clause
A single CTE gives the derived table a name up front, so the main query reads cleanly.
WITH user_revenue AS (
SELECT user_id, SUM(amount) AS total
FROM orders
WHERE status = 'completed'
GROUP BY user_id
)
SELECT u.name, ur.total
FROM users u
JOIN user_revenue ur ON u.id = ur.user_id
ORDER BY ur.total DESC;
Chained CTEs — each one can reference any CTE defined before it.
WITH user_revenue AS (
SELECT user_id, SUM(amount) AS total
FROM orders
WHERE status = 'completed'
GROUP BY user_id
),
avg_revenue AS (
SELECT AVG(total) AS avg_total FROM user_revenue
),
top_users AS (
SELECT user_id, total
FROM user_revenue, avg_revenue
WHERE total > avg_total
)
SELECT u.name, t.total
FROM users u
JOIN top_users t ON u.id = t.user_id;
4.6Recursive CTE
Generate a date series (first 7 days of 2023) — the anchor member seeds the recursion, the recursive member repeats until its WHERE stops matching.
WITH RECURSIVE date_series AS (
SELECT DATE '2023-01-01' AS d
UNION ALL
SELECT d + INTERVAL '1 day'
FROM date_series
WHERE d < DATE '2023-01-07'
)
SELECT d FROM date_series;
4.7Advanced Aggregation — ROLLUP, FILTER, CASE
ROLLUP adds subtotal and grand-total rows automatically.
SELECT country, status, SUM(o.amount) AS revenue
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY ROLLUP (country, status)
ORDER BY country, status;
FILTER (PostgreSQL) — conditional aggregates without CASE.
SELECT
COUNT(*) AS total_orders,
COUNT(*) FILTER (WHERE status = 'completed') AS completed,
COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled,
SUM(amount) FILTER (WHERE status = 'completed') AS completed_revenue
FROM orders;
The same thing with CASE WHEN — works in every SQL dialect, not just Postgres.
SELECT
COUNT(*) AS total_orders,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled
FROM orders;
✎Practice Problems
Advanced Patterns
CASE WHEN, PIVOT, NULL handling, date/string ops, set operations, query optimization, UPSERT
Most "advanced" SQL is just CASE WHEN wearing different hats: - Bucketing a value -> CASE WHEN ... THEN ... END - Pivoting rows to columns -> SUM(CASE WHEN col = 'x' THEN val ELSE 0 END) - Conditional counting -> COUNT(CASE WHEN ... THEN 1 END) Everything else in this module (NULLs, dates, strings, set ops) is about shaping data that's already been aggregated or filtered.
5.1CASE WHEN — conditional logic in SQL
Simple bucketing.
SELECT id, amount,
CASE
WHEN amount < 200 THEN 'small'
WHEN amount < 1000 THEN 'medium'
ELSE 'large'
END AS order_tier
FROM orders;
Conditional aggregation (pivot-style) — one row per user, one column per status.
SELECT
user_id,
SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END) AS completed_rev,
SUM(CASE WHEN status = 'cancelled' THEN amount ELSE 0 END) AS cancelled_rev,
COUNT(CASE WHEN status = 'pending' THEN 1 END) AS pending_count
FROM orders
GROUP BY user_id;
5.2PIVOT / CROSSTAB
Revenue by country x status, using the same conditional-aggregation pattern as 5.1.
SELECT
u.country,
SUM(CASE WHEN o.status = 'completed' THEN o.amount ELSE 0 END) AS completed,
SUM(CASE WHEN o.status = 'pending' THEN o.amount ELSE 0 END) AS pending,
SUM(CASE WHEN o.status = 'cancelled' THEN o.amount ELSE 0 END) AS cancelled,
SUM(o.amount) AS grand_total
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.country
ORDER BY grand_total DESC;
5.3NULL Handling
COALESCE returns the first non-NULL value in its argument list.
SELECT id, COALESCE(amount, 0) AS safe_amount FROM orders;
NULLIF returns NULL when its two arguments are equal — the standard trick for avoiding divide-by-zero.
SELECT user_id, total / NULLIF(order_count, 0) AS avg_order
FROM (
SELECT user_id, SUM(amount) AS total, COUNT(*) AS order_count
FROM orders
GROUP BY user_id
) t;
total / order_count throws a divide-by-zero error the moment any group has 0 rows counted a different way (e.g. a filtered COUNT). Wrapping the denominator in NULLIF(..., 0) turns that error into a harmless NULL result instead.5.4Date / Time Operations
Extract parts of a timestamp.
SELECT
id, created_at,
EXTRACT(YEAR FROM created_at) AS yr,
EXTRACT(MONTH FROM created_at) AS mo,
EXTRACT(DOW FROM created_at) AS dow
FROM orders;
Truncate to a granularity — the classic "group by month" building block.
SELECT DATE_TRUNC('month', created_at) AS month, COUNT(*) AS orders, SUM(amount) AS revenue
FROM orders
GROUP BY 1
ORDER BY 1;
Date arithmetic.
SELECT
id, created_at,
created_at + INTERVAL '30 days' AS expires_at,
CURRENT_DATE - created_at::DATE AS days_ago
FROM orders;
5.5String Operations
SELECT
name,
LOWER(name) AS lower_name,
UPPER(name) AS upper_name,
LENGTH(name) AS len,
SUBSTRING(name, 1, 3) AS first3,
CONCAT(name, ' (', country, ')') AS label,
TRIM(' Alice ') AS trimmed,
REPLACE(email, '@ex.com', '') AS username
FROM users;
5.6Set Operations — UNION, INTERSECT, EXCEPT
Users who placed orders in June OR July.
SELECT DISTINCT user_id FROM orders
WHERE created_at >= '2023-06-01' AND created_at < '2023-07-01'
UNION
SELECT DISTINCT user_id FROM orders
WHERE created_at >= '2023-07-01' AND created_at < '2023-08-01';
Users in BOTH months — swap UNION for INTERSECT.
SELECT DISTINCT user_id FROM orders
WHERE created_at >= '2023-06-01' AND created_at < '2023-07-01'
INTERSECT
SELECT DISTINCT user_id FROM orders
WHERE created_at >= '2023-07-01' AND created_at < '2023-08-01';
Users who ordered in June but NOT July — swap in EXCEPT.
SELECT DISTINCT user_id FROM orders
WHERE created_at >= '2023-06-01' AND created_at < '2023-07-01'
EXCEPT
SELECT DISTINCT user_id FROM orders
WHERE created_at >= '2023-07-01' AND created_at < '2023-08-01';
5.7Query Optimization Mental Models
EXPLAIN ANALYZE shows the actual execution plan a query used — always check it before assuming a query is "slow because SQL."
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 1;
- Index usage — good:
WHERE user_id = 1(uses an index directly). Bad:WHERE YEAR(created_at) = 2023(wrapping a column in a function blocks index usage). - Avoid SELECT * — only pull the columns you actually need; it reduces I/O and network transfer.
- Filter early — push WHERE conditions down so fewer rows flow through JOINs and aggregations.
5.8UPSERT patterns
PostgreSQL INSERT ON CONFLICT — insert a row, or update it if the conflicting key already exists.
INSERT INTO users (id, name, email, signup_date, country)
VALUES (1, 'Alice Updated', 'alice@ex.com', '2023-01-10', 'US')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
✎Practice Problems
Hard Interview Problems
10 classic problems that separate good from great — solved end to end
These are the classic problems that separate good from great. Each one below is fully worked: the question you'd hear in an interview, followed by the complete solution.
H1Nth Highest Value
"Find the 2nd highest order amount."
SELECT DISTINCT amount
FROM (
SELECT amount, DENSE_RANK() OVER (ORDER BY amount DESC) AS rnk
FROM orders
) t
WHERE rnk = 2;
H2Consecutive Days / Streak Detection
"Find users who logged in for at least 3 consecutive days." (Island detection)
WITH login_days AS (
SELECT DISTINCT user_id, event_time::DATE AS login_date
FROM events
),
with_groups AS (
SELECT user_id, login_date,
login_date - (ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date))::INT AS grp
FROM login_days
),
streak_lengths AS (
SELECT user_id, grp, COUNT(*) AS streak_len
FROM with_groups
GROUP BY user_id, grp
)
SELECT DISTINCT user_id
FROM streak_lengths
WHERE streak_len >= 3;
H3Session Analysis
"Group events into sessions where gap > 30 min = new session"
WITH with_prev AS (
SELECT user_id, event_time,
LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) AS prev_time
FROM events
),
with_session_start AS (
SELECT user_id, event_time,
CASE WHEN prev_time IS NULL OR event_time - prev_time > INTERVAL '30 minutes'
THEN 1 ELSE 0 END AS is_new_session
FROM with_prev
),
with_session_id AS (
SELECT user_id, event_time,
SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY event_time) AS session_id
FROM with_session_start
)
SELECT user_id, session_id,
MIN(event_time) AS session_start,
MAX(event_time) AS session_end,
COUNT(*) AS events_in_session,
MAX(event_time) - MIN(event_time) AS duration
FROM with_session_id
GROUP BY user_id, session_id
ORDER BY user_id, session_id;
H4Retention Analysis (Cohort)
"What % of users who signed up in month M placed an order in month M+1?"
WITH cohort AS (
SELECT u.id AS user_id, DATE_TRUNC('month', u.signup_date) AS cohort_month
FROM users u
),
orders_by_month AS (
SELECT user_id, DATE_TRUNC('month', created_at) AS order_month
FROM orders
WHERE status = 'completed'
),
retention AS (
SELECT c.cohort_month,
COUNT(DISTINCT c.user_id) AS cohort_size,
COUNT(DISTINCT o.user_id) AS retained_next_month
FROM cohort c
LEFT JOIN orders_by_month o
ON c.user_id = o.user_id
AND o.order_month = c.cohort_month + INTERVAL '1 month'
GROUP BY c.cohort_month
)
SELECT cohort_month, cohort_size, retained_next_month,
ROUND(100.0 * retained_next_month / NULLIF(cohort_size, 0), 1) AS retention_pct
FROM retention
ORDER BY cohort_month;
H5Median
No built-in MEDIAN function in standard SQL — this is the portable way to compute it.
WITH ordered AS (
SELECT amount,
ROW_NUMBER() OVER (ORDER BY amount) AS rn,
COUNT(*) OVER () AS total
FROM orders
WHERE status = 'completed'
)
SELECT AVG(amount) AS median
FROM ordered
WHERE rn IN (FLOOR((total + 1) / 2.0), CEIL((total + 1) / 2.0));
H6De-duplication
Keep the latest record per key.
SELECT user_id, event_type, event_time
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY user_id, event_type
ORDER BY event_time DESC
) AS rn
FROM events
) t
WHERE rn = 1;
H7Running Balance / Account Overdraft Detection
WITH transactions AS (
SELECT user_id, created_at,
CASE status
WHEN 'completed' THEN amount
WHEN 'cancelled' THEN -amount
ELSE 0
END AS txn_amount
FROM orders
),
running AS (
SELECT user_id, created_at, txn_amount,
SUM(txn_amount) OVER (
PARTITION BY user_id ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_balance
FROM transactions
)
SELECT DISTINCT user_id
FROM running
WHERE running_balance < 0;
H8Funnel Analysis
WITH page_viewers AS (
SELECT DISTINCT user_id
FROM events
WHERE event_type = 'page_view'
),
clickers AS (
SELECT DISTINCT e.user_id
FROM events e
JOIN page_viewers pv ON e.user_id = pv.user_id
WHERE e.event_type = 'click'
),
purchasers AS (
SELECT DISTINCT e.user_id
FROM events e
JOIN clickers c ON e.user_id = c.user_id
WHERE e.event_type = 'purchase'
)
SELECT
(SELECT COUNT(*) FROM page_viewers) AS step1_views,
(SELECT COUNT(*) FROM clickers) AS step2_clicks,
(SELECT COUNT(*) FROM purchasers) AS step3_purchases,
ROUND(100.0 * (SELECT COUNT(*) FROM clickers) / NULLIF((SELECT COUNT(*) FROM page_viewers), 0), 1) AS view_to_click_pct,
ROUND(100.0 * (SELECT COUNT(*) FROM purchasers) / NULLIF((SELECT COUNT(*) FROM page_viewers), 0), 1) AS overall_conversion_pct;
H9Gaps and Islands
Find gaps in a sequence of dates.
WITH daily_order_dates AS (
SELECT DISTINCT created_at::DATE AS order_date
FROM orders
),
with_next AS (
SELECT order_date,
LEAD(order_date) OVER (ORDER BY order_date) AS next_order_date
FROM daily_order_dates
)
SELECT order_date AS gap_start,
next_order_date AS gap_end,
next_order_date - order_date - 1 AS gap_days
FROM with_next
WHERE next_order_date - order_date > 1;
H10Many-to-Many: Users Who Bought ALL Products in a Category
WITH digital_products AS (
SELECT id FROM products WHERE category = 'digital'
),
user_digital_purchases AS (
SELECT DISTINCT o.user_id, oi.product_id
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE oi.product_id IN (SELECT id FROM digital_products)
AND o.status = 'completed'
)
SELECT user_id
FROM user_digital_purchases
GROUP BY user_id
HAVING COUNT(DISTINCT product_id) = (SELECT COUNT(*) FROM digital_products);
☰Quick Reference: Most Common Interview Patterns
Top-N Per Group
ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)
Deduplication
ROW_NUMBER() rn=1
Consecutive/Streak
date - ROW_NUMBER() = const island trick
LAG/LEAD Comparison
prev/next row comparison for trends
Anti-Join
LEFT JOIN + IS NULL or NOT EXISTS
Running Total
SUM() OVER (ORDER BY ... ROWS UNBOUNDED PRECEDING)
Funnel
chained CTEs, each qualifying prior step
Cohort Retention
JOIN on cohort_month + interval
Median
PERCENTILE_CONT or ROW_NUMBER + AVG
Divide-by-Zero
NULLIF(denominator, 0)
Mock Interview: 10 Timed SQL Problems
No peeking at answers. Set a timer for each problem before you start.
Set a timer. Aim for 5–8 min per problem. No looking back at the modules mid-problem — simulate real interview pressure.
DBSchema Reminder
name
signup_date
country
user_id
amount
status
created_at
name
category
price
product_id
quantity
unit_price
user_id
event_type
event_time
session_id
Find the total revenue per country for completed orders. Sort by revenue descending.
List users who have never placed any order.
For each user, show their most expensive single order. Include user name, order_id, and amount.
Find the top 3 best-selling products by total revenue (quantity × unit_price). Include product name, category, total units sold, and total revenue.
Show month-over-month revenue growth. Columns: month, revenue, previous_month_revenue, growth_pct. Round to 1 decimal.
Funnel: Among users who had a page_view, what % also had a purchase event? Show both counts and the conversion rate.
Find users who placed orders in at least 2 different months. Show user_id, name, and the list of months ordered.
For each product, show: product name, its rank by revenue within its category, and whether it's in the top 2 of that category (yes/no).
Detect "churned" users: users who placed orders in the first 3 months of data but placed NO orders in the last 3 months of data. Return user_id and name.
For each user, compute their "average time between orders" (in days). Only include users with 2+ orders. Sort by avg_days_between_orders ascending.
%Scoring Guide
| Questions | Points | Tier |
|---|---|---|
| Q1–Q2 | 2 pts each | Warm-up |
| Q3–Q5 | 3 pts each | Core |
| Q6–Q8 | 4 pts each | Hard |
| Q9–Q10 | 5 pts each | Brutal |
Total: 35 pts