skip to content

Why does a top-3-per-customer query built from a correlated COUNT return four rows for some customers?

level: seniorimportance: should knowfreq 35%

answer

  1. count how many rows beat this one
  2. equal keys produce equal counts
  3. the boundary tie survives together
  4. behaves like a rank, not a row number
  5. make the ordering key unique

basics

~20 s

Because the inner count of strictly-later rows is equal for tied rows, so every row in a tie at the cut-off passes the predicate. The idiom ranks like RANK, not ROW_NUMBER; add a unique tiebreaker to the inner comparison for exactly N.

solid answer

~40 s

The idiom is `WHERE (SELECT COUNT(*) FROM orders o2 WHERE o2.customer_id = o.customer_id AND o2.order_date > o.order_date) < 3` — keep a row when fewer than three of its customer's orders are strictly newer. Two orders sharing the same date have the *same* count of strictly-later rows, so if that tie sits at the boundary both survive and you get four rows. The comparison is `>` rather than `>=` deliberately: with `>=` each row would count itself and you would get one row too few. To force exactly N, break the tie inside the predicate with a unique column: count rows where `o2.order_date > o.order_date OR (o2.order_date = o.order_date AND o2.order_id > o.order_id)`. That makes the ordering total, so each row has a distinct count and the cut is exact.

code

sql · 8 lines
sql
SELECT o.*
FROM orders o
WHERE (SELECT COUNT(*)
       FROM orders o2
       WHERE o2.customer_id = o.customer_id
         AND o2.order_date  > o.order_date) < 3;
-- two orders sharing a date have the same count, so a tie at the
-- cut-off returns four rows for that customer

go deeper

for a junior

Recognise the shape: a subquery counting how many rows of the same group beat the current one, with the count compared to N. Being able to read it back in plain English is enough here.

for a middle

Explain why equal ordering keys yield equal counts and therefore extra rows, and write the version with a unique tiebreaker column added to the inner comparison.

for a senior

Diagnose it from a symptom — a report showing more rows than requested — and decide which behaviour the requirement actually wants: exactly N, or all rows tied at position N. State the NULL and missing-correlation failure modes too.

for a principal

Own the definitional question: what "top 3" means when the ordering key is not unique, and where that rule lives so reports, exports and the API agree instead of each embedding its own tiebreaker.

## The idiom Before window functions were widely available, top-N-per-group was written with a correlated count: a row belongs to the top N of its group when fewer than N rows of that group beat it. ```sql SELECT o.* FROM orders o WHERE (SELECT COUNT(*) FROM orders o2 WHERE o2.customer_id = o.customer_id AND o2.order_date > o.order_date) < 3; ``` The subquery is correlated twice over: on `o.customer_id`, which confines the count to the same customer, and on `o.order_date`, which is the ordering key. Reading it as "how many of this customer's orders are newer than this one" makes the predicate obvious — the newest order has 0, the second 1, the third 2, and everything else 3 or more. ## Why ties produce extra rows The count is a function of the *values* compared, not of row identity. If a customer's 3rd and 4th newest orders share the same `order_date`, both have exactly two strictly-later orders, so both satisfy `< 3` and four rows come back. Push the tie further up — two orders on the newest date — and both score 0, the next scores 2, and you still get four rows overall for that customer. This behaviour is *rank-like*: rows tied on the ordering key are treated as occupying the same position, exactly as a rank would. It is not a bug in the idiom, it is the consequence of comparing on a non-unique key. Whether you want it depends on the requirement: "the three newest orders" wants exactly three, while "all orders in the three newest positions" wants the ties kept. ## Getting exactly N Make the ordering total by adding a unique column as a secondary comparison inside the correlated predicate: ```sql SELECT o.* FROM orders o WHERE (SELECT COUNT(*) FROM orders o2 WHERE o2.customer_id = o.customer_id AND (o2.order_date > o.order_date OR (o2.order_date = o.order_date AND o2.order_id > o.order_id))) < 3; ``` Now no two rows of the same customer can have the same count, because `(order_date, order_id)` is unique, so each customer contributes at most three rows and the choice among tied dates is deterministic. Some engines let you write that condition as a row-value comparison, `(o2.order_date, o2.order_id) > (o.order_date, o.order_id)`; the expanded `OR` form above is the portable spelling. ## Why `>` and not `>=` With `>=` the inner query also counts the outer row itself, since a row trivially satisfies `o2.order_date >= o.order_date` when `o2` is that same row. Every count shifts up by one and `< 3` yields only the top two per customer. Candidates get this backwards constantly. If you prefer to think in ranks, `>=` gives you "the rank of this row" (1-based) and the predicate becomes `<= 3`; `>` gives you "how many beat it" (0-based) and the predicate is `< 3`. Pick one convention and be consistent. ## The top-1 special case For N = 1 the usual spelling is an equality against a correlated aggregate: ```sql SELECT o.* FROM orders o WHERE o.order_date = (SELECT MAX(o2.order_date) FROM orders o2 WHERE o2.customer_id = o.customer_id); ``` Same tie behaviour — every order sharing the customer's maximum date comes back — plus a NULL trap: rows whose `order_date` is NULL never satisfy the equality, because `NULL = anything` is unknown, so those orders are simply absent from the result rather than sorted to one end. ## When to use this shape at all On an engine with window functions, ranking in a derived table is the clearer expression of the same intent and it states tie handling explicitly by the choice of ranking function. The correlated-count idiom still earns its place in two situations: engines or contexts without window support, and cases where N is not a constant but depends on the group. Being able to read it matters regardless — it is common in older code, and misreading its tie behaviour is how "why does this report show four rows" tickets happen. ## Common mistakes - Assuming the predicate guarantees exactly N rows per group. - Using `>=` and quietly returning N−1 rows. - Forgetting the `o2.customer_id = o.customer_id` correlation, which turns a per-customer top-N into a global one. - Ignoring NULLs in the ordering key in the `MAX` variant.

  • What changes if you write >= instead of > in the inner comparison?
    Each row then counts itself, because it trivially satisfies `o2.order_date >= o.order_date`. Every count shifts up by one, so `< 3` returns only the top two per customer. Keep `>` with `< N`, or switch deliberately to `>=` with `<= N` and read the count as a 1-based rank.
  • How do you get only the single newest order per customer with a correlated subquery?
    Compare against a correlated `MAX`: `WHERE o.order_date = (SELECT MAX(o2.order_date) FROM orders o2 WHERE o2.customer_id = o.customer_id)`. Ties on the maximum date all come back, and any order with a NULL `order_date` is excluded, since NULL never satisfies an equality.
  • What happens if you drop the o2.customer_id = o.customer_id condition?
    The subquery stops being correlated on the group and counts newer orders across the entire table, so the query returns the three newest orders overall rather than three per customer. That single missing line is the most common way this idiom is broken in real code.

saying these in an interview costs you the question

  • Claims the predicate always returns exactly N rows per group
  • Uses >= and does not notice one row is missing
  • Thinks ties are broken arbitrarily by the engine
  • Omits the group correlation and gets a global top-N
  • Ignores NULL ordering keys in the MAX variant

context