Monthly Transactions II
The mechanism in one line
Reshape two differently-shaped event streams into one stream that carries a synthetic state label, then let a single grouped pass count and sum each label per bucket — so chargebacks and approvals are tallied side by side without ever joining the two aggregates back together.
That synthetic-label trick has a name worth keeping: a tagged union (also called a discriminated union or a UNPIVOT-by-UNION). You normalize heterogeneous rows to a common schema, add a discriminator column, stack them with UNION ALL, and then a conditional SUM(CASE …) demultiplexes them back into separate output columns. It is the SQL equivalent of switch on a tagged enum.
The trap that makes this problem "II"
A chargeback is not bucketed by the original transaction's date — it is bucketed by the chargeback's own trans_date, but it inherits the country and amount of the original transaction. So three independent facts about one transaction can land in three different months:
- The original approval counts in the month it was approved.
- The chargeback of that same transaction counts in the (later) month the chargeback arrived.
- A chargeback can hit a transaction that was declined or even approved in a month with zero other activity.
This is why the chargeback branch must JOIN Transactions (to recover country and amount) while taking its month from Chargebacks.trans_date.
The corrected query
The walkthrough's query is correct as written. Two details carry the whole problem: the chargeback branch dates by Chargebacks.trans_date (not the transaction's), and HAVING — not WHERE — does the zero-row filtering because it must run after aggregation.
SELECT s.month,
s.country,
SUM(CASE WHEN s.state = 'approved' THEN 1 ELSE 0 END) AS approved_count,
SUM(CASE WHEN s.state = 'approved' THEN s.amount ELSE 0 END) AS approved_amount,
SUM(CASE WHEN s.state = 'back' THEN 1 ELSE 0 END) AS chargeback_count,
SUM(CASE WHEN s.state = 'back' THEN s.amount ELSE 0 END) AS chargeback_amount
FROM (
-- chargeback branch: month from the CHARGEBACK, country/amount from the TRANSACTION
SELECT LEFT(c.trans_date, 7) AS month,
t.country,
'back' AS state, -- synthetic discriminator
t.amount
FROM Chargebacks c
JOIN Transactions t ON c.trans_id = t.id
UNION ALL
-- approval branch
SELECT LEFT(t.trans_date, 7) AS month,
t.country,
t.state,
t.amount
FROM Transactions t
WHERE t.state = 'approved'
) s
GROUP BY s.month, s.country
HAVING approved_count > 0 OR chargeback_count > 0
ORDER BY s.month;Use single quotes for string literals ('back'): double quotes are standard-SQL identifiers, and on a database with ANSI_QUOTES enabled the original "back" would be parsed as a column name and error. MySQL's default mode happens to tolerate it, which is exactly the kind of portability landmine worth removing.
Worked trace on the real example
Inputs (the canonical dataset, all US):
| Transactions id | state | amount | trans_date |
|---|---|---|---|
| 101 | approved | 1000 | 2019-05-18 |
| 102 | declined | 2000 | 2019-05-19 |
| 103 | approved | 3000 | 2019-06-10 |
| 104 | declined | 4000 | 2019-06-13 |
| 105 | approved | 5000 | 2019-06-15 |
| Chargebacks trans_id | chargeback trans_date |
|---|---|
| 102 | 2019-05-29 |
| 101 | 2019-06-30 |
| 105 | 2019-09-18 |
Step 1 — approval branch (only approved rows survive; 102 and 104 are dropped here):
| month | country | state | amount |
|---|---|---|---|
| 2019-05 | US | approved | 1000 |
| 2019-06 | US | approved | 3000 |
| 2019-06 | US | approved | 5000 |
Step 2 — chargeback branch (month from the chargeback date; amount/country recovered via the join — note CB on 102 survives even though 102 was declined):
| month (CB date) | country | state | amount (from txn) |
|---|---|---|---|
| 2019-05 | US | back | 2000 (txn 102) |
| 2019-06 | US | back | 1000 (txn 101) |
| 2019-09 | US | back | 5000 (txn 105) |
Step 3 — UNION ALL + GROUP BY + conditional SUM. The 2019-09 bucket has zero approvals but one chargeback, so HAVING keeps it. Every output reconciles with the stacked stream:
| month | country | approved_count | approved_amount | chargeback_count | chargeback_amount |
|---|---|---|---|---|---|
| 2019-05 | US | 1 | 1000 | 1 | 2000 |
| 2019-06 | US | 2 | 8000 | 1 | 1000 |
| 2019-09 | US | 0 | 0 | 1 | 5000 |
The 2019-06 approved_amount = 3000 + 5000 = 8000 confirms the conditional sum is adding only the rows whose discriminator matches.
Why the naive versions are wrong
- Two separate aggregates then JOIN. The tempting shape is: aggregate approvals by (month, country), aggregate chargebacks by (month, country), then
JOINthem. But2019-09has a chargeback and no approval, and2019-05would in general have months that exist on only one side. AnINNER JOINsilently drops these; you need aFULL OUTER JOINwithCOALESCEon both sides — more code and more chances to forget a side. The tagged union sidesteps the outer-join entirely. - Dating the chargeback by the transaction's date. Joining and then using
LEFT(t.trans_date,7)puts the chargeback of txn 101 into2019-05instead of2019-06, and txn 105's chargeback into2019-06instead of2019-09— the whole point of part "II" lost. - Filtering zeros with WHERE.
WHEREruns before grouping, so it cannot seeapproved_count. Putting the all-zero filter anywhere butHAVINGis a syntax or logic error. - Restricting the chargeback join to approved transactions. The spec says a chargeback can correspond to a transaction "even if it was not approved." Txn 102 was declined yet still produces a valid chargeback row; an
AND t.state='approved'on the join would erase it.
Pitfalls
UNIONvsUNION ALL. PlainUNIONdeduplicates. Two genuinely identical (month, country, state, amount) rows — e.g. two 1000-amount approvals in the same US month — would collapse to one and undercount. AlwaysUNION ALLfor additive aggregation.- Quoting strings as
"…". Portable only by luck. UnderANSI_QUOTES/ Postgres, double quotes mean identifier; use'back'. - Month via string slicing.
LEFT(date,7)assumesYYYY-MM-DDtext formatting; on engines that store/return dates differently, preferDATE_FORMAT(d,'%Y-%m')(MySQL) orTO_CHAR(d,'YYYY-MM')(Postgres) to be format-independent. - Schema mismatch in the union. The two
SELECTbranches must list columns in the same order and compatible types;UNION ALLmatches by position, not by name, so a swappedcountry/statewould compile and silently corrupt results.
Takeaways
- When you need parallel metrics over heterogeneous rows, tag-and-stack (UNION ALL with a discriminator) then demultiplex with conditional
SUM(CASE)— it replaces a fragileFULL OUTER JOINof two aggregates with one clean grouped pass. - Conditional
SUM(CASE WHEN … THEN 1/amount ELSE 0)is the generic SQL pivot: each output column is one branch of aswitchover the discriminator. - Know which date governs each fact — here the chargeback is bucketed by its own arrival date but carries the transaction's country and amount; mixing those up is the entire difficulty of this problem.
HAVINGis the only place a post-aggregation predicate (like "drop all-zero buckets") can live.
Problem from LeetCode 1645 "Hopper Company Queries"-family / "Monthly Transactions II"; canonical example dataset reproduced. Mechanism framing (tagged/discriminated union, conditional-aggregation pivot) draws on the SQL standard's treatment of UNION ALL and CASE, and on the conditional-aggregation pattern documented in the PostgreSQL and MySQL manuals (CASE, DATE_FORMAT/TO_CHAR) and in Itzik Ben-Gan's T-SQL Querying. Re-authored and deepened for this guide: added the mechanism statement, named the tagged-union pattern, traced the real example end-to-end, diagrammed the pipeline, fixed the string-literal quoting for portability, and documented the four naive-version failure modes.
🤖 Don't fully get this? Learn it with Claude
Stuck on Monthly Transactions II? 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 **Monthly Transactions II** (Databases) and want to truly understand it. Explain Monthly Transactions II 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 **Monthly Transactions II** 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 **Monthly Transactions II** 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 **Monthly Transactions II** 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.