{}The Interview
Handbook

Tracks / SQL

Queries, joins & window functions

mid 10 questions · 5 min read sqljoinswindow-functionsaggregation

Questions in this set 10
  1. 01Explain the join types with a concrete example.
  2. 02What is the logical order of evaluation in a SELECT?
  3. 03Write a query for "the top N per group".
  4. 04Show a few more window function patterns.
  5. 05Write a query to find duplicate rows and delete all but one.
  6. 06Explain CTEs and recursive CTEs.
  7. 07UNION vs UNION ALL, and EXISTS vs IN vs JOIN.
  8. 08How do NULLs behave?
  9. 09Write a query for cohort retention or a funnel.
  10. 10Write a query with a conditional aggregate (pivot).
01

Explain the join types with a concrete example.

sql
-- 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.

sql
-- 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'
02

What is the logical order of evaluation in a SELECT?

FROM/JOINWHEREGROUP BYHAVINGSELECT (and window functions) → DISTINCTORDER BYLIMIT.

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.

03

Write a query for "the top N per group".

The canonical window-function question.

sql
-- 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;

04

Show a few more window function patterns.

sql
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.

05

Write a query to find duplicate rows and delete all but one.

sql
-- 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.

06

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).

sql
-- 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.

07

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.

08

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.

09

Write a query for cohort retention or a funnel.

sql
-- 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.

10

Write a query with a conditional aggregate (pivot).

sql
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.