Skip to main content

Practical Reporting Queries: A Mini-Project

You have spent the previous guides learning SQL concepts one at a time: SELECT, WHERE, GROUP BY, HAVING, aggregate functions, joins, aliases, and more. Each guide focused on a single clause or technique in isolation. But real-world SQL is never about one clause at a time. Real queries combine everything together to answer complex business questions.

This guide is different. Instead of teaching a new concept, it puts everything you have learned into practice through a hands-on mini-project. You will build a complete set of reporting queries for the ShopSmart e-commerce store, the kind of queries that power dashboards, generate business reports, and inform decision-making in real companies.

Each report starts with a clear business question, walks through the query-building process step by step, and delivers a polished, production-ready result. By the end, you will have written queries that calculate monthly revenue, rank customers by spending, analyze product performance across categories, and identify inventory trends.

Setting Up: Expanding the ShopSmart Database

Before we start building reports, let us expand our sample database with additional data (we defined it in a previous guide here).

Real reporting queries are more meaningful with more data to work with. Run these inserts to add product ratings and more orders.

-- Add a ratings table
CREATE TABLE reviews (
id INTEGER PRIMARY KEY,
product_id INTEGER NOT NULL,
customer_id INTEGER NOT NULL,
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
review_date DATE NOT NULL,
FOREIGN KEY (product_id) REFERENCES products(id),
FOREIGN KEY (customer_id) REFERENCES customers(id)
);

-- Insert reviews
INSERT INTO reviews VALUES (1, 1, 1, 5, '2024-01-20');
INSERT INTO reviews VALUES (2, 1, 2, 4, '2024-01-25');
INSERT INTO reviews VALUES (3, 1, 3, 5, '2024-02-10');
INSERT INTO reviews VALUES (4, 2, 1, 5, '2024-01-22');
INSERT INTO reviews VALUES (5, 2, 4, 4, '2024-02-15');
INSERT INTO reviews VALUES (6, 2, 5, 3, '2024-03-01');
INSERT INTO reviews VALUES (7, 3, 2, 4, '2024-02-05');
INSERT INTO reviews VALUES (8, 3, 6, 5, '2024-03-12');
INSERT INTO reviews VALUES (9, 4, 1, 5, '2024-01-30');
INSERT INTO reviews VALUES (10, 4, 3, 4, '2024-02-20');
INSERT INTO reviews VALUES (11, 5, 4, 3, '2024-03-05');
INSERT INTO reviews VALUES (12, 6, 3, 5, '2024-03-10');
INSERT INTO reviews VALUES (13, 6, 1, 4, '2024-03-18');
INSERT INTO reviews VALUES (14, 7, 5, 5, '2024-02-28');
INSERT INTO reviews VALUES (15, 7, 2, 4, '2024-03-15');
INSERT INTO reviews VALUES (16, 8, 4, 5, '2024-03-08');
INSERT INTO reviews VALUES (17, 8, 6, 4, '2024-03-22');
INSERT INTO reviews VALUES (18, 8, 1, 5, '2024-04-01');
INSERT INTO reviews VALUES (19, 10, 2, 4, '2024-02-12');
INSERT INTO reviews VALUES (20, 10, 5, 5, '2024-03-20');
INSERT INTO reviews VALUES (21, 10, 3, 4, '2024-04-05');

-- Add more orders for richer reporting data
INSERT INTO orders VALUES (9, 1, '2024-04-15', 89.99, 'completed');
INSERT INTO orders VALUES (10, 3, '2024-04-18', 63.99, 'completed');
INSERT INTO orders VALUES (11, 2, '2024-04-22', 174.99, 'shipped');
INSERT INTO orders VALUES (12, 4, '2024-04-25', 45.00, 'pending');

INSERT INTO order_items VALUES (14, 9, 2, 1, 89.99);
INSERT INTO order_items VALUES (15, 10, 7, 1, 38.00);
INSERT INTO order_items VALUES (16, 10, 10, 1, 24.99);
INSERT INTO order_items VALUES (17, 11, 2, 1, 89.99);
INSERT INTO order_items VALUES (18, 11, 8, 1, 110.00);
INSERT INTO order_items VALUES (19, 12, 3, 1, 45.00);

Verify the new data is loaded:

SELECT 'reviews' AS table_name, COUNT(*) AS rows FROM reviews
UNION ALL
SELECT 'orders', COUNT(*) FROM orders
UNION ALL
SELECT 'order_items', COUNT(*) FROM order_items;

Expected output:

table_namerows
reviews21
orders12
order_items19

Now we have enough data to build meaningful reports.

Report 1: Total Revenue Per Month

Business question: "How much revenue did we generate each month? Is the business growing?"

This is one of the most fundamental business reports. Stakeholders want to see revenue trends over time to understand growth, seasonality, and the impact of business decisions.

Step 1: Understand the Raw Data

First, let us see what we are working with:

SELECT id, order_date, total_amount, status
FROM orders
ORDER BY order_date;

Output:

idorder_datetotal_amountstatus
12024-01-10119.98completed
22024-01-1589.99completed
32024-02-2077.49completed
42024-03-05129.99shipped
52024-03-12155.00shipped
62024-03-2034.99pending
72024-04-0163.00pending
82024-04-10199.98pending
92024-04-1589.99completed
102024-04-1863.99completed
112024-04-22174.99shipped
122024-04-2545.00pending

We have orders spanning January through April 2024.

Step 2: Group by Month

We need to extract the month from each order date and group the results:

-- PostgreSQL syntax
SELECT TO_CHAR(order_date, 'YYYY-MM') AS month,
COUNT(*) AS total_orders,
SUM(total_amount) AS revenue
FROM orders
GROUP BY TO_CHAR(order_date, 'YYYY-MM')
ORDER BY month;
Date Formatting by Database
-- PostgreSQL
TO_CHAR(order_date, 'YYYY-MM')

-- MySQL
DATE_FORMAT(order_date, '%Y-%m')

-- SQLite
STRFTIME('%Y-%m', order_date)

-- SQL Server
FORMAT(order_date, 'yyyy-MM')

Output:

monthtotal_ordersrevenue
2024-012209.97
2024-02177.49
2024-033319.98
2024-046636.95

Step 3: Add Depth and Polish

A production report needs more context. Let us add average order value, running comparisons, and formatting:

SELECT TO_CHAR(order_date, 'YYYY-MM') AS month,
COUNT(*) AS total_orders,
ROUND(SUM(total_amount), 2) AS revenue,
ROUND(AVG(total_amount), 2) AS avg_order_value,
MIN(total_amount) AS smallest_order,
MAX(total_amount) AS largest_order
FROM orders
GROUP BY TO_CHAR(order_date, 'YYYY-MM')
ORDER BY month;

Output:

monthtotal_ordersrevenueavg_order_valuesmallest_orderlargest_order
2024-012209.97104.9989.99119.98
2024-02177.4977.4977.4977.49
2024-033319.98106.6634.99155.00
2024-046636.95106.1645.00199.98

Step 4: Revenue by Status and Month

For a more detailed view, break revenue down by order status within each month:

SELECT TO_CHAR(order_date, 'YYYY-MM') AS month,
status,
COUNT(*) AS orders,
ROUND(SUM(total_amount), 2) AS revenue
FROM orders
GROUP BY TO_CHAR(order_date, 'YYYY-MM'), status
ORDER BY month, status;

Output:

monthstatusordersrevenue
2024-01completed2209.97
2024-02completed177.49
2024-03pending134.99
2024-03shipped2284.99
2024-04completed2153.98
2024-04pending3307.98
2024-04shipped1174.99

Step 5: Confirmed Revenue Only

Management often wants to see only revenue from orders that have been completed or shipped, excluding pending orders that might still be cancelled:

SELECT TO_CHAR(order_date, 'YYYY-MM') AS month,
COUNT(*) AS confirmed_orders,
ROUND(SUM(total_amount), 2) AS confirmed_revenue,
ROUND(AVG(total_amount), 2) AS avg_confirmed_order
FROM orders
WHERE status IN ('completed', 'shipped')
GROUP BY TO_CHAR(order_date, 'YYYY-MM')
ORDER BY month;

Output:

monthconfirmed_ordersconfirmed_revenueavg_confirmed_order
2024-012209.97104.98
2024-02177.4977.49
2024-032284.99142.50
2024-043328.97109.66

This is the kind of query a finance team would use to track actual revenue.

Report 2: Top 5 Customers by Spend

Business question: "Who are our most valuable customers? How much have they spent, and how often do they order?"

Customer ranking reports drive loyalty programs, targeted marketing, and VIP experiences. Knowing your top spenders helps you allocate resources where they have the most impact.

Step 1: Calculate Spending Per Customer

SELECT customer_id,
COUNT(*) AS order_count,
SUM(total_amount) AS total_spent
FROM orders
GROUP BY customer_id
ORDER BY total_spent DESC;

Output:

customer_idorder_counttotal_spent
23327.98
13287.46
42200
61199.98
32193.98000000000002
5134.99

Step 2: Add Customer Names and Details

Raw customer IDs are not useful in a report. Join with the customers table:

SELECT c.first_name || ' ' || c.last_name AS customer,
c.email,
c.city,
COUNT(o.id) AS total_orders,
ROUND(SUM(o.total_amount), 2) AS total_spent,
ROUND(AVG(o.total_amount), 2) AS avg_order_value,
MIN(o.order_date) AS first_order,
MAX(o.order_date) AS last_order
FROM customers c
JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.first_name, c.last_name, c.email, c.city
ORDER BY total_spent DESC
LIMIT 5;

Output:

customer_idemailcitytotal_orderstotal_spentavg_order_valuefirst_orderlast_order
Bob Martinezbob@email.comLos Angeles3327.98109.332024-01-152024-04-22
Alice Johnsonalice@email.comNew York3287.4695.822024-01-102024-04-15
David Chendavid@email.comNew York22001002024-03-122024-04-25
Frank Wilsonfrank@email.comChicago1199.98199.982024-04-102024-04-10
Carol Singhcarol@email.comChicago2193.9896.992024-03-052024-04-18

Step 3: Add a Customer Tier

Categorize customers based on their spending level:

SELECT c.first_name || ' ' || c.last_name AS customer,
COUNT(o.id) AS total_orders,
ROUND(SUM(o.total_amount), 2) AS total_spent,
ROUND(AVG(o.total_amount), 2) AS avg_order_value,
CASE
WHEN SUM(o.total_amount) >= 300 THEN 'Platinum'
WHEN SUM(o.total_amount) >= 200 THEN 'Gold'
WHEN SUM(o.total_amount) >= 100 THEN 'Silver'
ELSE 'Bronze'
END AS customer_tier
FROM customers c
JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.first_name, c.last_name
ORDER BY total_spent DESC;

Output:

customer_idtotal_orderstotal_spentavg_order_valuecustomer_tier
Bob Martinez3327.98109.33Platinum
Alice Johnson3287.4695.82Gold
David Chen2200100Gold
Frank Wilson1199.98199.98Silver
Carol Singh2193.9896.99Silver
Eva Brown134.9934.99Bronze

Step 4: Include Customers Who Have Not Ordered

A complete customer report should show all customers, including those who have never placed an order:

SELECT c.first_name || ' ' || c.last_name AS customer,
c.city,
COUNT(o.id) AS total_orders,
COALESCE(ROUND(SUM(o.total_amount), 2), 0) AS total_spent,
CASE
WHEN SUM(o.total_amount) >= 300 THEN 'Platinum'
WHEN SUM(o.total_amount) >= 200 THEN 'Gold'
WHEN SUM(o.total_amount) >= 100 THEN 'Silver'
WHEN SUM(o.total_amount) > 0 THEN 'Bronze'
ELSE 'Inactive'
END AS customer_tier
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.first_name, c.last_name, c.city
ORDER BY total_spent DESC;

Output:

customercitytotal_orderstotal_spentcustomer_tier
Bob MartinezLos Angeles3327.98Platinum
Alice JohnsonNew York3287.46Gold
David ChenNew York2200.00Gold
Frank WilsonChicago1199.98Silver
Carol SinghChicago2193.98Silver
Eva BrownSeattle134.99Bronze
Grace TaylorNULL00Inactive

The LEFT JOIN ensures Grace appears even with no orders, and COALESCE converts her NULL total to 0.

Report 3: Average Product Rating by Category

Business question: "How are our products rated across different categories? Which categories have the highest customer satisfaction?"

Product ratings drive purchasing decisions, help identify quality issues, and guide category management. This report combines data from three tables: products, categories, and reviews.

Step 1: Explore the Reviews Data

SELECT r.product_id, p.name, r.rating, r.review_date
FROM reviews r
JOIN products p ON r.product_id = p.id
ORDER BY p.name, r.review_date
LIMIT 10;

Output:

product_idnameratingreview_date
6Coffee Maker Pro52024-03-10
6Coffee Maker Pro42024-03-18
5Data Science Handbook32024-03-05
2Mechanical Keyboard52024-01-22
2Mechanical Keyboard42024-02-15
2Mechanical Keyboard32024-03-01
8Running Shoes X152024-03-08
8Running Shoes X142024-03-22
8Running Shoes X152024-04-01
4SQL for Beginners52024-01-30

Step 2: Average Rating Per Product

SELECT p.name AS product,
COUNT(r.id) AS review_count,
ROUND(AVG(r.rating), 2) AS avg_rating,
MIN(r.rating) AS lowest_rating,
MAX(r.rating) AS highest_rating
FROM products p
JOIN reviews r ON p.id = r.product_id
GROUP BY p.id, p.name
ORDER BY avg_rating DESC, review_count DESC;

Output:

productreview_countavg_ratinglowest_ratinghighest_rating
Wireless Mouse34.6745
Running Shoes X134.6745
Coffee Maker Pro24.5045
USB-C Hub24.5045
SQL for Beginners24.5045
Yoga Mat Premium24.5045
Stainless Water Bottle34.3345
Mechanical Keyboard34.0035
Data Science Handbook13.0033

Step 3: Average Rating by Category

Now roll up to the category level:

SELECT c.name AS category,
COUNT(DISTINCT p.id) AS products_reviewed,
COUNT(r.id) AS total_reviews,
ROUND(AVG(r.rating), 2) AS avg_rating,
MIN(r.rating) AS lowest_rating,
MAX(r.rating) AS highest_rating
FROM categories c
JOIN products p ON c.id = p.category_id
JOIN reviews r ON p.id = r.product_id
GROUP BY c.id, c.name
ORDER BY avg_rating DESC;

Output:

categoryproducts_reviewedtotal_reviewsavg_ratinglowest_ratinghighest_rating
Home & Kitchen124.5045
Sports384.5045
Electronics384.3835
Books234.0035

Step 4: The Complete Category Performance Report

Combine ratings with sales and inventory data for a comprehensive category dashboard:

SELECT c.name AS category,
COUNT(DISTINCT p.id) AS total_products,
ROUND(AVG(p.price), 2) AS avg_price,
SUM(p.stock_quantity) AS total_stock,
COUNT(r.id) AS total_reviews,
ROUND(AVG(r.rating), 2) AS avg_rating,
SUM(CASE WHEN r.rating >= 4 THEN 1 ELSE 0 END) AS positive_reviews,
ROUND(
SUM(CASE WHEN r.rating >= 4 THEN 1 ELSE 0 END) * 100.0 / COUNT(r.id),
1
) AS positive_pct
FROM categories c
JOIN products p ON c.id = p.category_id
LEFT JOIN reviews r ON p.id = r.product_id
GROUP BY c.id, c.name
ORDER BY avg_rating DESC;

Output:

categorytotal_productsavg_pricetotal_stocktotal_reviewsavg_ratingpositive_reviewspositive_pct
Home & Kitchen1129.995024.52100
Sports360.1292084.58100
Electronics457.22107584.38787.5
Books237.4913034266.7

This single query tells you everything about each category: how many products it has, pricing, stock levels, review volume, average rating, and what percentage of reviews are positive (4 or 5 stars).

Why This Matters

Notice how the report combines data from three different tables (categories, products, reviews) and uses multiple aggregate functions in a single query. This is what production SQL looks like. Each column answers a specific business question, and together they paint a complete picture of category performance.

Report 4: Categories with High Product Count

Business question: "Which categories have the most products? Are there categories that are under-represented in our catalog?"

This type of report helps merchandising teams balance their catalog and identify gaps.

Step 1: Basic Product Count

SELECT c.name AS category,
COUNT(p.id) AS product_count
FROM categories c
LEFT JOIN products p ON c.id = p.category_id
GROUP BY c.id, c.name
ORDER BY product_count DESC;

Output:

categoryproduct_count
Electronics4
Sports3
Books2
Home & Kitchen1

Step 2: Filter to Categories Meeting a Threshold

Find categories with more than 2 products:

SELECT c.name AS category,
COUNT(p.id) AS product_count,
ROUND(AVG(p.price), 2) AS avg_price,
SUM(p.stock_quantity) AS total_stock
FROM categories c
JOIN products p ON c.id = p.category_id
GROUP BY c.id, c.name
HAVING COUNT(p.id) > 2
ORDER BY product_count DESC;

Output:

categoryproduct_countavg_pricetotal_stock
Electronics457.50425
Sports357.66340

Step 3: Catalog Balance Report

Create a comprehensive catalog analysis that identifies under-represented and well-stocked categories:

SELECT c.name AS category,
COUNT(p.id) AS product_count,
COUNT(CASE WHEN p.is_available = true THEN 1 END) AS available_count,
COUNT(CASE WHEN p.is_available = false THEN 1 END) AS unavailable_count,
ROUND(AVG(p.price), 2) AS avg_price,
SUM(p.stock_quantity) AS total_stock,
ROUND(SUM(p.price * p.stock_quantity), 2) AS inventory_value,
CASE
WHEN COUNT(p.id) >= 4 THEN 'Well Stocked'
WHEN COUNT(p.id) >= 2 THEN 'Adequate'
ELSE 'Needs Expansion'
END AS catalog_status
FROM categories c
LEFT JOIN products p ON c.id = p.category_id
GROUP BY c.id, c.name
ORDER BY product_count DESC;

Output:

categoryproduct_countavailable_countunavailable_countavg_pricetotal_stockinventory_valuecatalog_status
Electronics43157.4942520247.25Well Stocked
Sports33057.6634014898.20Adequate
Books22038.75803024.50Adequate
Home & Kitchen110129.99253249.75Needs Expansion

Report 5: Product Performance Scorecard

Business question: "How is each product performing across all dimensions: sales, revenue, ratings, and inventory?"

This is the most complex report, combining data from almost every table in the database.

Building the Query Incrementally

Start with basic product info and build up:

SELECT p.name AS product,
c.name AS category,
p.price,
p.stock_quantity AS stock,
p.is_available AS available,
COALESCE(order_data.times_ordered, 0) AS times_ordered,
COALESCE(order_data.total_units_sold, 0) AS units_sold,
COALESCE(order_data.total_revenue, 0) AS revenue,
COALESCE(review_data.review_count, 0) AS reviews,
COALESCE(review_data.avg_rating, 0) AS avg_rating,
CASE
WHEN COALESCE(order_data.total_revenue, 0) = 0 THEN 'No Sales'
WHEN order_data.total_revenue >= 150 THEN 'Top Performer'
WHEN order_data.total_revenue >= 50 THEN 'Solid'
ELSE 'Underperforming'
END AS performance_tier
FROM products p
JOIN categories c ON p.category_id = c.id
LEFT JOIN (
SELECT product_id,
COUNT(*) AS times_ordered,
SUM(quantity) AS total_units_sold,
ROUND(SUM(quantity * unit_price), 2) AS total_revenue
FROM order_items
GROUP BY product_id
) AS order_data ON p.id = order_data.product_id
LEFT JOIN (
SELECT product_id,
COUNT(*) AS review_count,
ROUND(AVG(rating), 2) AS avg_rating
FROM reviews
GROUP BY product_id
) AS review_data ON p.id = review_data.product_id
ORDER BY revenue DESC;

Output:

productcategorypricestockavailabletimes_orderedunits_soldrevenuereviewsavg_ratingperformance_tier
Mechanical KeyboardElectronics89.9975144359.9634Top Performer
Running Shoes X1Sports1106013333034.67Top Performer
USB-C HubElectronics4520013313524.5Solid
Coffee Maker ProHome & Kitchen129.9925111129.9924.5Solid
Yoga Mat PremiumSports381001227624.5Solid
Stainless Water BottleSports24.9918013374.9734.33Solid
SQL for BeginnersBooks34.995012269.9824.5Solid
Wireless MouseElectronics29.9915011259.9834.67Solid
Data Science HandbookBooks42.530100013No Sales
Bluetooth SpeakerElectronics650000000No Sales
Understanding Subqueries in FROM

This query uses subqueries in the FROM clause (also called derived tables or inline views). Each subquery pre-aggregates data from order_items and reviews, then the main query joins those summaries to the products table. This avoids double-counting issues that arise when joining multiple one-to-many tables directly.

-- The order_data subquery aggregates order items per product
LEFT JOIN (
SELECT product_id,
COUNT(*) AS times_ordered,
SUM(quantity) AS total_units_sold,
ROUND(SUM(quantity * unit_price), 2) AS total_revenue
FROM order_items
GROUP BY product_id
) AS order_data ON p.id = order_data.product_id

The LEFT JOIN ensures products with no orders still appear in the results (with zeros from COALESCE).

Report 6: Executive Summary Dashboard

Business question: "Give me a single-screen overview of the entire business."

This report combines multiple metrics into one compact query:

SELECT
-- Order metrics
(SELECT COUNT(*) FROM orders) AS total_orders,
(SELECT COUNT(*) FROM orders WHERE status = 'completed') AS completed_orders,
(SELECT ROUND(SUM(total_amount), 2) FROM orders) AS gross_revenue,
(SELECT ROUND(SUM(total_amount), 2) FROM orders
WHERE status IN ('completed', 'shipped')) AS confirmed_revenue,
(SELECT ROUND(AVG(total_amount), 2) FROM orders) AS avg_order_value,
-- Customer metrics
(SELECT COUNT(*) FROM customers) AS total_customers,
(SELECT COUNT(DISTINCT customer_id) FROM orders) AS active_customers,
-- Product metrics
(SELECT COUNT(*) FROM products) AS total_products,
(SELECT COUNT(*) FROM products WHERE is_available = true) AS available_products,
(SELECT COUNT(*) FROM products WHERE stock_quantity = 0) AS out_of_stock,
-- Review metrics
(SELECT COUNT(*) FROM reviews) AS total_reviews,
(SELECT ROUND(AVG(rating), 2) FROM reviews) AS overall_rating;

Output:

total_orderscompleted_ordersgross_revenueconfirmed_revenueavg_order_valuetotal_customersactive_customerstotal_productsavailable_productsout_of_stocktotal_reviewsoverall_rating
1251244.39901.42103.70761091214.38

A single query that gives executives everything they need at a glance.

Scalar Subqueries Have Limitations

The executive summary uses scalar subqueries (subqueries in the SELECT list that return a single value). While convenient for dashboards, each subquery runs independently and scans the table separately. For very large databases, it can be more efficient to compute these metrics through other approaches. For small to medium datasets, this pattern works well and is extremely readable.

Business question: "How does each month compare to the previous month? Are we trending up or down?"

-- PostgreSQL syntax using LAG window function
SELECT month,
total_orders,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month_revenue,
CASE
WHEN LAG(revenue) OVER (ORDER BY month) IS NULL THEN 'N/A'
WHEN revenue > LAG(revenue) OVER (ORDER BY month) THEN 'Up'
WHEN revenue < LAG(revenue) OVER (ORDER BY month) THEN 'Down'
ELSE 'Flat'
END AS trend
FROM (
SELECT TO_CHAR(order_date, 'YYYY-MM') AS month,
COUNT(*) AS total_orders,
ROUND(SUM(total_amount), 2) AS revenue
FROM orders
GROUP BY TO_CHAR(order_date, 'YYYY-MM')
) AS monthly_data
ORDER BY month;

Output:

monthtotal_ordersrevenueprev_month_revenuetrend
2024-012209.97NULLN/A
2024-02177.49209.97Down
2024-033319.9877.49Up
2024-046636.95319.98Up
Preview: Window Functions

This query uses the LAG() window function, which accesses a value from a previous row without collapsing the data with GROUP BY. Window functions are an advanced SQL topic covered in a future guide. For now, notice how LAG(revenue) OVER (ORDER BY month) gives you the previous month's revenue alongside the current month's, enabling month-over-month comparisons.

Building Your Own Reports: A Framework

When tasked with building a reporting query, follow this systematic approach:

1. Clarify the Business Question

Before writing any SQL, make sure you understand exactly what is being asked:

  • What metric is needed? (count, sum, average, min, max)
  • What dimension should it be grouped by? (month, category, customer, status)
  • What filters apply? (date range, status, availability)
  • Who is the audience? (executives want summaries, analysts want detail)

2. Identify the Tables Needed

Map each piece of information to its source table:

  • Revenue data → orders, order_items
  • Customer info → customers
  • Product details → products
  • Category names → categories
  • Ratings → reviews

3. Build Incrementally

Never try to write the entire query at once:

-- Step 1: Basic data retrieval
SELECT * FROM orders LIMIT 5;

-- Step 2: Add grouping
SELECT customer_id, COUNT(*) FROM orders GROUP BY customer_id;

-- Step 3: Add aggregates
SELECT customer_id, COUNT(*), SUM(total_amount) FROM orders GROUP BY customer_id;

-- Step 4: Add joins
SELECT c.first_name, COUNT(o.id), SUM(o.total_amount)
FROM customers c JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.first_name;

-- Step 5: Add filters, aliases, sorting
SELECT c.first_name || ' ' || c.last_name AS customer,
COUNT(o.id) AS orders,
ROUND(SUM(o.total_amount), 2) AS total_spent
FROM customers c
JOIN orders o ON c.id = o.customer_id
WHERE o.status = 'completed'
GROUP BY c.id, c.first_name, c.last_name
HAVING SUM(o.total_amount) > 100
ORDER BY total_spent DESC;

4. Polish for Production

Add meaningful aliases, round decimals, handle NULLs, and format the output:

-- Raw: Hard to read
SELECT category_id, avg(price), sum(stock_quantity) FROM products GROUP BY category_id;

-- Polished: Report-ready
SELECT c.name AS "Category",
COUNT(p.id) AS "Products",
ROUND(AVG(p.price), 2) AS "Avg Price",
SUM(p.stock_quantity) AS "Total Stock",
ROUND(SUM(p.price * p.stock_quantity), 2) AS "Inventory Value"
FROM categories c
JOIN products p ON c.id = p.category_id
GROUP BY c.id, c.name
ORDER BY "Inventory Value" DESC;
Save Your Reports

Once you build a reporting query that works, save it. Production environments often use views to store frequently-run reports as reusable queries:

CREATE VIEW monthly_revenue AS
SELECT TO_CHAR(order_date, 'YYYY-MM') AS month,
COUNT(*) AS total_orders,
ROUND(SUM(total_amount), 2) AS revenue
FROM orders
GROUP BY TO_CHAR(order_date, 'YYYY-MM')
ORDER BY month;

-- Now you can query it like a table
SELECT * FROM monthly_revenue;

Key Takeaways

This mini-project demonstrated how all the SQL concepts you have learned come together to build real-world reports. Here is what you should take away:

  • Real SQL queries combine multiple clauses: SELECT, FROM, JOIN, WHERE, GROUP BY, HAVING, ORDER BY, and LIMIT work together in a single query
  • Revenue reports group by time periods (month, quarter, year) and aggregate financial data with SUM and AVG
  • Customer ranking reports join customer data with order data, group by customer, and sort by aggregate spending
  • Category analysis joins products, categories, and reviews to build multi-dimensional views of business performance
  • Build queries incrementally: start simple, verify each step, then add complexity
  • Use LEFT JOIN when you need to include entities with no matching records (customers with no orders, products with no reviews)
  • Use COALESCE to replace NULL aggregate results with meaningful defaults (typically 0)
  • CASE expressions inside aggregates let you count conditional occurrences and create categorical labels
  • Subqueries in FROM (derived tables) help pre-aggregate data to avoid double-counting when joining multiple one-to-many relationships
  • Polish your output with meaningful aliases, rounded decimals, and calculated fields that answer the actual business question
  • Every report starts with a clear business question that defines what metrics are needed, how they should be grouped, and what filters apply

You now have the skills to build the reporting queries that power real business dashboards. The techniques in this guide, combining joins, aggregates, grouping, filtering, and calculated fields, represent the core of what most SQL developers do every day.