Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

SQL (PostgreSQL, MySQL, SQLite)  Module 22 – SQL interview preparation

Interview drills: reports, pivots, percentages

Original SQL interview problems on business reports: the grain, pivots with conditional aggregation, shares that add up to 100 and rounding last.

  • Intermediate
  • 30 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8, PostgreSQL 18.6 and MySQL 8.4.11
  • By MySmartCoPilot
Free intro, Pro: 7 of 30 minutes free
Free: Two problems for one month of orders · The brute force, and why it fails at full size · What to notice. With a pass: the other 9 sections and 3 interview answers, about 23 minutes.

What you will learn

  • Build business reports with the correct grain and denominators
  • Convert rows into columns with conditional aggregation
  • Distinguish zero from no value, and list groups that have no rows
  • Format and round numbers only in the final step, so that shares add up

Before you start

What you’ll learn · Before you start

What you will learn

  • Build business reports with the correct grain and denominators
  • Convert rows into columns with conditional aggregation
  • Distinguish zero from no value, and list groups that have no rows
  • Format and round numbers only in the final step, so that shares add up

Before you start

On this page

Reporting questions ask for the table a manager would read: one row per city, a column per payment method, each category’s share of revenue. The SQL is mostly GROUP BY and CASE; what interviewers check is the grain and the denominators. Write the grain first, “one row per city”, because a join can quietly change it: an order paid in two parts becomes two rows. Name each denominator, a share of what, and say what happens when it is zero. Keep every group the report needs, also one without rows, by starting from the table that lists the groups. Round only at the end, because rounding early makes totals drift, and formatting turns numbers into text.

Two problems for one month of orders

Problem A: the payment mix by city. An invented grocery delivery service has cities(id, name), orders(id, city_id, status, placed_on, fee_paise) and payments(id, order_id, method, amount_paise); an order paid in two parts has two payments. For one month, return one row per city, every city included: its number of delivered orders and the share of their paid amount that came by upi, card and cash, in per cent with one decimal. Full size: 300 cities, 4 million orders and 4.6 million payments a month.

Problem B: shares of revenue. From categories(id, name) and order_items(order_id, category_id, qty, unit_paise), return each category’s revenue and its share of the total with one decimal, every category included. The shares as shown must add up to 100.0.

Order 103 with two payments becomes two rows when orders are joined to payments, so a count of rows counts it twice.orders: one row per order103 · Indore · fee 2500payments: one row per payment103 · upi · 30000103 · card · 50000orders JOIN payments: one row per payment103 · upi · 30000 · fee 2500103 · card · 50000 · fee 2500Report grain: one row per citycount orders at the order grain,sum amounts at the payment graincount(*) counts 103 twice,sum(fee) adds its fee twice

A join changes the grain from orders to payments

Text description of the diagram

The diagram follows one order of the lesson's data through a join.

  1. The orders table has one row per order: order 103, in Indore, with a delivery fee of 2500 paise.
  2. The payments table has one row per payment, and order 103 was paid in two parts: 30000 paise by upi and 50000 by card.
  3. Joining orders to payments gives one row per payment, so order 103 appears twice, each time with its fee of 2500.
  4. The report wants one row per city. Counting rows after the join counts order 103 twice, and summing the fee adds it twice, so orders must be counted at the order grain and payments summed at the payment grain.

The brute force, and why it fails at full size

The literal translation computes every cell of the report with a subquery of its own:

One subquery per cell, for the upi column SQL · sqlite/brute_force.sql
-- An invented grocery delivery service: its cities, orders and payments. Amounts are in paise; an order
-- can be paid in two parts, so it can have two payments.
CREATE TABLE cities (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO cities VALUES (1, 'Indore'), (2, 'Kochi'), (3, 'Nagpur'), (4, 'Surat');
CREATE TABLE orders (
  id        INTEGER PRIMARY KEY,
  city_id   INTEGER NOT NULL REFERENCES cities (id),
  status    TEXT    NOT NULL,                 -- 'delivered' or 'cancelled'
  placed_on TEXT    NOT NULL,                 -- 'YYYY-MM-DD'
  fee_paise INTEGER NOT NULL                  -- the delivery fee, one per order
);
INSERT INTO orders VALUES
  (101, 1, 'delivered', '2026-09-03', 2500), (102, 1, 'delivered', '2026-09-07', 1500),
  (103, 1, 'delivered', '2026-09-12', 2500), (104, 1, 'cancelled', '2026-09-15', 2500),
  (105, 1, 'delivered', '2026-09-28', 1500), (201, 2, 'delivered', '2026-09-02', 2000),
  (202, 2, 'delivered', '2026-09-19', 2000), (203, 2, 'delivered', '2026-09-30', 0),
  (301, 3, 'delivered', '2026-08-30', 2000), (401, 4, 'delivered', '2026-09-05', 1500),
  (402, 4, 'delivered', '2026-09-21', 1500);
CREATE TABLE payments (
  id           INTEGER PRIMARY KEY,
  order_id     INTEGER NOT NULL REFERENCES orders (id),
  method       TEXT    NOT NULL,              -- 'upi', 'card' or 'cash'
  amount_paise INTEGER NOT NULL
);
INSERT INTO payments (order_id, method, amount_paise) VALUES
  (101, 'upi', 45000), (102, 'card', 120000), (103, 'upi', 30000), (103, 'card', 50000), (105, 'cash', 25000),
  (201, 'upi', 60000), (202, 'upi', 15000), (203, 'card', 90000),
  (301, 'upi', 40000),
  (401, 'cash', 70000), (402, 'card', 35000);

-- The literal translation: one subquery per cell of the report, cities in rows, methods in columns.
SELECT c.name AS city,
       (SELECT count(*) FROM orders AS o
        WHERE o.city_id = c.id AND o.status = 'delivered' AND o.placed_on LIKE '2026-09-%') AS orders,
       (SELECT 100 * sum(p.amount_paise) FROM payments AS p JOIN orders AS o ON o.id = p.order_id
        WHERE o.city_id = c.id AND o.status = 'delivered' AND o.placed_on LIKE '2026-09-%' AND p.method = 'upi')
       / (SELECT sum(p.amount_paise) FROM payments AS p JOIN orders AS o ON o.id = p.order_id
          WHERE o.city_id = c.id AND o.status = 'delivered' AND o.placed_on LIKE '2026-09-%') AS upi_pct
FROM cities AS c
ORDER BY c.name;

Output

┌────────┬────────┬─────────┐
│  city  │ orders │ upi_pct │
├────────┼────────┼─────────┤
│ Indore │ 4      │ 27      │
│ Kochi  │ 3      │ 45      │
│ Nagpur │ 0      │         │
│ Surat  │ 2      │         │
└────────┴────────┴─────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < brute_force.sql

The upi column alone needs two subqueries per city, each joining payments to orders, so the whole report runs seven for each of 300 cities, and without a suitable index each reads 4.6 million payments: about 10¹⁰ rows. The result is also wrong twice. 100 * sum(...) / sum(...) divides integers, so Indore shows 27 instead of 27.8, and Surat, which had no upi payment, shows NULL where its share is 0.

What to notice

  • Write the grain first: one row per city.
  • Count orders at the order grain and sum payments at the payment grain, then join the summaries.
  • Conditional aggregation pivots the methods into columns.
  • Multiply by 100.0 before dividing, and wrap the denominator in nullif(..., 0).
  • Start from cities with a LEFT JOIN, so that a city without orders keeps its row.
  • Round in the last step: shares rounded one by one need not add up to 100.

The report in four steps, grain first Pro: opens with a Pro, Premium or Ultimate pass

In this part:

  • One join, two grains: the double count, reproduced
  • Pivot columns in SQLite, PostgreSQL and MySQL
  • Shares that add up to 100.0
    • How far rounded shares can drift, as a bound
  • Rounding rules, measured in three engines
  • Edge cases and common bugs
  • Your turn: order statuses per city as columns
    • Order statuses per city as columns, with a cancellation rate
  • A practice set with sample answers
  • Check yourself
  • Interview questions
    • What is the grain of a report, and why should you state it before you write the query?
    • A city-wise payment report shows more orders for one city than the orders table holds for it, and its delivery fees are too high. The query joins orders to payments and then groups by city. What happened, and how do you fix the counts, the fees and the shares?
    • Finance complains that the category shares in a monthly report add up to 99.9 % in some months and 100.1 % in others. Why does it happen, what are your options, and which one would you ship?
    • The same report query shows a share of 33 in PostgreSQL, 33.3333 in MySQL and 33.3 in SQLite after a rewrite, and some rounded totals differ by 0.01 between the engines. Explain each difference and make the query return the same numbers in all three.
  • Key takeaways

6 runnable examples (SQL, mysql_server.py, pg_server.py, report.py) · 1 exercise with sample tests · 3 interview answers · about 20 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.