SQL

15 schema + canonical query recipes. Click any to see the schema and the right query.

Schema
CREATE TABLE sales (
  id        BIGINT PRIMARY KEY,
  region    TEXT NOT NULL,
  rep_name  TEXT NOT NULL,
  amount    NUMERIC NOT NULL,
  sold_at   DATE NOT NULL
);

Return the top 2 sales reps in each region by total amount. If two reps tie, both should appear and the next rep should be skipped (classic 1, 2, 2, 4 ranking).

Canonical query
SELECT region, rep_name, total
FROM (
  SELECT region, rep_name, SUM(amount) AS total,
         RANK() OVER (PARTITION BY region ORDER BY SUM(amount) DESC) AS rk
  FROM sales
  GROUP BY region, rep_name
) t
WHERE rk <= 2;

RANK() produces 1, 2, 2, 4 — exactly the 'include ties, skip the next slot' semantics asked for. ROW_NUMBER (a) breaks ties arbitrarily and would drop a tied rep. DENSE_RANK (c) would let a third rep through because it produces 1, 2, 2, 3. LIMIT 2 (d) returns 2 rows globally, not 2 per region.

Dive deeper

ROW_NUMBER, RANK, and DENSE_RANK only differ on ties. Pick by what the product says about ties: arbitrary tiebreak (ROW_NUMBER), include-and-skip (RANK), include-and-continue (DENSE_RANK).

Schema
CREATE TABLE customers (
  id    BIGINT PRIMARY KEY,
  email TEXT NOT NULL
);

CREATE TABLE orders (
  id          BIGINT PRIMARY KEY,
  customer_id BIGINT,  -- nullable: guest checkouts also live here
  total       NUMERIC NOT NULL
);

Return every customer who has never placed an order. Note that orders.customer_id is nullable because guest checkouts are stored in the same table.

Canonical query
SELECT c.id, c.email
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;

LEFT JOIN ... WHERE o.id IS NULL is the textbook anti-join and is NULL-safe. Option (a) is the classic NULL trap: NOT IN against a subquery that contains any NULL returns no rows because 'x NOT IN (..., NULL, ...)' evaluates to UNKNOWN. (c) is logically impossible since INNER JOIN guarantees o.id is not null. (d) just checks whether any guest order exists anywhere — it has no correlation to c.

Schema
CREATE TABLE daily_revenue (
  day     DATE PRIMARY KEY,
  amount  NUMERIC NOT NULL
);

Return each day with a running total of revenue from the earliest day through that day, ordered by day.

Canonical query
SELECT day, amount,
       SUM(amount) OVER (ORDER BY day
                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM daily_revenue
ORDER BY day;

A windowed SUM with ORDER BY and an explicit ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW frame is the canonical running total. (a) and (d) return the grand total on every row because there is no ORDER BY in the window. (c) partitions by day, so each window contains a single row and 'running_total' just equals amount.

Dive deeper

Without an explicit frame clause, ORDER BY in a window defaults to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which collapses tied ORDER BY values into one bucket. Spelling out ROWS avoids surprises when keys repeat.

Schema
CREATE TABLE employees (
  id         BIGINT PRIMARY KEY,
  department TEXT NOT NULL,
  salary     NUMERIC NOT NULL
);

Return the median salary per department in PostgreSQL. Use a continuous median (interpolate between the two middle values for even-sized groups).

Canonical query
SELECT department,
       PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) AS median_salary
FROM employees
GROUP BY department;

PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) is Postgres's continuous median and interpolates between the two middle values. PERCENTILE_DISC (c) returns the lower of the two middle values, which is a discrete median. AVG (a) is the mean, not the median. (d) uses a MEDIAN() function that does not exist in Postgres.

Schema
CREATE TABLE employees (
  id         BIGINT PRIMARY KEY,
  name       TEXT NOT NULL,
  manager_id BIGINT REFERENCES employees(id),
  salary     NUMERIC NOT NULL
);

Return the names of employees who earn strictly more than their direct manager. Employees with no manager should not appear.

Canonical query
SELECT e.name
FROM employees e
JOIN employees m ON m.id = e.manager_id
WHERE e.salary > m.salary;

An INNER self-join on manager_id pairs each employee with their direct manager and excludes top-level employees (manager_id IS NULL) automatically. (a) is a cross join and compares everyone to everyone. (b) re-adds top-level employees via the OR clause, violating the spec. (d) compares to the company average, not to the manager.

Schema
CREATE TABLE employees (
  id         BIGINT PRIMARY KEY,
  name       TEXT NOT NULL,
  manager_id BIGINT REFERENCES employees(id)
);

Given a CEO with manager_id IS NULL, return every employee in the org along with their depth (CEO = 0, direct reports = 1, etc.).

Canonical query
WITH RECURSIVE tree AS (
  SELECT id, name, 0 AS depth
  FROM employees
  WHERE manager_id IS NULL
  UNION ALL
  SELECT e.id, e.name, t.depth + 1
  FROM employees e
  JOIN tree t ON e.manager_id = t.id
)
SELECT * FROM tree;

A recursive CTE (Common Table Expression) with the RECURSIVE keyword anchors on the CEO and walks down one level per iteration. (c) omits RECURSIVE, so Postgres treats 'tree' inside the second SELECT as an unknown table and the query fails. (a) caps depth at 1. (d) just counts how many managers a person has (0 or 1), which is not depth.

Dive deeper

Postgres requires the RECURSIVE keyword on the CTE even though only one branch is recursive. Always end the recursive branch with a guaranteed terminating join condition to avoid infinite loops on cyclic data.

Schema
CREATE TABLE employees (
  id         BIGINT PRIMARY KEY,
  department TEXT NOT NULL
);

Return the names of departments that have at least 3 employees, alphabetically sorted.

Canonical query
SELECT department
FROM employees
GROUP BY department
HAVING COUNT(*) >= 3
ORDER BY department;

Filtering aggregates requires HAVING, which runs after GROUP BY. (a) and (c) try to use WHERE on COUNT(*), which is a syntax error because WHERE is evaluated before grouping. (d) applies HAVING to the whole result set (no GROUP BY), so it returns either all departments or none depending on the total row count.

Schema
CREATE TABLE users (
  id           BIGINT PRIMARY KEY,
  email        TEXT NOT NULL,
  phone        TEXT          -- nullable
);

Return a single row with two columns: total_users (every row) and users_with_phone (rows where phone is set).

Canonical query
SELECT COUNT(*) AS total_users,
       COUNT(phone) AS users_with_phone
FROM users;

COUNT(*) counts rows including NULLs, while COUNT(col) skips rows where col IS NULL — that is exactly the distinction we need. (a) counts every row twice. (b) is correct in practice but only because id is NOT NULL; COUNT(*) is the clearer intent. (d) tries to SUM a text column, which errors.

Schema
CREATE TABLE customers (
  id    BIGINT PRIMARY KEY,
  name  TEXT NOT NULL
);

CREATE TABLE orders (
  id          BIGINT PRIMARY KEY,
  customer_id BIGINT NOT NULL REFERENCES customers(id),
  total       NUMERIC NOT NULL
);

For a report, return every customer along with their order total. Customers who have placed no orders should still appear (with total = 0).

Canonical query
SELECT c.id, c.name, COALESCE(SUM(o.total), 0) AS total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name;

LEFT JOIN keeps every customer and pads missing orders with NULL, which COALESCE turns into 0. INNER JOIN (a) drops customers with no orders. RIGHT JOIN (c) keeps every order — fine here since orders.customer_id is NOT NULL, but it does not guarantee customers with no orders are kept. FULL OUTER (d) is overkill and would also keep orphan orders if customer_id were nullable.

Schema
CREATE TABLE products (
  id    BIGINT PRIMARY KEY,
  name  TEXT NOT NULL
);

CREATE TABLE returns (
  id         BIGINT PRIMARY KEY,
  product_id BIGINT  -- nullable: 'unknown product' returns
);

Return the names of products that have NEVER been returned. The returns table can contain rows with product_id IS NULL.

Canonical query
SELECT name
FROM products p
WHERE NOT EXISTS (
  SELECT 1 FROM returns r WHERE r.product_id = p.id
);

NOT EXISTS is NULL-safe: 'r.product_id = p.id' is simply false when r.product_id IS NULL, so those rows are ignored. NOT IN (a) returns zero rows because the subquery contains a NULL — 'x NOT IN (..., NULL)' evaluates to UNKNOWN, never true. (c) is a logic error: '!= ANY' is true if any value differs, so it is almost always true. (d) does not filter at all.

Dive deeper

Default to NOT EXISTS for anti-joins. If you must use NOT IN, either guarantee the subquery column is NOT NULL or wrap it: 'WHERE col NOT IN (SELECT x FROM t WHERE x IS NOT NULL)'.

Schema
CREATE TABLE page_views (
  user_id BIGINT NOT NULL,
  page    TEXT   NOT NULL,
  viewed_at TIMESTAMPTZ NOT NULL
);

Return each unique (user_id, page) pair along with the number of views for that pair. Order by views desc.

Canonical query
SELECT user_id, page, COUNT(*) AS views
FROM page_views
GROUP BY user_id, page
ORDER BY views DESC;

GROUP BY user_id, page collapses to unique pairs and lets COUNT(*) count rows per pair. (a) is invalid in Postgres: COUNT(*) without GROUP BY collapses the whole table to one row, and DISTINCT cannot 'undo' that. (c) groups by user_id only, which means page is not a valid bare column. (d) counts distinct timestamps and is missing the GROUP BY entirely.

Schema
CREATE TABLE users (
  id    BIGINT PRIMARY KEY,
  email TEXT NOT NULL
);

Return every email address that is associated with more than one user row, along with how many times it appears.

Canonical query
SELECT email, COUNT(*) AS n
FROM users
GROUP BY email
HAVING COUNT(*) > 1;

GROUP BY email + HAVING COUNT(*) > 1 is the canonical duplicate-finder. (a) puts the aggregate filter in WHERE, which is a syntax error. (c) returns every distinct email, not just duplicates, and the DISTINCT is redundant once GROUP BY is present. (d) duplicates rows by self-join and hardcodes n=2 — wrong for emails that appear 3+ times.

Schema
CREATE TABLE signups (
  user_id    BIGINT PRIMARY KEY,
  signed_up  TIMESTAMPTZ NOT NULL
);

CREATE TABLE sessions (
  id         BIGINT PRIMARY KEY,
  user_id    BIGINT NOT NULL,
  started_at TIMESTAMPTZ NOT NULL
);

In PostgreSQL, return the user_ids of people who started at least one session strictly AFTER their signup and within 7 days of signing up (i.e. signup < session <= signup + 7 days).

Canonical query
SELECT DISTINCT s.user_id
FROM signups s
JOIN sessions x ON x.user_id = s.user_id
WHERE x.started_at >  s.signed_up
  AND x.started_at <= s.signed_up + INTERVAL '7 days';

Postgres supports INTERVAL arithmetic directly, and the half-open inequalities match the spec exactly. (a) compares a TIMESTAMPTZ subtraction (which yields an INTERVAL) to a bare number — invalid. (c) uses DATEDIFF, which is SQL Server / MySQL, not Postgres. (d) adds a bare integer 7 to a TIMESTAMPTZ (error) and uses BETWEEN, which includes both endpoints (so signup itself counts).

Schema
CREATE TABLE orders (
  id        BIGINT PRIMARY KEY,
  region    TEXT NOT NULL,
  total     NUMERIC NOT NULL,
  placed_at DATE NOT NULL
);

In PostgreSQL, pivot 2025 orders into one row per region with columns q1, q2, q3, q4 holding the sum of total for that quarter.

Canonical query
SELECT region,
       SUM(CASE WHEN EXTRACT(QUARTER FROM placed_at) = 1 THEN total ELSE 0 END) AS q1,
       SUM(CASE WHEN EXTRACT(QUARTER FROM placed_at) = 2 THEN total ELSE 0 END) AS q2,
       SUM(CASE WHEN EXTRACT(QUARTER FROM placed_at) = 3 THEN total ELSE 0 END) AS q3,
       SUM(CASE WHEN EXTRACT(QUARTER FROM placed_at) = 4 THEN total ELSE 0 END) AS q4
FROM orders
WHERE EXTRACT(YEAR FROM placed_at) = 2025
GROUP BY region;

Conditional aggregation (SUM(CASE WHEN ... )) is the portable Postgres pivot pattern: one row per region, one column per quarter. (b) puts CASE outside SUM, so SUM still collapses by group and the CASE just nullifies most rows; the extra GROUP BY placed_at also breaks the row-per-region requirement. (c) leaves the result long, not pivoted. (d) uses Oracle/T-SQL PIVOT syntax that Postgres does not support.

Schema
CREATE TABLE users (
  id      BIGINT PRIMARY KEY,
  status  TEXT NOT NULL
);

CREATE TABLE orders (
  id          BIGINT PRIMARY KEY,
  user_id     BIGINT NOT NULL REFERENCES users(id),
  placed_at   TIMESTAMPTZ NOT NULL
);

In PostgreSQL, set status = 'inactive' for every user who has placed no orders in the last 365 days (including users who have NEVER placed an order). Update only users currently not already inactive.

Canonical query
UPDATE users u
SET status = 'inactive'
WHERE u.status <> 'inactive'
  AND NOT EXISTS (
    SELECT 1 FROM orders o
    WHERE o.user_id = u.id
      AND o.placed_at >= NOW() - INTERVAL '365 days'
  );

NOT EXISTS asks 'does this user have ANY recent order?' and negates it, which naturally includes users with zero orders. (a) only marks users who DO have an old order and still joins to the orders table, missing never-ordered users entirely. (c) works only if orders.user_id is guaranteed NOT NULL — it is here, but the bigger issue is (d) would never match never-ordered users because MAX returns NULL and NULL < anything is UNKNOWN, not true.

Dive deeper

Mass UPDATEs should be idempotent: the 'AND status <> target' guard avoids writing the same row repeatedly across cron runs, which matters for replication lag and audit logs.