Generated by Codex with GPT 5.6 Sol High
Quick facts
- Difficulty:
MEDIUM - Problem: Monthly Transactions I
- Topics:
Database
The problem in one picture
The Transactions table contains one row per transaction: its country, whether it was approved or declined, its amount, and its date. The requested report has one row for every (calendar month, country) pair and six columns:
- the month and country that identify the group;
- the number and total amount of all transactions in that group; and
- the number and total amount of only the approved transactions in that group.
This is a conditional-aggregation problem. The grouping decides which rows belong to one output row, while each aggregate decides what to measure inside that group.
Deriving the optimal query
First, convert each full date into a month key such as 2019-01. Then group by that month key and country. A plain COUNT(*) and SUM(amount) produce the two all-transaction metrics.
The approved metrics use the same groups but contribute a value only when state = 'approved'. A CASE expression turns an approved row into 1 for the count and its amount for the sum; every other row contributes 0. That lets the database calculate the entire report in one grouping pass instead of scanning or joining the table multiple times.
SQL solution
SELECT
DATE_FORMAT(trans_date, '%Y-%m') AS month,
country,
COUNT(*) AS trans_count,
SUM(CASE WHEN state = 'approved' THEN 1 ELSE 0 END) AS approved_count,
SUM(amount) AS trans_total_amount,
SUM(CASE WHEN state = 'approved' THEN amount ELSE 0 END)
AS approved_total_amount
FROM Transactions
GROUP BY DATE_FORMAT(trans_date, '%Y-%m'), country;Each input row enters exactly one month-and-country group. The unconditional aggregates see every row, while the two CASE expressions mask declined rows with zero. The query therefore produces all four measures with one table scan followed by one grouped aggregation.
If there are n transactions and g distinct month-country groups, a hash aggregation takes expected O(n) time and O(g) working space. A database may instead choose a sort-based plan, which can take O(n log n) time. An index beginning with trans_date can help when a date-range filter is added, but computing a formatted month for every row does not by itself make the unfiltered aggregation sublinear: every transaction still contributes to the totals.
Python solution
from __future__ import annotations
from collections.abc import Sequence
import pandas as pd
REQUIRED_COLUMNS: tuple[str, ...] = (
"id",
"country",
"state",
"amount",
"trans_date",
)
def _require_columns(frame: pd.DataFrame, columns: Sequence[str]) -> None:
"""Raise a useful error when the input does not match the problem schema."""
missing_columns = [column for column in columns if column not in frame.columns]
if missing_columns:
missing = ", ".join(missing_columns)
raise ValueError(f"Transactions is missing required columns: {missing}")
def monthly_transactions(transactions: pd.DataFrame) -> pd.DataFrame:
"""Aggregate total and approved transactions by month and country.
The input frame is never mutated. Invalid dates raise an exception instead of
being silently omitted, and null countries remain valid grouping keys.
"""
_require_columns(transactions, REQUIRED_COLUMNS)
working = transactions.loc[:, REQUIRED_COLUMNS].copy()
working["trans_date"] = pd.to_datetime(working["trans_date"], errors="raise")
working["month"] = working["trans_date"].dt.strftime("%Y-%m")
is_approved = working["state"].eq("approved")
working["approved_count"] = is_approved.astype("int64")
working["approved_amount"] = working["amount"].where(is_approved, 0)
return (
working.groupby(["month", "country"], dropna=False, sort=False)
.agg(
trans_count=("id", "size"),
approved_count=("approved_count", "sum"),
trans_total_amount=("amount", "sum"),
approved_total_amount=("approved_amount", "sum"),
)
.reset_index()
)The Python version mirrors the SQL query. It derives the month key once, creates two per-row approved contributions, and performs one grouped aggregation. Its expected time is O(n), and its additional memory usage is O(n + g) because the defensive copy and derived columns coexist with the grouped result.
Interview follow-ups
How would the query change if the report covered only a date range?
Add a half-open filter before grouping, for example trans_date >= :start_date AND trans_date < :end_date. A half-open interval avoids end-of-day mistakes if the column later changes from DATE to DATETIME. The aggregation logic remains correct because it is applied only to qualifying rows. With an index whose leading column is trans_date, the database can scan just the matching range; the cost becomes proportional to the selected rows rather than the entire table, plus the grouped output.
How would you include months with no transactions?
Aggregation cannot invent missing groups. Start from a calendar table containing every required month, cross join it with the desired country set, and left join the transaction aggregates onto those pairs. Replace null aggregate values with zero using COALESCE. This makes the output size O(mc) for m months and c countries, so it is more expensive than returning only observed groups but correctly represents empty periods.
How would you add declined and pending metrics without another scan?
Add more conditional aggregates to the same SELECT, one pair for each state. Each expression contributes either 1 or amount when its condition matches and zero otherwise. The query still performs one logical grouping pass, so its asymptotic time remains O(n); only the constant work and width of each output row increase.
What if the database does not support DATE_FORMAT?
Use the dialect’s month-bucketing function while preserving the same grouping key. PostgreSQL can group by DATE_TRUNC('month', trans_date) and format only the final display value; SQL Server can group by DATEFROMPARTS(YEAR(trans_date), MONTH(trans_date), 1). Grouping by a real date is often preferable to grouping by display text because it sorts chronologically and avoids formatting-dependent behavior. The aggregation and its complexity do not otherwise change.
How would you make this report fast on a very large append-only table?
Partition the fact table by transaction date and maintain a monthly summary table keyed by month and country. New batches can be aggregated and merged into the summary, so dashboards read O(g) precomputed rows instead of rescanning O(n) raw transactions. The tradeoff is extra storage and write-path complexity: late corrections require an upsert or a rebuild of the affected month.
How should refunds or signed amounts be handled?
Clarify the business definition before changing the query. If refunds are stored as negative amounts and totals are meant to be net, the existing SUM(amount) is already correct. If the report needs gross sales and refunds separately, add conditional sums based on a transaction type or the sign of amount. One-pass aggregation still works, but the metric names and conditions must make the accounting semantics explicit.
Could a window function replace GROUP BY here?
A window function can compute the same totals over partitions of month and country, but it retains one output row per input transaction. An additional deduplication step would then be necessary to produce one report row per group. That does more work and communicates the intent less clearly, so GROUP BY is the appropriate solution unless row-level detail and group totals are both required in the same result.