Number of Transactions per Visit
Every visit is a row in Visits; every transaction is a row in Transactions tagged with the same (user_id, date) as the visit that contained it — so a LEFT JOIN from Visits to Transactions followed by COUNT(transaction_date) turns each visit into a single integer (a no-transaction visit keeps its row but counts 0 because COUNT ignores the NULL the outer join produced), and then you simply tally how many visits landed on each integer 0, 1, 2, … up to the busiest visit.
The two stages
The whole problem is two independent transforms chained together. Getting them straight is the key insight:
- Per-visit count — collapse the join down to one number per visit. This is the genuinely instructive step: the
LEFT JOINguarantees every visit survives, andCOUNT(transaction_date)(counting a column, not*) treats the unmatchedNULLas 0. UseCOUNT(*)here and a zero-transaction visit would wrongly count as 1. - Histogram — take that bag of per-visit counts and ask “how many visits had exactly k?” for every k from 0 to the maximum. The trap is k-values that nobody hit (e.g. exactly 2 transactions): the spec demands they appear with
visits_count = 0, so you cannot justGROUP BYthe counts — you must generate the full dense range 0..max and left-join the histogram onto it.
Worked example, traced end to end
Take the canonical inputs (10 visits, 8 transactions):
| Visit (user, date) | Matching transaction rows | COUNT(transaction_date) |
|---|---|---|
| (1, 01-01) | — none — | 0 |
| (2, 01-02) | — none — | 0 |
| (12, 01-01) | — none — | 0 |
| (19, 01-03) | — none — | 0 |
| (1, 01-02) | 120 | 1 |
| (2, 01-03) | 22 | 1 |
| (1, 01-04) | 7 | 1 |
| (7, 01-11) | 232 | 1 |
| (8, 01-28) | 1 | 1 |
| (9, 01-25) | 33, 66, 99 | 3 |
Stage 1 yields the bag of per-visit counts {0,0,0,0, 1,1,1,1,1, 3}. The maximum is 3. Stage 2 generates the dense range 0,1,2,3 and tallies each — including 2, which no visit produced:
| transactions_count | visits_count | where it came from |
|---|---|---|
| 0 | 4 | the four no-transaction visits |
| 1 | 5 | five single-transaction visits |
| 2 | 0 | nobody — but still required by the spec |
| 3 | 1 | user 9 on 01-25 |
This output is verified against SQLite 3.51.
The query
Here is a portable, correct version. The per-visit count is unchanged from the original page (it was the strong part); the sequence is generated robustly with a recursive CTE, which works in PostgreSQL, MySQL 8+, SQL Server and SQLite:
WITH per_visit AS (
SELECT COUNT(t.transaction_date) AS cnt
FROM Visits v
LEFT JOIN Transactions t
ON v.user_id = t.user_id
AND v.visit_date = t.transaction_date
GROUP BY v.user_id, v.visit_date
),
seq(n) AS ( -- generate 0,1,2,...,max reliably
SELECT 0
UNION ALL
SELECT n + 1 FROM seq
WHERE n < (SELECT MAX(cnt) FROM per_visit)
)
SELECT seq.n AS transactions_count,
COUNT(per_visit.cnt) AS visits_count
FROM seq
LEFT JOIN per_visit ON per_visit.cnt = seq.n
GROUP BY seq.n
ORDER BY seq.n;The final COUNT(per_visit.cnt) counts a column (not *) for the same reason as stage 1: when a generated k matches no visit, the left join supplies NULL and COUNT skips it, yielding the required 0.
Why the original sequence generator is fragile
The widely-copied version generates the range like this:
WITH t AS (
SELECT ROW_NUMBER() OVER() AS row_num FROM Transactions
UNION
SELECT 0
)It assigns 1..N to the N rows of Transactions and unions in a 0, giving {0,1,…,N} where N = COUNT(*) FROM Transactions. But the required range is 0..M, where M is the maximum transactions in any single visit — and the total transaction count N is almost always larger than the busiest single visit M. So on this very dataset (8 transaction rows, busiest visit = 3) the idiom overshoots: it emits {0,1,…,8} — nine buckets — with spurious trailing rows (4,0),(5,0),…,(8,0) that are not in the answer (only 0..3). It does not “happen to be correct here” — it is wrong here. Sizing a sequence by a table's cardinality is simply the wrong tool: here N > M makes it overshoot; on any other “generate 0..K” problem where K exceeds the table's row count, the same idiom does the opposite and silently truncates. The truncation case, demonstrated:
-- base table has 3 rows, but we want 0..5
WITH t AS (SELECT ROW_NUMBER() OVER() AS row_num FROM Tiny -- 3 rows
UNION SELECT 0)
SELECT MAX(row_num) FROM t; -- returns 3, NOT 5 ← wrongIt also wastes work: you materialise one row per transaction (millions of rows) just to read off the integers 1..N, when you only need 0..max(cnt) — a handful of values. The recursive CTE generates exactly the values you need and makes the upper bound, MAX(cnt), explicit in the code rather than smuggled in through a row count.
Pitfalls
COUNT(*)instead ofCOUNT(column). The single most common bug. A zero-transaction visit survives theLEFT JOINas one row full ofNULLs;COUNT(*)counts that row as 1, so your “0 transactions” bucket vanishes and every count is off by one for empty visits.- Forgetting the empty buckets. If you just
GROUP BYthe per-visit counts, value 2 (which no visit hit) is dropped from the output. The spec explicitly requires every integer from 0 to max, including ones withvisits_count = 0. Generating the dense range is not optional polish — it is the requirement. - Counting transactions globally instead of per visit. You must
GROUP BY (user_id, visit_date), not by user. The histogram is over visits; a user who visits twice contributes two data points. - Joining on
user_idalone. A transaction belongs to a specific visit; the join key must be bothuser_idand the date. Drop the date and a user's transactions smear across all of their visits. - Relying on row count to size a sequence. The
ROW_NUMBER() OVER() FROM SomeTabletrick is not a general number generator — it caps at the table's cardinality and its ordering with an emptyOVER()is unspecified. Use a recursive CTE (or a dedicated numbers/tally table) when you need a guaranteed contiguous range.
Takeaways
- Two transforms, kept separate: first collapse each visit to one count (an outer join preserves zero-transaction visits), then build a histogram over those counts.
COUNT(col)is the load-bearing trick on both joins: it converts theNULLfrom an unmatchedLEFT JOINinto the0the problem needs.- Dense output means generate the axis. When the result must include keys that no data row produced, synthesise the full key range and left-join your data onto it.
- Generate sequences explicitly. Prefer a recursive CTE bounded by a real expression (
MAX(cnt)) over theROW_NUMBER()-over-a-tablehack, which only works when the range happens to fit inside a row count.
Problem from LeetCode 1565-series “Number of Transactions per Visit” (Hard). Sequence-generation analysis informed by the SQLite (3.51) and PostgreSQL documentation on recursive CTEs and by Joe Celko's discussion of auxiliary numbers tables in SQL for Smarties. The per-visit LEFT JOIN + COUNT(column) explanation is retained from the original lesson. All query outputs in this page were executed and verified on SQLite 3.51. Re-authored and deepened for this guide: the fragile ROW_NUMBER()-over-Transactions sequence generator was replaced with a robust recursive CTE, with a demonstrated failing case and a note on why the original happened to be correct here.
🤖 Don't fully get this? Learn it with Claude
Stuck on Number of Transactions per Visit? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Number of Transactions per Visit** (Databases) and want to truly understand it. Explain Number of Transactions per Visit from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Number of Transactions per Visit** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Number of Transactions per Visit** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Number of Transactions per Visit** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.