Hook: Interviewers love this topic because one small join mistake can quietly double a revenue report.
Question: How do you write sales reporting queries with joins in SQL?
Answer: Start by joining tables at the right grain (the level one row represents), such as one row per order or one row per order line. Use INNER JOIN when you only want matched sales, and LEFT JOIN when you need coverage, like customers with zero orders. The main trap is fan-out (row multiplication), which happens when you join two one-to-many tables before aggregating.
Interview-Ready Answer: I build sales reports by joining each table at the correct level of detail, then I aggregate after I have one row per thing I want to measure. For actual sales I usually use INNER JOIN, and for reports that must include zero-sales rows I use LEFT JOIN. The big thing I watch for is double counting: if I join two one-to-many tables too early, the row count explodes, so I often pre-aggregate in a CTE first.
Sales reporting is not just "join tables and sum money." It is a careful question about grain. A fact table is a table of events or transactions, like orders or order items. A dimension table is a descriptive table, like customers or regions. The report should choose one stable row shape first, then join other data onto it.
SUM, COUNT, and GROUP BY only after the row shape is correct.LEFT JOIN when the business wants zero rows to appear, such as customers with no purchases. Use INNER JOIN when missing matches should be excluded.order_date >= '2025-01-01' and < '2025-02-01' so you do not miss late-night timestamps.| Approach | Best for | Main risk |
|---|---|---|
| INNER JOIN | Actual sales only | Drops unmatched rows |
| LEFT JOIN | Coverage reports | Filters can cancel it |
| Pre-aggregate then join | Revenue accuracy | More SQL, but safer |
The phrase to remember is: join at the right grain. If one order has 3 items and 2 payments, a raw join can turn 1 order into 6 rows. That is not a small bug; it is a broken report.
In real databases, the optimizer chooses a join algorithm. A nested loop checks one row against many others and can behave like O(n*m) on large, unindexed tables. A hash join is often closer to O(n+m) but needs memory for the hash table. A merge join works well when inputs are already sorted or indexed. In practice, indexes on foreign keys matter a lot: orders.customer_id, order_items.order_id, payments.order_id. On a dashboard with 10 million orders and 50 million order lines, the wrong join order or a missing index can turn a 1-2 second report into a 30-second wait or worse.
WHERE after LEFT JOIN: if you filter on the right table in WHERE, you may accidentally turn the query back into an inner join.COUNT(*) vs COUNT(column): COUNT(*) counts joined rows, even null-extended rows; COUNT(o.order_id) is usually better for zero-order coverage reports.SUM returns null when nothing matches, so use COALESCE(..., 0) for reports.DECIMAL, not floating point, to avoid rounding surprises.Imagine an e-commerce checkout service that feeds a finance dashboard. Each order can have multiple items, and each order can also have split payments. A developer writes one big join from orders to order_items to payments and sums the item prices. The dashboard suddenly shows revenue that is 2x or 4x too high on some orders because every payment row multiplies every item row.
What goes wrong:
The fix is to aggregate order_items and payments separately to one row per order, then join those summaries back to orders and customers. That keeps the report honest.
-- PostgreSQL-friendly example: sales reporting with joins, pre-aggregation, and a zero-sales edge case.
-- The comments explain WHY each step exists so you can reuse the pattern in interviews.
DROP TABLE IF EXISTS payments;
DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS customers;
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
customer_name TEXT NOT NULL,
region TEXT NOT NULL
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
order_date DATE NOT NULL,
status TEXT NOT NULL
);
CREATE TABLE order_items (
order_item_id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(order_id),
quantity INTEGER NOT NULL,
unit_price DECIMAL(10,2) NOT NULL
);
CREATE TABLE payments (
payment_id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(order_id),
amount DECIMAL(10,2) NOT NULL
);
INSERT INTO customers (customer_id, customer_name, region) VALUES
(1, 'Ava', 'North'),
(2, 'Ben', 'South'),
(3, 'Chloe', 'North'),
(4, 'Diego', 'West');
INSERT INTO orders (order_id, customer_id, order_date, status) VALUES
(101, 1, DATE '2025-01-05', 'completed'),
(102, 1, DATE '2025-01-07', 'canceled'),
(103, 2, DATE '2025-01-08', 'completed'),
(104, 3, DATE '2025-01-12', 'completed');
INSERT INTO order_items (order_item_id, order_id, quantity, unit_price) VALUES
(1001, 101, 2, 50.00),
(1002, 101, 1, 20.00),
(1003, 102, 1, 999.00),
(1004, 103, 3, 15.00),
(1005, 104, 1, 100.00);
INSERT INTO payments (payment_id, order_id, amount) VALUES
(5001, 101, 60.00),
(5002, 101, 60.00),
(5003, 103, 45.00),
(5004, 104, 100.00);
-- BAD IDEA (commented out): joining order_items and payments raw would fan out order 101.
-- Because order 101 has 2 items and 2 payments, a naive join would produce 4 rows for that order.
-- SELECT c.region, SUM(oi.quantity * oi.unit_price) AS revenue
-- FROM customers c
-- JOIN orders o ON o.customer_id = c.customer_id
-- JOIN order_items oi ON oi.order_id = o.order_id
-- JOIN payments p ON p.order_id = o.order_id
-- GROUP BY c.region;
WITH line_revenue AS (
SELECT
oi.order_id,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM order_items oi
GROUP BY oi.order_id
),
payment_totals AS (
SELECT
p.order_id,
SUM(p.amount) AS paid
FROM payments p
GROUP BY p.order_id
)
SELECT
c.region,
COUNT(DISTINCT o.order_id) AS completed_orders,
COALESCE(SUM(lr.revenue), 0) AS total_revenue,
COALESCE(SUM(pt.paid), 0) AS total_paid
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
AND o.status = 'completed'
AND o.order_date >= DATE '2025-01-01'
AND o.order_date < DATE '2025-02-01'
LEFT JOIN line_revenue lr
ON lr.order_id = o.order_id
LEFT JOIN payment_totals pt
ON pt.order_id = o.order_id
GROUP BY c.region
ORDER BY c.region;
-- Expected result shape:
-- region | completed_orders | total_revenue | total_paid
-- North | 2 | 220.00 | 220.00
-- South | 1 | 45.00 | 45.00
-- West | 0 | 0.00 | 0.00Follow-up & Tricky Questions:
LEFT JOIN instead of INNER JOIN? Use LEFT JOIN when the business wants zero values to appear, such as regions or customers with no sales. Use INNER JOIN when missing matches should be excluded from the report.ON clause for a LEFT JOIN? Because filters in WHERE can remove null-extended rows and accidentally turn the query into an inner join. Putting them in ON preserves the left side.orders.customer_id, order_items.order_id, and payments.order_id. Those indexes reduce table scans and make join lookups much cheaper.>= '2025-01-01' and < '2025-02-01', instead of BETWEEN on timestamps. It avoids missing rows with times late on the last day.Tricky / gotcha questions:
COUNT(*) on a LEFT JOIN tell you how many customers had sales? No. It counts joined rows, including the null-extended row, so for coverage reports you usually want COUNT(o.order_id) or COUNT(DISTINCT o.order_id).orders.total_amount after joining to order items? Only if you know there is exactly one order row per order in the final result. If the join multiplies the order row, the total will be repeated.p.amount after a LEFT JOIN payments p? Not without care. A WHERE p.amount > 0 filter removes unmatched rows and changes the meaning of the join; keep it in ON or handle nulls explicitly.Common Mistakes:
WHERE conditions that break LEFT JOIN. Fix: move right-table filters into ON when you need zero rows preserved.DISTINCT.Memory Hook: First shrink, then join. If a table can multiply rows, compress it to one row per key before you combine it with the rest.
Cheat Sheet:
INNER JOIN for actual sales, LEFT JOIN for zero-sales coverage.COALESCE for zero totals and exact decimal types for money.Practice Tasks:
0 total.