Top Product Category by Transactions

A medium SQL interview practice problem on DataDriven. Write the query and run it against a real database, with instant feedback.

Domain
SQL
Difficulty
medium
Seniority
L4

Problem

Our all-time category leaderboard crowns a single winner and hides how the lead has changed hands from year to year. For each of the three latest calendar years in transactions, show the year, the products category with the most purchases that year and its purchase count, keeping every category tied for a year's lead.

Organic purchases, no marketing nudge. Which category wins?

Tables

transactions

Columns: transaction_id INTEGER, user_id INTEGER, product_id INTEGER, quantity INTEGER, total_amount REAL, transaction_date TEXT

transactions: 10 of 200 rows
transaction_iduser_idproduct_idquantitytotal_amounttransaction_date
10671971001223.462026-02-02
11342941050336.932026-03-03
12013911099450.42026-04-04
12684881148563.872026-05-05
13355851197002026-06-06
14026821246290.812026-07-07
146977912953104.282026-08-08
153687613444117.752026-09-09
160397313935-452026-01-10
1670107014421144.692026-02-11

products

Columns: product_id INTEGER, product_name TEXT, category TEXT, price REAL, rating REAL, in_stock INTEGER

products: 10 of 200 rows
product_idproduct_namecategorypriceratingin_stock
1001Basic Device 1XBooks17.522.71
1050Deluxe Bundle 2XClothing25.054.41
1099Pro Unit 3XHome & Kitchen32.582.11
1148Ultra Tool 4XCollectibles40.113.81
1197Essential Set 5XToys47.641.51
1246Classic System 6XBeauty03.21
1295Smart Gadget 7XAutomotive62.74.90
1344Eco Kit 8XGarden70.232.61
1393Turbo Pack 9XMusic77.764.30
1442Premium Widget 10XElectronics85.2921

Expected output

First 4 of 4 rows
sales_yearcategorytransaction_count
2024Garden9
2024Home & Kitchen9
2025Music9
2026Electronics20

Worked solution and explanation

What this really is

This is a per-group arg-max with ties, dressed up as a leaderboard review. Anyone can count transactions per year and category. The trick is that the maximum you compare against has to belong to the same year. Reach for one global MAX and every year whose leader sold less than the busiest year's leader quietly drops out, so a three-year report comes back with one row in it.

The second quiet requirement is the window. 'The three latest years' is a fact about transactions, not about today's calendar, so the bound is computed from the table. The data runs back to 2022; hardcode the years, or forget the bound, and old leaders leak into the report.


Building it

Step 1: Anchor the window with a scalar subquery

SELECT MAX(EXTRACT(YEAR FROM latest.transaction_date)) - 2 FROM transactions latest returns one number: the earliest year still in scope. The WHERE compares each row's EXTRACT(YEAR FROM t.transaction_date) against it, so the older years never reach the counts in y.

Step 2: Bucket by year and count per category

DATE_TRUNC('year', t.transaction_date) collapses every date to its year start, year_start, which is the grouping key. Inner-join transactions to products on product_id to pull category, then COUNT(*). Rows with a NULL product_id have no category and fall out of the join. Out-of-stock products still count: in_stock describes today, not the sale.

Step 3: Self-join the counts on the year bucket

rival is the same per-year, per-category count over every year. Joining it to y on year_start lets each row see every competitor in its own year, so HAVING y.transaction_count = MAX(rival.transaction_count) keeps that year's leader and nothing else. Co-leaders both pass.

Step 4: Label the bucket for the report

TO_CHAR(y.year_start, 'YYYY') turns the January-first timestamp into the plain year a reader expects. The join and the grouping stay on the bucket itself, so the label is pure presentation.

Per-year leaders, ties kept
SELECT TO_CHAR(y.year_start, 'YYYY') AS sales_year,
       y.category,
       y.transaction_count
FROM (
  SELECT DATE_TRUNC('year', t.transaction_date) AS year_start,
         p.category,
         COUNT(*) AS transaction_count
  FROM transactions t
  JOIN products p ON t.product_id = p.product_id
  WHERE EXTRACT(YEAR FROM t.transaction_date) >= (
    SELECT MAX(EXTRACT(YEAR FROM latest.transaction_date)) - 2
    FROM transactions latest
  )
  GROUP BY year_start, p.category
) y
JOIN (
  SELECT DATE_TRUNC('year', rt.transaction_date) AS year_start,
         rp.category,
         COUNT(*) AS transaction_count
  FROM transactions rt
  JOIN products rp ON rt.product_id = rp.product_id
  GROUP BY year_start, rp.category
) rival ON rival.year_start = y.year_start
GROUP BY y.year_start, y.category, y.transaction_count
HAVING y.transaction_count = MAX(rival.transaction_count);
One global maximum
HAVING COUNT(*) = (SELECT MAX(cnt) FROM ...) with no year scoping compares one year's category against the busiest category of any year. Only the single peak year survives; the other two vanish.
Maximum per year
Joining rival to y on year_start means MAX(rival.transaction_count) is that year's peak. Every year reports its own leader, ties included.

Common follow-up questions