Reporting SQL is like building a scoreboard from raw game logs: if you count the same play twice, the final score still looks neat and still lies.
Question: Design SQL for reporting.
Answer: I’d design reporting SQL by first defining the grain, meaning what one row should represent, such as one day per country. Then I’d filter the base fact table early, join dimension tables by keys, aggregate with GROUP BY, and handle missing values with COALESCE. The big risks are double counting, wrong join filters, and slow full-table scans.
Interview-Ready Answer: I’d start by asking what one row in the report should represent, then build the query around that grain. I filter the fact table first, join dimensions by keys, aggregate with GROUP BY, and use COALESCE so empty groups still show as zero instead of null. I also validate the totals against the source data to catch double counting, and if the report is high traffic I’d consider partitioning or a materialized view to keep it fast.
Detailed Explanation: Reporting SQL is not just ‘write a SELECT’. It is a design problem: choose the right grain, join safely, aggregate correctly, and make it fast enough for users who refresh dashboards all day.
Before writing SQL, translate the request into three things: the metric, the dimension, and the time window. For example: revenue by country for January. The metric is revenue, the dimension is country, and the grain could be one row per country for one month. If you do not lock the grain first, you can accidentally mix daily and monthly rows and get numbers that look fine but are wrong.
orders or payments. A fact table is the table with events or transactions.SUM, COUNT, or AVG. This is where one row becomes one summary row.COALESCE so a dashboard shows 0 instead of null.The database optimizer may reorder some work behind the scenes, but writing the query in this order makes your intent clear and helps you reason about correctness.
| Approach | Best for | Trade-off |
|---|---|---|
| Live query | Small data | Fresh, but slower |
| Materialized view | Dashboards | Fast, slightly stale |
| Summary table | Heavy reporting | Extra ETL work |
A live query is easiest when the data is small or the question is ad hoc. A materialized view is a stored result set: the database computes it ahead of time and refreshes it later. A summary table is similar, but you usually build and update it yourself in ETL, which means more control and more maintenance.
For a plain grouped report, think roughly O(n) over the number of filtered rows, plus join cost. If you scan 50 million orders, the database must touch a lot of data even if the final output is only 20 rows. With date partitioning, a January report may scan only one partition instead of the full table; that can cut work from 50 million rows to around 4 million. A good index on (status, order_date) can help when the filter is selective, but on very large analytic tables, partition pruning and pre-aggregation usually matter more than a single index.
Also watch for expensive operators. COUNT(DISTINCT) is heavier than COUNT(*) because the engine has to remove duplicates. Wide joins can spill to disk if memory is too small, and that turns a two-second query into a 30-second one.
LEFT JOIN in the WHERE clause can silently turn it into an inner join.SUM ignores nulls, but AVG and empty groups can surprise you, so check defaults.Real-World Story: A checkout team at an e-commerce company needs a daily revenue dashboard by country. The report starts from a country list so finance can see zeros, even for countries with no sales yet. One day an engineer moves the payment-status filter into the WHERE clause after a LEFT JOIN; the zero-sale country disappears, and the total revenue suddenly looks 8% lower. The symptoms are subtle: the dashboard still loads, logs show fewer rows than yesterday, and the reconciliation job prints a mismatch like ‘report total does not equal ledger total’. Users do not see a crash, but they do see the wrong business answer, which is worse.
-- A small, runnable reporting example that shows the right pattern:
-- 1) define the grain,
-- 2) filter the fact table early,
-- 3) join dimensions safely,
-- 4) keep zero rows visible.
CREATE TABLE countries (
country_code CHAR(2) PRIMARY KEY,
country_name VARCHAR(50) NOT NULL
);
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
customer_name VARCHAR(50) NOT NULL,
country_code CHAR(2) NOT NULL REFERENCES countries(country_code)
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
order_date DATE NOT NULL,
status VARCHAR(20) NOT NULL,
amount DECIMAL(10,2) NOT NULL
);
-- Helpful on reporting tables: the date filter and join key are common access paths.
CREATE INDEX idx_orders_status_date ON orders(status, order_date);
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_customers_country_code ON customers(country_code);
INSERT INTO countries (country_code, country_name) VALUES
('US', 'United States'),
('CA', 'Canada'),
('MX', 'Mexico');
INSERT INTO customers (customer_id, customer_name, country_code) VALUES
(1, 'Ava', 'US'),
(2, 'Ben', 'US'),
(3, 'Chloe', 'CA');
INSERT INTO orders (order_id, customer_id, order_date, status, amount) VALUES
(1001, 1, DATE '2024-01-02', 'PAID', 120.00),
(1002, 1, DATE '2024-01-03', 'CANCELLED', 120.00),
(1003, 2, DATE '2024-01-05', 'PAID', 75.50),
(1004, 3, DATE '2024-01-07', 'PAID', 200.00),
(1005, 3, DATE '2024-02-01', 'PAID', 50.00),
(1006, 2, DATE '2024-01-20', 'PENDING', 30.00);
-- Base fact set for the report: only paid January orders.
-- Keeping this logic in a CTE makes the reporting grain obvious and reusable.
WITH paid_january_orders AS (
SELECT order_id, customer_id, amount
FROM orders
WHERE status = 'PAID'
AND order_date >= DATE '2024-01-01'
AND order_date < DATE '2024-02-01'
),
country_report AS (
SELECT
ctry.country_name,
COUNT(pjo.order_id) AS paid_orders,
COALESCE(SUM(pjo.amount), 0) AS revenue,
COALESCE(AVG(pjo.amount), 0) AS average_order_value
FROM countries ctry
LEFT JOIN customers cust
ON cust.country_code = ctry.country_code
LEFT JOIN paid_january_orders pjo
ON pjo.customer_id = cust.customer_id
GROUP BY ctry.country_name
)
SELECT country_name, paid_orders, revenue, average_order_value
FROM country_report
ORDER BY revenue DESC, country_name;
-- Control total for validation: the sum here should match the report's total revenue.
SELECT COALESCE(SUM(amount), 0) AS control_total_revenue
FROM orders
WHERE status = 'PAID'
AND order_date >= DATE '2024-01-01'
AND order_date < DATE '2024-02-01';
-- Common failure path to remember:
-- If you move the paid/date filters into a WHERE clause after the LEFT JOIN,
-- countries with no matching paid orders disappear, which is bad for reporting.Follow-up & Tricky Questions:
LEFT JOIN act like an inner join? Because a filter in the WHERE clause removes the null-extended rows that the LEFT JOIN created. If you need to preserve missing rows, keep the filter in the join condition or in a pre-filtered CTE.COUNT(*) and COUNT(column)? COUNT(*) counts rows, including rows where every selected column is null from an outer join. COUNT(column) ignores null values, which is often what you want when counting matched facts.COUNT(DISTINCT) often expensive? The engine must remove duplicates before it can return the answer, which usually needs more memory or sorting. If the metric is important and repeated often, pre-aggregate or store a deduplicated summary instead.Common Mistakes:
COALESCE and test empty cases explicitly.Memory Hook: Think of reporting SQL like a LEGO tower: first choose the base block (grain), then snap each piece on once (join keys), and finally check the height against the ruler (control total). If the tower is built on the wrong base, every number above it is wrong even if it looks sturdy.
Cheat Sheet:
GROUP BY for the final report row shape.COALESCE for zero-friendly dashboards.Practice Tasks: