Queries, joins & window functions
Questions in this set 10
- 01Explain the join types with a concrete example.
- 02What is the logical order of evaluation in a SELECT?
- 03Write a query for "the top N per group".
- 04Show a few more window function patterns.
- 05Write a query to find duplicate rows and delete all but one.
- 06Explain CTEs and recursive CTEs.
- 07UNION vs UNION ALL, and EXISTS vs IN vs JOIN.
- 08How do NULLs behave?
- 09Write a query for cohort retention or a funnel.
- 10Write a query with a conditional aggregate (pivot).
Explain the join types with a concrete example.
-- INNER: rows present in both
SELECT u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id;
-- LEFT: every user, NULLs where there is no order
SELECT u.name, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id;
-- users with NO orders — the "anti-join" pattern
SELECT u.* FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.id IS NULL;
-- equivalently, and often clearer:
SELECT u.* FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- CROSS: cartesian product; useful for generating a calendar × category grid
SELECT d.day, c.id FROM generate_series('2025-01-01'::date,'2025-01-31',interval '1 day') d(day)
CROSS JOIN categories c;The classic trap: putting a condition on the right table in WHERE instead of ON turns a LEFT JOIN into an INNER JOIN, because WHERE o.status = 'paid' filters out the NULL rows the join just produced.
-- WRONG (silently inner) -- RIGHT
LEFT JOIN orders o ON o.user_id=u.id LEFT JOIN orders o ON o.user_id=u.id AND o.status='paid'
WHERE o.status = 'paid'What is the logical order of evaluation in a SELECT?
FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT (and window functions) → DISTINCT → ORDER BY → LIMIT.
Two consequences you will be asked about: you cannot reference a SELECT alias in WHERE (it does not exist yet) but you can in ORDER BY; and WHERE filters rows before aggregation while HAVING filters groups after it — so put every condition you can in WHERE, because it reduces the rows that must be grouped.
Write a query for "the top N per group".
The canonical window-function question.
-- most recent 3 orders per customer
SELECT * FROM (
SELECT o.*,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
FROM orders o
) t
WHERE rn <= 3;Know the three ranking functions and how they differ on ties: ROW_NUMBER gives 1,2,3,4 (arbitrary among ties); RANK gives 1,2,2,4 (gaps); DENSE_RANK gives 1,2,2,3 (no gaps). If the interviewer asks for "the top-selling product per category, ties included", that is RANK() … = 1, not ROW_NUMBER.
Postgres alternative that is often faster: SELECT DISTINCT ON (customer_id) * FROM orders ORDER BY customer_id, created_at DESC;
Show a few more window function patterns.
SELECT
day, revenue,
SUM(revenue) OVER (ORDER BY day) AS running_total,
AVG(revenue) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7,
LAG(revenue) OVER (ORDER BY day) AS prev_day,
revenue - LAG(revenue) OVER (ORDER BY day) AS delta,
revenue::numeric / NULLIF(SUM(revenue) OVER (), 0) AS share_of_total,
NTILE(4) OVER (ORDER BY revenue) AS quartile
FROM daily_revenue;The key idea to articulate: a window function computes across a set of rows without collapsing them, unlike GROUP BY. NULLIF(x, 0) in the denominator is the standard divide-by-zero guard, and it is the kind of detail that reads as production experience.
Write a query to find duplicate rows and delete all but one.
-- find
SELECT email, COUNT(*), MIN(id) FROM users GROUP BY email HAVING COUNT(*) > 1;
-- delete all but the oldest, safely
DELETE FROM users u
USING (
SELECT id, ROW_NUMBER() OVER (PARTITION BY lower(email) ORDER BY created_at, id) AS rn
FROM users
) d
WHERE u.id = d.id AND d.rn > 1;
-- then prevent recurrence
CREATE UNIQUE INDEX CONCURRENTLY users_email_uniq ON users (lower(email));Always end this answer with the constraint. Cleaning duplicates without adding the unique index means you will be doing it again next month — interviewers listen for that instinct.
Explain CTEs and recursive CTEs.
A CTE (WITH) names a subquery, improving readability and allowing reuse. In Postgres 12+ CTEs are inlined by default (previously they were an optimisation fence — MATERIALIZED/NOT MATERIALIZED now controls this explicitly).
-- org chart: everyone under a manager
WITH RECURSIVE subordinates AS (
SELECT id, name, manager_id, 1 AS depth FROM employees WHERE id = 42 -- anchor
UNION ALL
SELECT e.id, e.name, e.manager_id, s.depth + 1 -- recursive term
FROM employees e JOIN subordinates s ON e.manager_id = s.id
WHERE s.depth < 10 -- cycle guard
)
SELECT * FROM subordinates;Recursive CTEs handle hierarchies, graph traversal and gap-filling date series. Always include a depth limit or a visited-path check — a cycle in the data otherwise loops forever.
UNION vs UNION ALL, and EXISTS vs IN vs JOIN.
UNION deduplicates (a sort or hash — expensive); UNION ALL does not. Use ALL unless you actually need dedup; this is a free performance win people miss.
IN (subquery), EXISTS and a semi-join are usually planned identically in modern Postgres. The one real difference: NOT IN with NULLs in the subquery returns no rows at all, because x NOT IN (1, NULL) evaluates to UNKNOWN. Use NOT EXISTS, which handles NULLs correctly. This is a favourite gotcha question.
How do NULLs behave?
NULL means unknown, so any comparison with it is UNKNOWN: NULL = NULL is not true — use IS NULL / IS NOT DISTINCT FROM. Aggregates skip NULLs, so COUNT(col) < COUNT(*) when the column has nulls, and AVG ignores them rather than treating them as zero. NULL in a CHECK passes; unique indexes traditionally allow multiple NULLs (Postgres 15 added NULLS NOT DISTINCT). Concatenation with NULL yields NULL — use COALESCE.
Write a query for cohort retention or a funnel.
-- monthly cohort retention
WITH first_seen AS (
SELECT user_id, date_trunc('month', MIN(created_at)) AS cohort FROM events GROUP BY 1
),
activity AS (
SELECT DISTINCT e.user_id, f.cohort, date_trunc('month', e.created_at) AS month
FROM events e JOIN first_seen f USING (user_id)
)
SELECT cohort,
(EXTRACT(YEAR FROM age(month, cohort)) * 12
+ EXTRACT(MONTH FROM age(month, cohort)))::int AS month_number,
COUNT(DISTINCT user_id) AS users
FROM activity
GROUP BY 1, 2
ORDER BY 1, 2;This kind of question is testing whether you can decompose a business question into CTEs. Narrate the steps ("first each user's cohort, then their active months, then count per cohort-offset") before writing SQL — that is what is being assessed.
Write a query with a conditional aggregate (pivot).
SELECT
date_trunc('day', created_at)::date AS day,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'paid') AS paid, -- Postgres
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) AS refunded, -- portable
ROUND(100.0 * COUNT(*) FILTER (WHERE status='paid') / NULLIF(COUNT(*),0), 1) AS paid_pct
FROM orders
WHERE created_at >= now() - interval '30 days'
GROUP BY 1 ORDER BY 1;FILTER is cleaner than CASE and, in Postgres, usually faster. Note the 100.0 — integer division silently truncating to zero is one of the most common wrong answers in SQL interviews.