Writing Complex Queries for Real-World Business Scenarios in MySQL
Course: MySQL Training
Topic: Advanced SQL Query Writing for Business Analysis and Reporting
Course Link: MySQL Training Course
Demo Link: Register for a Course Demo
1. Introduction
Writing complex queries for real-world business scenarios is an essential skill for database developers, SQL analysts, data analysts, business intelligence professionals, and reporting specialists. Businesses store large amounts of information in different tables, and SQL helps combine, filter, calculate, and analyze this information to support decision-making.
A business rarely needs only a simple list of records. It may need to identify the most profitable products, compare monthly revenue, find inactive customers, calculate outstanding payments, rank employees, analyze regional sales, or discover customers who purchase frequently. These requirements often involve multiple tables, joins, subqueries, aggregate functions, conditional expressions, Common Table Expressions (CTEs), and window functions.
A complex SQL query should not only return the correct answer but also be readable, maintainable, accurate, and efficient. The query must reflect the actual business rules and avoid common errors such as duplicated totals, incorrect date filtering, and counting the wrong records.
1.1 Learning Objectives
- Understand how to convert business requirements into SQL queries.
- Identify the correct tables, relationships, and columns for a business problem.
- Combine multiple tables using INNER JOIN, LEFT JOIN, and other joins.
- Calculate totals, averages, counts, and percentages using aggregate functions.
- Apply WHERE, GROUP BY, HAVING, ORDER BY, and LIMIT correctly.
- Use CASE expressions to implement business rules.
- Write subqueries and CTEs for multi-step calculations.
- Use window functions to calculate rankings, running totals, and period comparisons.
- Analyze sales, customers, products, inventory, and payments.
- Debug incorrect query results and optimize performance.
2. Understanding Real-World Business Requirements
Before writing SQL, understand what the business wants to measure. A request such as "find the best customers" is incomplete until the business defines what "best" means. It might mean highest revenue, most orders, highest profit, most recent purchase, or strongest customer retention.
2.1 Common Business Scenarios
| Business Department |
Business Requirement |
SQL Concepts |
| Sales |
Calculate monthly sales revenue. |
SUM(), GROUP BY, date functions |
| Marketing |
Identify customers who purchase repeatedly. |
JOIN, COUNT(), HAVING |
| Finance |
Find unpaid and partially paid invoices. |
CTEs, LEFT JOIN, SUM() |
| Inventory |
Identify products requiring restocking. |
WHERE, CASE, comparison operators |
| Human Resources |
Rank employees by department salary. |
GROUP BY, RANK(), window functions |
| Customer Service |
Identify customers with unresolved requests. |
JOIN, date conditions, filtering |
| Management |
Compare this month's performance with the previous month. |
CTEs, LAG(), conditional aggregation |
| Business Intelligence |
Build a dashboard with sales and customer KPIs. |
Joins, aggregates, CTEs, window functions |
2.2 A Systematic Approach to Complex Query Writing
- Define the question: Specify the exact information that must be returned.
- Define the metric: Decide what revenue, profit, active customer, or completed order means.
- Identify the tables: Locate the data required to answer the question.
- Determine relationships: Identify primary keys, foreign keys, and join conditions.
- Choose the output level: Decide whether the result should contain one row per customer, order, product, or month.
- Apply filters: Include appropriate dates, statuses, regions, and categories.
- Calculate results: Use aggregates, CASE expressions, subqueries, or window functions.
- Validate the output: Compare sample records and totals with expected results.
- Optimize the query: Review indexes and execution plans when necessary.
3. Creating a Sample Business Database
To understand practical query writing, we will create a fictional retail database named RetailPro. It stores customer details, product information, orders, order items, and payment records.
3.1 Create the Database
CREATE DATABASE retailpro_db;
USE retailpro_db;
3.2 Create the Customers Table
CREATE TABLE customers (
customer_id INT PRIMARY KEY,
customer_name VARCHAR(100) NOT NULL,
email VARCHAR(150),
city VARCHAR(80),
registration_date DATE
);
The customers table stores customer details, locations, and registration dates.
3.3 Create the Products Table
CREATE TABLE products (
product_id INT PRIMARY KEY,
product_name VARCHAR(120) NOT NULL,
category VARCHAR(80),
unit_price DECIMAL(10,2) NOT NULL,
stock_quantity INT NOT NULL DEFAULT 0
);
The products table stores product names, categories, prices, and inventory quantities.
3.4 Create the Orders Table
CREATE TABLE orders (
order_id INT PRIMARY KEY,
customer_id INT NOT NULL,
order_date DATE NOT NULL,
order_status VARCHAR(30) NOT NULL,
FOREIGN KEY (customer_id)
REFERENCES customers(customer_id)
);
The orders table contains one record for each order. The customer_id column identifies the customer who placed it.
3.5 Create the Order Items Table
CREATE TABLE order_items (
order_item_id INT PRIMARY KEY,
order_id INT NOT NULL,
product_id INT NOT NULL,
quantity INT NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
FOREIGN KEY (order_id)
REFERENCES orders(order_id),
FOREIGN KEY (product_id)
REFERENCES products(product_id)
);
An order can contain multiple items. The unit price is stored with each order item to preserve the price used for that transaction, even if the product's current price changes later.
3.6 Create the Payments Table
CREATE TABLE payments (
payment_id INT PRIMARY KEY,
order_id INT NOT NULL,
payment_date DATE,
amount DECIMAL(12,2) NOT NULL,
payment_status VARCHAR(30) NOT NULL,
FOREIGN KEY (order_id)
REFERENCES orders(order_id)
);
This table stores payments associated with orders. An order can have multiple payment records, such as partial payments or separate payment attempts.
3.7 Insert Sample Customers
INSERT INTO customers
(customer_id, customer_name, email, city, registration_date)
VALUES
(1, 'Aarav Sharma', '[email protected]', 'Mumbai', '2025-01-10'),
(2, 'Priya Patel', '[email protected]', 'Pune', '2025-02-15'),
(3, 'Rahul Mehta', '[email protected]', 'Mumbai', '2025-03-20'),
(4, 'Neha Singh', '[email protected]', 'Delhi', '2025-04-05'),
(5, 'Vikram Rao', '[email protected]', 'Pune', '2025-05-12'),
(6, 'Sneha Joshi', '[email protected]', 'Mumbai', '2025-06-18');
3.8 Insert Sample Products
INSERT INTO products
(product_id, product_name, category, unit_price, stock_quantity)
VALUES
(101, 'Laptop', 'Electronics', 60000, 12),
(102, 'Wireless Mouse', 'Accessories', 1200, 8),
(103, 'Keyboard', 'Accessories', 1800, 25),
(104, 'Monitor', 'Electronics', 15000, 5),
(105, 'Office Chair', 'Furniture', 8500, 3),
(106, 'USB Hub', 'Accessories', 900, 40);
3.9 Insert Sample Orders
INSERT INTO orders
(order_id, customer_id, order_date, order_status)
VALUES
(1001, 1, '2026-01-05', 'Completed'),
(1002, 2, '2026-01-12', 'Completed'),
(1003, 1, '2026-02-03', 'Completed'),
(1004, 3, '2026-02-15', 'Pending'),
(1005, 4, '2026-03-01', 'Completed'),
(1006, 5, '2026-03-10', 'Cancelled'),
(1007, 2, '2026-03-18', 'Completed'),
(1008, 3, '2026-04-02', 'Completed');
3.10 Insert Sample Order Items
INSERT INTO order_items
(order_item_id, order_id, product_id, quantity, unit_price, discount_amount)
VALUES
(1, 1001, 101, 1, 60000, 0),
(2, 1001, 102, 2, 1200, 100),
(3, 1002, 103, 1, 1800, 0),
(4, 1002, 104, 1, 15000, 500),
(5, 1003, 105, 1, 8500, 0),
(6, 1003, 103, 2, 1800, 100),
(7, 1004, 101, 1, 60000, 0),
(8, 1005, 102, 3, 1200, 0),
(9, 1005, 106, 2, 900, 0),
(10, 1006, 104, 1, 15000, 0),
(11, 1007, 101, 1, 60000, 2000),
(12, 1008, 105, 2, 8500, 500);
3.11 Insert Sample Payments
INSERT INTO payments
(payment_id, order_id, payment_date, amount, payment_status)
VALUES
(1, 1001, '2026-01-05', 62300, 'Paid'),
(2, 1002, '2026-01-12', 16300, 'Paid'),
(3, 1003, '2026-02-03', 12000, 'Paid'),
(4, 1004, NULL, 60000, 'Pending'),
(5, 1005, '2026-03-01', 5400, 'Paid'),
(6, 1006, NULL, 15000, 'Refunded'),
(7, 1007, '2026-03-18', 58000, 'Paid'),
(8, 1008, '2026-04-02', 16500, 'Paid');
Note: These sample records are for learning. A production database should define how taxes, shipping fees, refunds, payment reversals, and other adjustments affect business calculations.
4. Essential SQL Concepts for Complex Queries
4.1 INNER JOIN
INNER JOIN returns records with matching values in both tables. It is useful when only records with related data should appear in the result.
SELECT
o.order_id,
o.order_date,
c.customer_name
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id;
This query displays orders along with the names of the customers who placed them.
4.2 LEFT JOIN
LEFT JOIN preserves every row from the left table and adds matching data from the right table. If no match exists, the right-side columns contain NULL.
SELECT
c.customer_id,
c.customer_name,
o.order_id
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id;
This query includes customers who have not placed any orders.
4.3 Aggregate Functions
| Function |
Purpose |
Business Example |
| SUM() |
Adds numeric values. |
Total sales revenue. |
| COUNT() |
Counts rows or non-NULL values. |
Number of orders. |
| AVG() |
Calculates an average. |
Average order value. |
| MAX() |
Finds the maximum value. |
Highest product price. |
| MIN() |
Finds the minimum value. |
Lowest product price. |
4.4 GROUP BY and HAVING
GROUP BY creates groups of rows for aggregate calculations. HAVING filters the groups after aggregation.
SELECT
customer_id,
COUNT(*) AS total_orders
FROM orders
GROUP BY customer_id
HAVING COUNT(*) > 1;
This query identifies customers who have placed more than one order.
4.5 CASE Expressions
CASE applies conditional logic within SQL queries. It can classify products, customers, payments, and performance levels.
SELECT
product_name,
stock_quantity,
CASE
WHEN stock_quantity = 0 THEN 'Out of Stock'
WHEN stock_quantity < 10 THEN 'Low Stock'
ELSE 'Available'
END AS stock_status
FROM products;
5. Business Scenario 1: Calculate Total Order Revenue
Business requirement: Management wants to calculate the value of each order based on its items and discounts.
5.1 Calculate Revenue by Order
SELECT
oi.order_id,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS order_revenue
FROM order_items AS oi
GROUP BY oi.order_id
ORDER BY order_revenue DESC;
The query multiplies the quantity by the unit price, subtracts the recorded discount, and adds the net amounts for each order.
5.2 Calculate Revenue for Completed Orders
SELECT
o.order_id,
o.order_date,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS order_revenue
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.order_status = 'Completed'
GROUP BY o.order_id, o.order_date
ORDER BY order_revenue DESC;
5.3 Explanation
- The join connects each order to its order items.
- The WHERE condition includes completed orders only.
- The SUM function calculates the total item value for each order.
- The GROUP BY clause returns one row per order.
- The ORDER BY clause sorts the highest-value orders first.
For a real financial report, confirm whether the required measure is gross sales, net sales, recognized revenue, or received payments. These measures can differ because of taxes, shipping, refunds, and accounting rules.
6. Business Scenario 2: Find Best-Selling Products
Business requirement: The sales team wants to identify products that generate the highest revenue and unit sales.
6.1 Calculate Product Performance
SELECT
p.product_id,
p.product_name,
SUM(oi.quantity) AS units_sold,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS total_sales
FROM products AS p
INNER JOIN order_items AS oi
ON p.product_id = oi.product_id
INNER JOIN orders AS o
ON oi.order_id = o.order_id
WHERE o.order_status = 'Completed'
GROUP BY p.product_id, p.product_name
ORDER BY total_sales DESC;
6.2 Find the Top Three Products
SELECT
p.product_id,
p.product_name,
SUM(oi.quantity) AS units_sold,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS total_sales
FROM products AS p
INNER JOIN order_items AS oi
ON p.product_id = oi.product_id
INNER JOIN orders AS o
ON oi.order_id = o.order_id
WHERE o.order_status = 'Completed'
GROUP BY p.product_id, p.product_name
ORDER BY total_sales DESC
LIMIT 3;
LIMIT returns up to three products. If the business requires every product tied at the third position, use a ranking function instead.
7. Business Scenario 3: Identify High-Value Customers
Business requirement: The marketing team wants to find customers who contribute the highest completed-order value.
SELECT
c.customer_id,
c.customer_name,
COUNT(DISTINCT o.order_id) AS completed_orders,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS total_order_value
FROM customers AS c
INNER JOIN orders AS o
ON c.customer_id = o.customer_id
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.order_status = 'Completed'
GROUP BY c.customer_id, c.customer_name
ORDER BY total_order_value DESC;
7.1 Find Customers Who Spent More Than 50000
SELECT
c.customer_id,
c.customer_name,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS total_spent
FROM customers AS c
INNER JOIN orders AS o
ON c.customer_id = o.customer_id
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.order_status = 'Completed'
GROUP BY c.customer_id, c.customer_name
HAVING SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) > 50000
ORDER BY total_spent DESC;
HAVING is required because the spending threshold applies to the aggregate total for each customer, not to individual order-item rows.
8. Business Scenario 4: Find Customers Who Have Never Ordered
Business requirement: Marketing wants to identify registered customers who have never placed an order.
SELECT
c.customer_id,
c.customer_name,
c.email,
c.registration_date
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL;
The LEFT JOIN retains all customers. The NULL check selects those without a matching order.
8.1 Find Customers Who Have Never Completed an Order
SELECT
c.customer_id,
c.customer_name
FROM customers AS c
WHERE NOT EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.customer_id
AND o.order_status = 'Completed'
);
This query includes customers with pending or cancelled orders, as long as they have no completed order.
9. Business Scenario 5: Generate Monthly Sales Reports
Business requirement: Management wants to review revenue and completed-order counts for each month.
SELECT
DATE_FORMAT(o.order_date, '%Y-%m') AS sales_month,
COUNT(DISTINCT o.order_id) AS completed_orders,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS monthly_sales
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.order_status = 'Completed'
GROUP BY DATE_FORMAT(o.order_date, '%Y-%m')
ORDER BY sales_month;
9.1 Filter a Specific Year
SELECT
DATE_FORMAT(o.order_date, '%Y-%m') AS sales_month,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS monthly_sales
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.order_status = 'Completed'
AND o.order_date >= '2026-01-01'
AND o.order_date < '2027-01-01'
GROUP BY DATE_FORMAT(o.order_date, '%Y-%m')
ORDER BY sales_month;
The inclusive start date and exclusive end date make the period boundary explicit. This approach also works when the order_date column is changed to DATETIME.
10. Business Scenario 6: Compare Sales Between Two Periods
Business requirement: Management wants to compare January sales with February sales.
SELECT
SUM(
CASE
WHEN o.order_date >= '2026-01-01'
AND o.order_date < '2026-02-01'
THEN oi.quantity * oi.unit_price
- oi.discount_amount
ELSE 0
END
) AS january_sales,
SUM(
CASE
WHEN o.order_date >= '2026-02-01'
AND o.order_date < '2026-03-01'
THEN oi.quantity * oi.unit_price
- oi.discount_amount
ELSE 0
END
) AS february_sales
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.order_status = 'Completed'
AND o.order_date >= '2026-01-01'
AND o.order_date < '2026-03-01';
10.1 Calculate Sales Growth Percentage
WITH monthly_comparison AS (
SELECT
SUM(
CASE
WHEN o.order_date >= '2026-01-01'
AND o.order_date < '2026-02-01'
THEN oi.quantity * oi.unit_price
- oi.discount_amount
ELSE 0
END
) AS january_sales,
SUM(
CASE
WHEN o.order_date >= '2026-02-01'
AND o.order_date < '2026-03-01'
THEN oi.quantity * oi.unit_price
- oi.discount_amount
ELSE 0
END
) AS february_sales
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.order_status = 'Completed'
AND o.order_date >= '2026-01-01'
AND o.order_date < '2026-03-01'
)
SELECT
january_sales,
february_sales,
february_sales - january_sales AS sales_difference,
ROUND(
100.0 * (february_sales - january_sales)
/ NULLIF(january_sales, 0),
2
) AS growth_percentage
FROM monthly_comparison;
NULLIF prevents division by zero. When January sales are zero, the growth percentage is NULL because the usual percentage formula cannot be evaluated.
11. Business Scenario 7: Identify Low-Stock Products
Business requirement: The inventory team wants to identify products that need restocking.
11.1 Find Products with Fewer Than Ten Units
SELECT
product_id,
product_name,
stock_quantity
FROM products
WHERE stock_quantity < 10
ORDER BY stock_quantity ASC;
11.2 Categorize Inventory Status
SELECT
product_id,
product_name,
stock_quantity,
CASE
WHEN stock_quantity = 0 THEN 'Out of Stock'
WHEN stock_quantity < 5 THEN 'Critical'
WHEN stock_quantity < 10 THEN 'Low Stock'
ELSE 'Healthy'
END AS inventory_status
FROM products
ORDER BY stock_quantity ASC;
11.3 Calculate Reorder Quantity
Assume the target inventory level is ten units for each product.
SELECT
product_id,
product_name,
stock_quantity,
GREATEST(10 - stock_quantity, 0) AS reorder_quantity
FROM products
ORDER BY reorder_quantity DESC;
GREATEST ensures the suggested quantity is not negative. In a real inventory system, reorder calculations should also consider supplier lead times, expected demand, safety stock, and purchasing constraints.
12. Business Scenario 8: Identify Unpaid and Partially Paid Orders
Business requirement: The finance team wants to identify completed orders where the qualifying paid amount is less than the order total.
WITH order_totals AS (
SELECT
o.order_id,
o.customer_id,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS order_total
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.order_status = 'Completed'
GROUP BY o.order_id, o.customer_id
),
payment_totals AS (
SELECT
order_id,
SUM(
CASE
WHEN payment_status = 'Paid'
THEN amount
ELSE 0
END
) AS paid_amount
FROM payments
GROUP BY order_id
)
SELECT
ot.order_id,
c.customer_name,
ot.order_total,
COALESCE(pt.paid_amount, 0) AS paid_amount,
ot.order_total - COALESCE(pt.paid_amount, 0)
AS outstanding_amount
FROM order_totals AS ot
INNER JOIN customers AS c
ON ot.customer_id = c.customer_id
LEFT JOIN payment_totals AS pt
ON ot.order_id = pt.order_id
WHERE ot.order_total > COALESCE(pt.paid_amount, 0)
ORDER BY outstanding_amount DESC;
12.1 Explanation
- The first CTE calculates one order total per order.
- The second CTE calculates the sum of qualifying paid transactions per order.
- The final query combines the summaries and calculates the outstanding amount.
- COALESCE replaces a missing payment total with zero.
- The WHERE condition returns orders whose calculated total exceeds the qualifying paid amount.
Calculating the two totals separately prevents order items from being multiplied by multiple payment rows. Production financial reports must also define how refunds, chargebacks, taxes, and reversals affect outstanding balances.
13. Business Scenario 9: Rank Products by Revenue
Business requirement: Management wants to rank products from highest to lowest sales revenue.
WITH product_sales AS (
SELECT
p.product_id,
p.product_name,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS total_revenue
FROM products AS p
INNER JOIN order_items AS oi
ON p.product_id = oi.product_id
INNER JOIN orders AS o
ON oi.order_id = o.order_id
WHERE o.order_status = 'Completed'
GROUP BY p.product_id, p.product_name
),
ranked_products AS (
SELECT
product_id,
product_name,
total_revenue,
DENSE_RANK() OVER (
ORDER BY total_revenue DESC
) AS revenue_rank
FROM product_sales
)
SELECT *
FROM ranked_products
ORDER BY revenue_rank, product_name;
13.1 Find Products in the Top Three Revenue Ranks
WITH product_sales AS (
SELECT
p.product_id,
p.product_name,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS total_revenue
FROM products AS p
INNER JOIN order_items AS oi
ON p.product_id = oi.product_id
INNER JOIN orders AS o
ON oi.order_id = o.order_id
WHERE o.order_status = 'Completed'
GROUP BY p.product_id, p.product_name
),
ranked_products AS (
SELECT
product_id,
product_name,
total_revenue,
DENSE_RANK() OVER (
ORDER BY total_revenue DESC
) AS revenue_rank
FROM product_sales
)
SELECT *
FROM ranked_products
WHERE revenue_rank <= 3
ORDER BY revenue_rank, product_name;
DENSE_RANK assigns the same rank to tied values without leaving gaps in subsequent ranks. This query includes every product whose revenue belongs to the top three distinct revenue ranks.
14. Business Scenario 10: Calculate Average Order Value
Business requirement: Management wants to know the average value of completed orders.
WITH order_totals AS (
SELECT
o.order_id,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS order_total
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.order_status = 'Completed'
GROUP BY o.order_id
)
SELECT
ROUND(AVG(order_total), 2) AS average_order_value
FROM order_totals;
The CTE first calculates one total per order. AVG then calculates the average across those order totals. Averaging individual item amounts would measure the average item-line value instead of the average order value.
15. Business Scenario 11: Find Repeat Customers
Business requirement: The marketing team wants to identify customers with at least three completed orders.
SELECT
c.customer_id,
c.customer_name,
COUNT(DISTINCT o.order_id) AS completed_order_count
FROM customers AS c
INNER JOIN orders AS o
ON c.customer_id = o.customer_id
WHERE o.order_status = 'Completed'
GROUP BY c.customer_id, c.customer_name
HAVING COUNT(DISTINCT o.order_id) >= 3
ORDER BY completed_order_count DESC;
This query supports loyalty analysis, customer retention, and repeat-purchase campaigns. The minimum order count can be adjusted to match the business definition of a repeat customer.
16. Business Scenario 12: Identify Inactive Customers
Business requirement: The business wants to identify customers who have not placed a completed order after a specified cutoff date.
SELECT
c.customer_id,
c.customer_name,
MAX(o.order_date) AS last_completed_order
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id
AND o.order_status = 'Completed'
GROUP BY c.customer_id, c.customer_name
HAVING MAX(o.order_date) IS NULL
OR MAX(o.order_date) < '2026-01-01'
ORDER BY last_completed_order;
The query includes customers with no completed orders and customers whose latest completed order occurred before the cutoff. For a rolling 90-day report, calculate the cutoff based on the reporting date and define date boundaries consistently.
17. Business Scenario 13: Use CTEs to Simplify Complex Queries
A Common Table Expression (CTE) is a named result set defined with the WITH clause. It exists for a single statement and helps organize multi-step queries. CTEs are supported in MySQL 8.0 and later. Read the MySQL CTE documentation.
17.1 Example: Build Customer Segments
WITH customer_sales AS (
SELECT
o.customer_id,
COUNT(DISTINCT o.order_id) AS total_orders,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS total_sales
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.order_status = 'Completed'
GROUP BY o.customer_id
),
customer_segments AS (
SELECT
customer_id,
total_orders,
total_sales,
CASE
WHEN total_sales >= 100000 THEN 'Premium'
WHEN total_sales >= 50000 THEN 'Gold'
WHEN total_sales > 0 THEN 'Standard'
ELSE 'No Completed Sales'
END AS customer_segment
FROM customer_sales
)
SELECT
c.customer_name,
COALESCE(cs.total_orders, 0) AS total_orders,
COALESCE(cs.total_sales, 0) AS total_sales,
COALESCE(cs.customer_segment, 'No Completed Sales')
AS customer_segment
FROM customers AS c
LEFT JOIN customer_segments AS cs
ON c.customer_id = cs.customer_id
ORDER BY total_sales DESC;
17.2 Benefits of CTEs
- Separate a long query into understandable steps.
- Give intermediate calculations meaningful names.
- Allow later CTEs to reference earlier CTEs.
- Make it easier to inspect and debug complex calculations.
- Reduce repeated expressions in some query designs.
CTEs improve structure and readability but do not automatically guarantee faster execution. Review the execution plan and measure performance with representative data.
18. Business Scenario 14: Calculate Running Sales Totals
Business requirement: Management wants to see monthly revenue and cumulative revenue for a selected year.
WITH monthly_sales AS (
SELECT
DATE_FORMAT(o.order_date, '%Y-%m') AS sales_month,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS monthly_revenue
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.order_status = 'Completed'
AND o.order_date >= '2026-01-01'
AND o.order_date < '2027-01-01'
GROUP BY DATE_FORMAT(o.order_date, '%Y-%m')
)
SELECT
sales_month,
monthly_revenue,
SUM(monthly_revenue) OVER (
ORDER BY sales_month
ROWS BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW
) AS cumulative_revenue
FROM monthly_sales
ORDER BY sales_month;
The first stage calculates revenue for each month. The windowed SUM then adds each month's revenue to the totals from preceding months. MySQL window functions support calculations over related rows in a result set. Read the MySQL window functions documentation.
19. Business Scenario 15: Compare Revenue with the Previous Month
Business requirement: Management wants to identify whether monthly revenue increased or decreased compared with the preceding month.
WITH monthly_sales AS (
SELECT
DATE_FORMAT(o.order_date, '%Y-%m') AS sales_month,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS monthly_revenue
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.order_status = 'Completed'
GROUP BY DATE_FORMAT(o.order_date, '%Y-%m')
),
sales_comparison AS (
SELECT
sales_month,
monthly_revenue,
LAG(monthly_revenue) OVER (
ORDER BY sales_month
) AS previous_month_revenue
FROM monthly_sales
)
SELECT
sales_month,
monthly_revenue,
previous_month_revenue,
monthly_revenue - previous_month_revenue
AS revenue_change
FROM sales_comparison
ORDER BY sales_month;
LAG returns the value from the preceding row in the specified ordering. The first row has no preceding row, so its previous-month revenue and revenue change are NULL.
Important: This query compares consecutive rows that exist in the monthly summary. If a month has no sales records, it will be missing. A complete calendar report should include every required month, usually by joining to a calendar table or generating a date series.
20. Business Scenario 16: Analyze Revenue by Product Category
Business requirement: Management wants to understand which product categories contribute the most sales.
SELECT
p.category,
COUNT(DISTINCT o.order_id) AS order_count,
SUM(oi.quantity) AS units_sold,
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) AS category_revenue,
ROUND(
SUM(
oi.quantity * oi.unit_price
- oi.discount_amount
) / NULLIF(SUM(oi.quantity), 0),
2
) AS average_net_price_per_unit
FROM products AS p
INNER JOIN order_items AS oi
ON p.product_id = oi.product_id
INNER JOIN orders AS o
ON oi.order_id = o.order_id
WHERE o.order_status = 'Completed'
GROUP BY p.category
ORDER BY category_revenue DESC;
The query reports order counts, units sold, category revenue, and the average net amount per unit. It uses total net sales divided by total units, which is a quantity-weighted calculation. This differs from simply averaging the unit price of each order-item row.
21. Business Scenario 17: Find Products Never Sold
Business requirement: The inventory team wants to identify products that have never appeared in a completed order.
SELECT
p.product_id,
p.product_name,
p.category,
p.stock_quantity
FROM products AS p
WHERE NOT EXISTS (
SELECT 1
FROM order_items AS oi
INNER JOIN orders AS o
ON oi.order_id = o.order_id
WHERE oi.product_id = p.product_id
AND o.order_status = 'Completed'
);
NOT EXISTS checks whether any qualifying completed-order record exists for a product. The query can identify products that have stock but no completed sales. Pending and cancelled orders do not qualify as completed sales under this example's business rule.
22. Business Scenario 18: Calculate Order Completion Rate
Business requirement: Management wants to know what percentage of recorded orders have been completed.
SELECT
COUNT(*) AS total_orders,
SUM(
CASE
WHEN order_status = 'Completed' THEN 1
ELSE 0
END
) AS completed_orders,
ROUND(
100.0 * SUM(
CASE
WHEN order_status = 'Completed' THEN 1
ELSE 0
END
) / NULLIF(COUNT(*), 0),
2
) AS completion_percentage
FROM orders;
COUNT(*) calculates the number of orders in the report. The CASE expression counts completed orders, and NULLIF prevents division by zero. This is an order completion rate, not a website conversion rate, which would require a denominator such as sessions, visitors, or qualified leads.
23. Common Problems in Complex SQL Queries
23.1 Duplicate Totals Caused by Joins
Problem: Revenue or payment totals are unexpectedly high.
Cause: Joining multiple one-to-many tables can multiply rows. For example, an order with several items and several payments can produce every item-payment combination.
Solution: Aggregate each detail table to the required level before joining the results.
WITH order_totals AS (
SELECT
order_id,
SUM(quantity * unit_price - discount_amount)
AS order_total
FROM order_items
GROUP BY order_id
),
payment_totals AS (
SELECT
order_id,
SUM(
CASE
WHEN payment_status = 'Paid' THEN amount
ELSE 0
END
) AS paid_total
FROM payments
GROUP BY order_id
)
SELECT
ot.order_id,
ot.order_total,
COALESCE(pt.paid_total, 0) AS paid_total
FROM order_totals AS ot
LEFT JOIN payment_totals AS pt
ON ot.order_id = pt.order_id;
23.2 Using WHERE Instead of HAVING
Problem: The query cannot filter an aggregate value in the WHERE clause.
Cause: WHERE filters individual rows before grouping, while HAVING filters groups after aggregation.
Solution: Use WHERE for row-level conditions and HAVING for conditions on aggregate results.
23.3 Incorrect GROUP BY Columns
Problem: A query fails under strict SQL mode or returns misleading summaries.
Cause: The query selects non-aggregated columns that are not appropriately grouped or functionally dependent on the grouping columns.
Solution: Check the intended output level, include the appropriate grouping columns, and aggregate other selected values where appropriate.
23.4 Counting the Wrong Records
Problem: A report contains too many orders or customers.
Cause: A join creates multiple rows for the same order or customer.
Solution: Use COUNT(DISTINCT order_id) when the business metric is distinct orders, or aggregate the source data before joining.
23.5 Incorrect Date Filtering
Problem: A report excludes records on the last day or includes records from the following period.
Cause: The query uses unclear date boundaries or compares dates and timestamps incorrectly.
Solution: Use a start-inclusive and end-exclusive range, such as order_date >= '2026-01-01' AND order_date < '2026-02-01'.
23.6 Handling NULL Values
Problem: Calculations return NULL unexpectedly.
Cause: Arithmetic involving NULL can return NULL, and missing information may affect aggregates or joins.
Solution: Determine whether a missing value means zero, unknown, or not applicable. Use COALESCE only when the chosen replacement matches the business meaning.
24. Performance Optimization for Complex Queries
24.1 Select Only Required Columns
Avoid SELECT * in large reporting queries when only a few columns are required. Selecting the necessary columns can reduce data transfer and make the query easier to maintain.
24.2 Create Appropriate Indexes
Indexes can improve lookups, joins, and filtering. Candidate indexes depend on the query workload, data distribution, and existing indexes.
CREATE INDEX idx_orders_customer
ON orders(customer_id);
CREATE INDEX idx_orders_date_status
ON orders(order_date, order_status);
CREATE INDEX idx_order_items_product
ON order_items(product_id);
These indexes are examples, not universal recommendations. Check existing indexes before creating new ones. Indexes require storage and can add overhead to INSERT, UPDATE, and DELETE operations.
24.3 Use EXPLAIN
EXPLAIN
SELECT
customer_id,
COUNT(*) AS order_count
FROM orders
WHERE order_date >= '2026-01-01'
AND order_date < '2027-01-01'
GROUP BY customer_id;
EXPLAIN helps inspect the planned access methods, possible indexes, join order, and estimated row counts. EXPLAIN ANALYZE is available in supported MySQL versions and provides actual execution information by running the statement being analyzed.
24.4 Avoid Unnecessary Functions on Filter Columns
Filtering with YEAR(order_date) may make ordinary index use less straightforward than a direct date range. Prefer the following pattern when appropriate:
WHERE order_date >= '2026-01-01'
AND order_date < '2027-01-01'
24.5 Aggregate Before Joining When Necessary
Aggregating detailed tables before joining can reduce row multiplication and make totals more reliable. However, the best strategy depends on the data and optimizer, so measure the query rather than assuming every rewrite will be faster.
25. Real-World Query Design Checklist
- Understand the business question before writing SQL.
- Define the business metric and the required output level.
- Identify the relevant tables, keys, and relationships.
- Choose the correct join type.
- Define which order statuses qualify for the report.
- Distinguish revenue, profit, order value, and received payments.
- Use GROUP BY and aggregate functions at the correct level.
- Use HAVING for filters on aggregate results.
- Use CTEs when they make multi-step calculations easier to understand.
- Handle NULL values and division-by-zero cases deliberately.
- Check for row multiplication when joining detail tables.
- Validate totals against sample records or trusted reports.
- Review indexes and execution plans for slow queries.
- Document business assumptions and date boundaries.
26. Practical Project: Retail Business Performance Dashboard
Project Objective: Build a set of SQL queries to support a retail dashboard covering sales, customers, inventory, and payment monitoring.
26.1 Project Requirements
- Calculate the total value of completed orders.
- Generate monthly revenue and completed-order reports.
- Identify the top five products by revenue.
- Find customers who have never completed an order.
- Calculate the total order value for each customer.
- Identify products with fewer than ten units in stock.
- Find completed orders with an outstanding payment balance.
- Rank products by revenue.
- Calculate cumulative monthly revenue.
- Compare each month's revenue with the previous month.
- Identify product categories with the highest revenue.
- Calculate the order completion rate.
26.2 Recommended Dashboard Reports
| Report |
Recommended Fields |
| Sales Overview |
Completed orders, net item sales, average order value |
| Monthly Sales |
Month, order count, monthly revenue, cumulative revenue |
| Product Performance |
Product name, units sold, revenue, revenue rank |
| Customer Analysis |
Customer name, completed orders, total order value |
| Inventory Report |
Product name, stock quantity, inventory status, reorder quantity |
| Payment Monitoring |
Order ID, order total, paid amount, outstanding amount |
26.3 Validation Tasks
- Manually verify the item-level total of at least one order.
- Confirm that cancelled orders are excluded where required.
- Verify that multiple payment rows do not inflate order totals.
- Check that customers without completed orders are included when needed.
- Test NULL values, empty results, duplicate detail rows, and date boundaries.
- Compare the query results with a trusted business report where available.
27. Practice Exercises
- Write a query to calculate net item sales for every completed order.
- Find the five products with the highest revenue.
- Calculate monthly sales for a selected calendar year.
- Identify customers who have never placed an order.
- Find customers with at least three completed orders.
- Calculate average order value using one total per order.
- Identify products with zero stock and low stock.
- Calculate total revenue for each product category.
- Rank products using DENSE_RANK() based on revenue.
- Calculate cumulative monthly revenue using a window function.
- Compare each month's revenue with the previous month.
- Find completed orders with outstanding payment balances.
- Explain why joining order items directly to multiple payment rows can inflate totals.
- Use EXPLAIN to examine a query that filters orders by date.
- Rewrite a long query using multiple CTEs to improve readability.
28. Interview Questions and Answers
Q1. What is a complex SQL query?
Answer: A complex SQL query combines techniques such as joins, subqueries, aggregate functions, CTEs, conditional expressions, and window functions to solve a multi-step business requirement.
Q2. What is the difference between WHERE and HAVING?
Answer: WHERE filters individual rows before grouping, while HAVING filters groups after aggregate calculations.
Q3. Why are JOINs used in business queries?
Answer: JOINs combine related information from multiple tables, such as customer names with orders or product information with order items.
Q4. What is a CTE?
Answer: A Common Table Expression is a named result set defined with WITH that can be referenced within a single SQL statement.
Q5. What is the purpose of CASE?
Answer: CASE applies conditional logic and returns values based on specified conditions. It can categorize records or calculate conditional totals.
Q6. How do you calculate monthly revenue?
Answer: Join the relevant sales tables, calculate the required sales amount, group by month, and use SUM() to calculate monthly totals.
Q7. How can you find customers without orders?
Answer: Use a LEFT JOIN with a NULL check on the order key or use NOT EXISTS to check whether a qualifying order exists.
Q8. What is the difference between COUNT(*) and COUNT(column_name)?
Answer: COUNT(*) counts rows, whereas COUNT(column_name) counts only rows where the specified column is not NULL.
Q9. Why can joins produce incorrect totals?
Answer: Joining tables with multiple matching records can multiply rows. Aggregate detail tables at the correct level before joining when necessary.
Q10. How do you calculate a running total?
Answer: Use SUM() as a window function with an ORDER BY clause and an appropriate window frame.
Q11. What does LAG() do?
Answer: LAG() retrieves a value from a preceding row in an ordered window, making it useful for period comparisons.
Q12. How can you improve complex query performance?
Answer: Select necessary columns, use appropriate indexes, filter effectively, aggregate at the right level, inspect execution plans, and test with realistic data.
Q13. Why should order totals and payment totals sometimes be calculated separately?
Answer: Orders can contain multiple item rows and multiple payment rows. Joining both detail tables directly may multiply rows and inflate the calculations.
Q14. What does COALESCE() do?
Answer: COALESCE() returns the first non-NULL expression from its arguments. It can replace missing totals with zero when that matches the business rule.
Q15. How do you avoid division by zero?
Answer: Use NULLIF() on the denominator or handle zero-denominator cases with CASE logic.
29. Quick Revision Notes
- INNER JOIN: Returns matching records from both tables.
- LEFT JOIN: Preserves every row from the left table.
- GROUP BY: Groups records for aggregate calculations.
- HAVING: Filters groups after aggregation.
- CASE: Implements conditional logic.
- CTE: Names an intermediate result set using WITH.
- SUM(): Calculates totals.
- COUNT(DISTINCT ...): Counts distinct non-NULL values.
- DENSE_RANK(): Ranks values without gaps after ties.
- LAG(): Retrieves a value from a preceding row.
- Windowed SUM(): Calculates running totals.
- COALESCE(): Returns the first non-NULL value.
- NULLIF(): Returns NULL when two expressions are equal, useful for guarding against division by zero.
- EXPLAIN: Helps inspect the query execution plan.
- Index: Can improve data access when designed for the actual workload.
30. Conclusion
Writing complex queries for real-world business scenarios requires a combination of SQL knowledge, logical thinking, and an understanding of business rules. JOINs connect related datasets, aggregate functions summarize results, CASE expressions apply conditional logic, CTEs organize multi-step calculations, and window functions support rankings and comparisons.
Accurate business queries must also handle NULL values, date boundaries, different order statuses, and relationships between tables. One of the most important practices is to prevent duplicate totals caused by joining multiple one-to-many tables. Always define each metric carefully, validate results against sample data, and review execution plans when performance matters.
With regular practice, these techniques can be used to build reliable reports for sales, customer retention, inventory management, finance, and business intelligence.
Continue Learning: Visit the MySQL Training Course to explore additional database and SQL topics, and register for a course demo to learn more.