Sweaty Imposter

SQL

27 SQL questions, each with the schema and the canonical 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).

Read the Full Page
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.

Read the Full Page
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.

Read the Full Page
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.

Read the Full Page
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.

Read the Full Page
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 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.

Read the Full Page
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.

Read the Full Page
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.

Read the Full Page
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.

Read the Full Page
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)'.

Read the Full Page
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.

Read the Full Page
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.

Read the Full Page
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).

Read the Full Page
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.

Read the Full Page
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.

Read the Full Page
Schema
CREATE TABLE users (
  id     BIGINT PRIMARY KEY,
  email  TEXT NOT NULL,
  phone  TEXT  -- NULL when the user skipped it at signup
);

Return every user who has not added a phone number. A skipped phone is stored as NULL.

Canonical Query
SELECT id, email
FROM users
WHERE phone IS NULL;

NULL means unknown, so phone = NULL evaluates to NULL for every row, and WHERE keeps only rows where the condition is true: (a) returns nothing. IS NULL is the test that matches missing values. (c) looks for empty strings, which Postgres treats as a different value from NULL. (d) returns users who do have a phone, and it also skips the NULL rows, since NULL <> '' is unknown too.

Dive Deeper

IS DISTINCT FROM is the NULL-safe version of <>: it treats two NULLs as equal and a NULL as different from any value. If the app sometimes writes '' for a skipped field, normalize it with NULLIF(phone, '') or a CHECK constraint so the column has one way to say missing.

Read the Full Page
Schema
CREATE TABLE orders (
  id      BIGINT PRIMARY KEY,
  status  TEXT NOT NULL,     -- 'pending', 'shipped', 'delivered', 'cancelled'
  total   NUMERIC NOT NULL
);

Return the orders that are shipped or delivered and have a total over 100.

Canonical Query
SELECT id, status, total
FROM orders
WHERE (status = 'shipped' OR status = 'delivered') AND total > 100;

AND binds tighter than OR, so (a) reads as status = 'shipped' OR (status = 'delivered' AND total > 100) and returns every shipped order whatever its total. The parentheses in (c) group the two statuses first. (b) can never be true, because one row has one status. (d) is an error in Postgres: 'delivered' cannot be read as a boolean, so it cannot be an operand of AND or OR.

Dive Deeper

status IN ('shipped', 'delivered') says the same thing as the parenthesized OR and reads better as the list grows. When a WHERE clause mixes AND and OR, add parentheses even where precedence already does what you want, so the next reader doesn't have to remember the rule.

Read the Full Page
Schema
CREATE TABLE departments (
  id    BIGINT PRIMARY KEY,
  name  TEXT NOT NULL
);

CREATE TABLE employees (
  id             BIGINT PRIMARY KEY,
  name           TEXT NOT NULL,
  department_id  BIGINT REFERENCES departments(id)  -- NULL for new hires not yet placed
);

List every employee with their department name. New hires who are not in a department yet must still appear, with a NULL department name.

Canonical Query
SELECT e.name, d.name AS department
FROM employees e
LEFT JOIN departments d ON d.id = e.department_id;

A LEFT JOIN keeps every row from the left table (employees) and fills the right side with NULLs when nothing matches, which is exactly what the unplaced new hires need. INNER JOIN (a) drops them. RIGHT JOIN (b) keeps every department instead, so empty departments show up and unplaced employees disappear. (c) starts out right, then the WHERE filter throws away the NULL rows the LEFT JOIN kept, which turns it back into an inner join.

Dive Deeper

Conditions on the right-hand table go in the ON clause when you want to keep unmatched left rows. LEFT JOIN departments d ON d.id = e.department_id AND d.active keeps every employee, while the same test in WHERE drops anyone without an active department.

Read the Full Page
Schema
CREATE TABLE orders (
  id           BIGINT PRIMARY KEY,
  customer_id  BIGINT NOT NULL,
  total        NUMERIC NOT NULL
);

Return each customer_id with more than 5 orders, along with their order count.

Canonical Query
SELECT customer_id, COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
HAVING COUNT(*) > 5;

WHERE filters rows before grouping, so it cannot see an aggregate: (a) fails with 'aggregate functions are not allowed in WHERE'. HAVING filters groups after GROUP BY, which is where COUNT(*) exists. (c) is a MySQL habit: Postgres does not let HAVING refer to a SELECT alias, so it fails with column "order_count" does not exist. (d) returns the top 5 customers, which answers a different question.

Dive Deeper

The logical order of a query explains most of these errors: FROM and JOIN, then WHERE, then GROUP BY, then HAVING, then SELECT, then ORDER BY and LIMIT. ORDER BY can use a SELECT alias because it runs after SELECT; WHERE and HAVING run before it.

Read the Full Page
Schema
CREATE TABLE orders (
  id           BIGINT PRIMARY KEY,
  customer_id  BIGINT NOT NULL,
  status       TEXT NOT NULL,
  total        NUMERIC NOT NULL
);

Return the total amount each customer has spent, one row per customer.

Canonical Query
SELECT customer_id, SUM(total) AS spent
FROM orders
GROUP BY customer_id;

Every column in SELECT must be in GROUP BY or inside an aggregate. (a) has an aggregate but no GROUP BY, so Postgres rejects customer_id with 'must appear in the GROUP BY clause or be used in an aggregate function'. (c) fails the same way on status. (d) runs, but a window function keeps one row per order, and SUM(total) OVER () puts the grand total of the whole table on every row.

Dive Deeper

Postgres allows one shortcut: when you GROUP BY a table's primary key, you can select that table's other columns without listing them, because they depend on the key. GROUP BY c.id with SELECT c.id, c.email works when customers.id is the primary key.

Read the Full Page
Schema
CREATE TABLE orders (
  id           BIGINT PRIMARY KEY,
  customer_id  BIGINT NOT NULL,
  placed_at    TIMESTAMPTZ NOT NULL
);

How many different customers placed at least one order in October 2026? placed_at is a timestamp, so orders land at any time of day, including late on October 31.

Canonical Query
SELECT COUNT(DISTINCT customer_id)
FROM orders
WHERE placed_at >= '2026-10-01' AND placed_at < '2026-11-01';

COUNT(DISTINCT customer_id) counts each customer once, and the half-open range from October 1 up to (but excluding) November 1 covers every moment of October. (a) and (c) count orders: COUNT(customer_id) only skips NULLs, so a repeat customer is still counted once per order. (b) counts the right thing over the wrong range: '2026-10-31' means midnight at the start of October 31, so BETWEEN misses everything placed later that day.

Dive Deeper

With TIMESTAMPTZ, a literal like '2026-10-01' is read in the session's time zone, so set it (SET TIME ZONE 'UTC') or write the offset ('2026-10-01 00:00+00') when October means a specific zone. The half-open pattern keeps working if the column ever changes between DATE and TIMESTAMPTZ.

Read the Full Page
Schema
CREATE TABLE products (
  id     BIGINT PRIMARY KEY,
  name   TEXT NOT NULL,
  price  NUMERIC  -- NULL until the product is priced
);

Return the 3 most expensive products. Unpriced products (NULL price) should not appear.

Canonical Query
SELECT name, price
FROM products
WHERE price IS NOT NULL
ORDER BY price DESC
LIMIT 3;

Postgres sorts NULL as larger than any value, so ORDER BY price DESC puts the unpriced products first and (a) can return three NULLs. Filtering them out with WHERE price IS NOT NULL (b) fixes that. (c) returns the cheapest products. (d) only returns products tied for the single highest price, which is usually one row.

Dive Deeper

NULLS FIRST and NULLS LAST override the default for one sort key: ORDER BY price DESC NULLS LAST keeps unpriced rows but moves them to the end. Defaults differ between databases (MySQL and SQL Server sort NULL as the smallest value), so the same ORDER BY can surface NULLs in one engine and hide them in another.

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

Find every user whose email ends in @example.com. Some addresses were typed with capitals, like Sam@Example.com.

Canonical Query
SELECT id, email
FROM users
WHERE email ILIKE '%@example.com';

ILIKE is the case-insensitive version of LIKE in Postgres, and the leading % matches any prefix, so (c) finds Sam@Example.com too. LIKE (a) is case-sensitive in Postgres and misses the capitalized addresses. (b) compares against the literal text '%@example.com', because = does no pattern matching. (d) puts the wildcard at the wrong end and matches emails that start with @example.com.

Dive Deeper

A leading % means a plain B-tree index on email can't help, so this is a full scan on a big table. For case-insensitive lookups of a whole address, index lower(email) and compare lower(email) = lower($1). For fast '%...' searches, a pg_trgm trigram index supports both LIKE and ILIKE. In LIKE patterns, _ matches any single character, so escape it when you mean a literal underscore.

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

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

List every customer's email with their number of orders. Customers who have never ordered must appear with a count of 0.

Canonical Query
SELECT c.email, COUNT(o.id) AS order_count
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.email;

The LEFT JOIN from customers keeps a customer with no orders as one row whose order columns are NULL. COUNT(o.id) skips NULLs, so that customer gets 0. COUNT(*) in (a) counts the row itself and reports 1 for someone who never ordered. INNER JOIN (b) drops customers without orders entirely. (d) puts orders on the left, so it keeps every order and loses the customers who have none.

Dive Deeper

Joins multiply rows. If you join customers to orders and also to support tickets, a customer with 3 orders and 2 tickets becomes 6 rows, and both counts come out wrong. Count each child table in its own subquery (or use COUNT(DISTINCT o.id)) and join the results to customers. Grouping by c.id as well as c.email keeps two customers apart even if they share an email.

Read the Full Page
Schema
CREATE TABLE reviews (
  id          BIGINT PRIMARY KEY,
  product_id  BIGINT NOT NULL,
  rating      INT NOT NULL CHECK (rating BETWEEN 1 AND 5)
);

Return each product_id with an average rating of at least 4 across at least 10 reviews, along with the average and the review count.

Canonical Query
SELECT product_id, AVG(rating) AS avg_rating, COUNT(*) AS review_count
FROM reviews
GROUP BY product_id
HAVING AVG(rating) >= 4 AND COUNT(*) >= 10;

Both conditions are about the whole group, so both belong in HAVING, joined with AND. (a) filters rows before grouping: it averages and counts only the 4 and 5 star reviews, so a product with ten 5s and ten 1s passes with a reported average of 5.0. (c) accepts a product that meets either condition, including one with hundreds of 2 star reviews. (d) drops the count, so a single 5 star review is enough.

Dive Deeper

Put a condition in WHERE when it applies to individual rows (only reviews from the last year) and in HAVING when it applies to an aggregate (AVG, COUNT, SUM). The two combine: WHERE created_at >= now() - interval '1 year' with HAVING COUNT(*) >= 10 means at least 10 reviews in the last year. In Postgres, AVG of an INT column returns NUMERIC, but SUM(rating) / COUNT(*) divides two integers and truncates, so a 4.6 average comes out as 4.

Read the Full Page
Schema
CREATE TABLE employees (
  id      BIGINT PRIMARY KEY,
  name    TEXT NOT NULL,
  salary  NUMERIC NOT NULL,
  bonus   NUMERIC  -- NULL when the employee has no bonus
);

Return each employee's name and total pay, which is salary plus bonus. Employees without a bonus have NULL in bonus, and their total pay is their salary.

Canonical Query
SELECT name, salary + COALESCE(bonus, 0) AS total_pay
FROM employees;

Any arithmetic with NULL returns NULL, so (a) shows a NULL total for everyone without a bonus. COALESCE(bonus, 0) in (b) swaps the missing bonus for 0 before the addition. (c) applies COALESCE after the addition has already produced NULL, so those employees show a total pay of 0. NULLIF(bonus, 0) in (d) works the other way round: it turns a bonus of 0 into NULL and leaves NULL as NULL, which makes more totals NULL.

Dive Deeper

Aggregates skip NULL values entirely. For bonuses of 5000, NULL, and 0, SUM(bonus) is 5000 and COUNT(bonus) is 2, while AVG(bonus) is 2500 because it divides by the 2 non-NULL rows; AVG(COALESCE(bonus, 0)) gives 1666.67 across all 3 employees. SUM over a set with no non-NULL values returns NULL, so wrap it as COALESCE(SUM(bonus), 0) when a report needs a number.

Read the Full Page
Schema
CREATE TABLE posts (
  id            BIGINT PRIMARY KEY,
  title         TEXT NOT NULL,
  published_at  TIMESTAMPTZ NOT NULL
);

The blog shows 10 posts per page, newest first. Return page 2, which is posts 11 through 20.

Canonical Query
SELECT id, title, published_at
FROM posts
ORDER BY published_at DESC, id DESC
LIMIT 10 OFFSET 10;

OFFSET skips (page - 1) * page_size rows, so page 2 skips 10 and LIMIT takes the next 10. (a) skips 20 and returns page 3. (b) has no ORDER BY, and without one the database may return rows in any order, which can differ between runs. (d) sorts oldest first. The id DESC tiebreaker in (c) gives posts with the same published_at a fixed order, so none of them repeats or disappears between pages.

Dive Deeper

OFFSET still reads every row it skips, so page 500 costs as much as reading 5,000 rows, and a post published while someone is paging shifts every later page by one. Keyset pagination avoids both: remember the last row's (published_at, id) and ask for WHERE (published_at, id) < ($1, $2) ORDER BY published_at DESC, id DESC LIMIT 10. An index on (published_at, id) lets the database jump straight to that point.

Read the Full Page