CMD Guide
HomeDatabasesSQL Practice Problems

Suspicious Bank Accounts

Mechanism

The query works by collapsing raw transactions into one row per account-month of deposits, throwing away every month that does not breach max_income, and then asking a single question of the survivors: for each over-limit month, was the previous over-limit month exactly one calendar month earlier? If LAG reaches back to a row that is one month away, those two surviving rows were adjacent in time, which is the definition of "two consecutive over-limit months" — and the account is flagged.

The subtle part is that consecutiveness is checked after filtering, on a table that contains only breach months. So adjacency in that filtered table means adjacency in real time only because every non-breach month was deleted first. TIMESTAMPDIFF(MONTH, prev, curr) = 1 is what verifies the two surviving rows really are back-to-back calendar months and not just neighbours in a sparse list.

The schema

Accounts(account_id PK, max_income) — one income ceiling per account. Transactions(transaction_id PK, account_id, type ENUM('Creditor','Debtor'), amount, day DATETIME)'Creditor' is a deposit (counts toward income), 'Debtor' is a withdrawal (ignored). An account is suspicious when its monthly deposit total exceeds max_income for two or more consecutive months.

A worked example with real values

Take two accounts. The Debtor rows are noise — they exist only to prove the WHERE type = 'Creditor' filter matters.

Accounts

account_idmax_income
321000
410400

Transactions (deposits unless marked)

account_iddaytypeamount
32021-06-07Creditor298000
32021-07-12Creditor64900
32021-05-20Debtor40000
42021-05-03Creditor49300
42021-06-18Creditor10400
42021-07-09Creditor56300

Note account 3 has a May row, but it is a Debtor, so May contributes zero income and never appears once we filter to deposits. That is the correct reason May vanishes — not a phantom "0 income" row that somehow passed a strict "> 21000" test.

Step 1 — monthly deposit totals, keeping only the breaches

WITH incomes AS (
  SELECT a.account_id,
         DATE_FORMAT(a.day, '%Y-%m-01') AS trans_month,
         SUM(a.amount)                 AS total_income,
         b.max_income
  FROM Transactions a
  JOIN Accounts b ON a.account_id = b.account_id
  WHERE a.type = 'Creditor'
  GROUP BY a.account_id, DATE_FORMAT(a.day, '%Y-%m-01'), b.max_income
  HAVING SUM(a.amount) > b.max_income
)
SELECT * FROM incomes;

The HAVING runs after aggregation and keeps a group only when its summed deposits strictly exceed max_income. Every row below survives that test — that is the whole point of showing this table.

Output after Step 1 (only breach months — verify each row yourself):

account_idtrans_monthtotal_incomemax_incomepasses > ?
32021-06-0129800021000298000 > 21000 ✓
32021-07-01649002100064900 > 21000 ✓
42021-05-01493001040049300 > 10400 ✓
42021-07-01563001040056300 > 10400 ✓

Why the rows the old page showed were wrong. The previous version printed 3 | 2021-05-01 | 0 | 21000 and 4 | 2021-06-01 | 10400 | 10400. Neither can exist in this CTE: a total of 0 is not > 21000, and 10400 is not > 10400 (the comparison is strict, so equal does not pass). Both fail HAVING and are discarded before the table is ever produced. Account 3's May produced no group at all because its only May transaction was a withdrawal. Printing those phantom rows made the intermediate output contradict the query that generated it.

diagram
diagram

Step 2 — measure the gap to the previous breach

consec_income AS (
  SELECT account_id,
         TIMESTAMPDIFF(
           MONTH,
           LAG(trans_month) OVER (PARTITION BY account_id ORDER BY trans_month),
           trans_month
         ) AS gap
  FROM incomes
)
SELECT * FROM consec_income;

LAG walks each account's breach months in date order and hands back the prior breach month; TIMESTAMPDIFF(MONTH, prev, curr) turns the pair into an integer month gap. A gap of exactly 1 means two breaches sat in back-to-back calendar months.

Output after Step 2 (computed only from the four surviving Step-1 rows):

account_idtrans_monthprev breach (LAG)gap
32021-06-01NULLNULL
32021-07-012021-06-011
42021-05-01NULLNULL
42021-07-012021-05-012

Account 3's July is one month after its June breach → gap = 1. Account 4's two breaches are May and July; June did not breach so it is absent, and LAG jumps straight from July back to May → gap = 2. The vanished June is exactly why the gap is 2, not 1.

Step 3 — keep accounts with any 1-month gap

SELECT DISTINCT account_id
FROM consec_income
WHERE gap = 1;

WHERE gap = 1 discards the NULL first-row gaps and any wider jumps, keeping only accounts that had at least one pair of consecutive breach months. DISTINCT collapses an account that breached three-plus months in a row (which would yield multiple gap = 1 rows) down to one id.

Final result:

account_id
3

Account 4 is correctly excluded: it breached twice, but not in adjacent months.

Pitfalls

Takeaways


Problem from LeetCode 1843 “Suspicious Bank Accounts.” Mechanics of LAG, TIMESTAMPDIFF, DATE_FORMAT, and the HAVING-after-aggregation rule follow the MySQL 8.0 Reference Manual (window functions and date/time functions). Re-authored and deepened for this guide: the Step-1 intermediate output was corrected to show only rows that survive HAVING SUM(amount) > max_income (the previous version printed a 0-income and an equal-to-limit row that the query rejects), a self-consistent worked dataset and a timeline diagram were added, and a “why the naive row-number version is wrong” note was included.

🤖 Don't fully get this? Learn it with Claude

Stuck on Suspicious Bank Accounts? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Suspicious Bank Accounts** (Databases) and want to truly understand it. Explain Suspicious Bank Accounts 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.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Suspicious Bank Accounts** 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.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Suspicious Bank Accounts** 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.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Suspicious Bank Accounts** 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.

📝 My notes