Who are our most valuable customers, what do they buy, and how does their behavior compare with other customers? A retail case study written in SQL.
This project works through a retail company's sales data the way an analyst would before presenting to management. It starts with ten core business questions about revenue, products, regions, returns, and growth. Then it builds one final dataset that profiles every customer and places them in a value segment. That dataset answers the main question: who are our most valuable customers, what do they buy, and how does their behavior compare with everyone else's?
The results are visualized in an interactive Tableau dashboard.
Five related tables, loaded from CSV into DuckDB:
customers → orders → order_items → products, with returns linked to order_items. Revenue is defined as quantity × unit_price (the actual selling price on the order line), before returns and excluding shipping. Every order in the data is Completed.
Click SQL under any question to see the query behind the answer.
Revenue — What is total revenue?
$1,100,828.72 from 3,000 completed orders (Jan 2024 – Jul 2025).
SELECT ROUND(SUM(quantity * unit_price), 2) AS total_revenue
FROM order_items;| total_revenue |
|---|
| $1,100,828.72 |
Customers — How many have purchased, and how many never have?
447 of 500 customers (89%) have placed an order; 53 signed up but never bought.
SELECT
COUNT(DISTINCT o.customer_id) AS customers_purchased,
COUNT(*) FILTER (WHERE o.customer_id IS NULL) AS customers_never_purchased
FROM customers c
LEFT JOIN (SELECT DISTINCT customer_id FROM orders) o
ON c.customer_id = o.customer_id;| customers_purchased | customers_never_purchased |
|---|---|
| 447 | 53 |
Products — What are the top 10 products by revenue?
The Winter Jacket alone brings in $268,856, about 24% of all revenue. Most of the rest of the top 10 comes from the Camping Tent family.
SELECT
p.product_name,
p.category,
SUM(oi.quantity) AS quantity_sold,
ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue
FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
GROUP BY p.product_id, p.product_name, p.category
ORDER BY revenue DESC
LIMIT 10;| product_name | category | quantity_sold | revenue |
|---|---|---|---|
| Winter Jacket | Apparel | 2,060 | $268,856.21 |
| Camping Tent | Outdoor | 466 | $95,556.19 |
| Camping Tent - Orange | Outdoor | 301 | $58,412.96 |
| Camping Tent - Green | Outdoor | 224 | $47,324.18 |
| Camping Tent - White | Outdoor | 210 | $45,154.18 |
| Trail Running Shoes - Navy | Footwear | 362 | $41,749.76 |
| Hiking Backpack - White | Outdoor | 378 | $39,747.43 |
| Winter Jacket - Black | Apparel | 307 | $39,581.36 |
| Hiking Boots - Navy | Footwear | 260 | $38,005.02 |
| Fleece Pullover - Green | Apparel | 498 | $37,321.98 |
Categories — Which category generates the most revenue?
Apparel (39.1%), just ahead of Outdoor (38.0%). Accessories brings in less than 2%.
SELECT
p.category,
ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue,
ROUND(100.0 * SUM(oi.quantity * oi.unit_price)
/ SUM(SUM(oi.quantity * oi.unit_price)) OVER (), 2) AS pct_of_revenue
FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
GROUP BY p.category
ORDER BY revenue DESC;| category | revenue | pct_of_revenue |
|---|---|---|
| Apparel | $429,893.32 | 39.05% |
| Outdoor | $418,151.28 | 37.99% |
| Footwear | $233,518.93 | 21.21% |
| Accessories | $19,265.19 | 1.75% |
Customers — Who are the top 10 customers?
Henry Walker (South) leads with $58,402 from 162 orders, more than twice the #2 customer. Nine of the top 10 are Consumer accounts.
SELECT
c.customer_id,
c.customer_name,
c.region,
c.customer_segment,
COUNT(DISTINCT o.order_id) AS order_count,
ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY c.customer_id, c.customer_name, c.region, c.customer_segment
ORDER BY revenue DESC
LIMIT 10;| customer_id | customer_name | region | customer_segment | order_count | revenue |
|---|---|---|---|---|---|
| C0210 | Henry Walker | South | Consumer | 162 | $58,402.23 |
| C0479 | Irene Thomas | South | Consumer | 76 | $26,931.69 |
| C0114 | Olivia Wilson | East | Consumer | 46 | $16,675.62 |
| C0180 | Victoria Walker | West | Consumer | 42 | $14,890.16 |
| C0221 | David Allen | East | Corporate | 37 | $14,820.59 |
| C0253 | Emma Moore | North | Consumer | 34 | $14,097.36 |
| C0136 | Brian Johnson | West | Consumer | 21 | $11,284.99 |
| C0107 | William Anderson | West | Consumer | 28 | $10,691.65 |
| C0285 | Rachel Miller | West | Consumer | 30 | $10,238.94 |
| C0375 | Liam Smith | West | Consumer | 34 | $10,207.98 |
Geography — Which region generates the most revenue?
West, with $349,727 (32% of revenue) and the most customers and orders. North is last.
SELECT
c.region,
COUNT(DISTINCT c.customer_id) AS customers,
COUNT(DISTINCT o.order_id) AS orders,
ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY c.region
ORDER BY revenue DESC;| region | customers | orders | revenue |
|---|---|---|---|
| West | 137 | 978 | $349,727.21 |
| South | 102 | 847 | $310,062.46 |
| East | 124 | 716 | $266,496.80 |
| North | 84 | 459 | $174,542.25 |
Returns — Which category has the highest return rate?
Footwear, with 9.9% of units sold returned, compared with 4.0% for Accessories.
WITH returned_quantity AS (
SELECT order_item_id, SUM(return_quantity) AS quantity_returned
FROM returns
GROUP BY order_item_id
)
SELECT
p.category,
SUM(oi.quantity) AS quantity_sold,
SUM(COALESCE(r.quantity_returned, 0)) AS quantity_returned,
ROUND(SUM(COALESCE(r.quantity_returned, 0)) * 100.0
/ NULLIF(SUM(oi.quantity), 0), 2) AS return_rate
FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
LEFT JOIN returned_quantity r ON oi.order_item_id = r.order_item_id
GROUP BY p.category
ORDER BY return_rate DESC;| category | quantity_sold | quantity_returned | return_rate |
|---|---|---|---|
| Footwear | 1,767 | 175 | 9.9% |
| Apparel | 4,231 | 326 | 7.71% |
| Outdoor | 3,019 | 169 | 5.6% |
| Accessories | 804 | 32 | 3.98% |
Return rate = units returned ÷ units sold.
Time — Which month generated the most revenue?
July 2025, with $152,238. The five best months all fall in 2025, so the business is growing.
SELECT
STRFTIME(DATE_TRUNC('month', o.order_date), '%b %Y') AS month,
ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY DATE_TRUNC('month', o.order_date)
ORDER BY revenue DESC
LIMIT 5;| month | revenue |
|---|---|
| Jul 2025 | $152,237.90 |
| Jun 2025 | $114,988.58 |
| Apr 2025 | $83,571.26 |
| May 2025 | $82,168.09 |
| Mar 2025 | $79,531.66 |
Top 5 months shown.
Growth — Which month had the highest month-over-month growth?
June 2025, up 39.9% over May. July followed with another +32.4%.
WITH monthly_revenue AS (
SELECT
DATE_TRUNC('month', o.order_date) AS month_date,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY 1
),
monthly_growth AS (
SELECT
month_date,
revenue,
LAG(revenue) OVER (ORDER BY month_date) AS previous_month_revenue
FROM monthly_revenue
)
SELECT
STRFTIME(month_date, '%b %Y') AS month,
ROUND(revenue, 2) AS revenue,
ROUND(previous_month_revenue, 2) AS previous_month_revenue,
ROUND((revenue - previous_month_revenue)
/ NULLIF(previous_month_revenue, 0) * 100, 2) AS mom_growth_pct
FROM monthly_growth
WHERE previous_month_revenue IS NOT NULL
ORDER BY mom_growth_pct DESC
LIMIT 5;| month | revenue | previous_month_revenue | mom_growth_pct |
|---|---|---|---|
| Jun 2025 | $114,988.58 | $82,168.09 | 39.94% |
| Jul 2025 | $152,237.90 | $114,988.58 | 32.39% |
| Mar 2025 | $79,531.66 | $60,569.59 | 31.31% |
| Oct 2024 | $49,693.45 | $38,212.58 | 30.04% |
| Apr 2024 | $40,674.34 | $31,796.74 | 27.92% |
Top 5 months shown. Uses LAG() over monthly revenue.
Concentration — What share of revenue comes from the top 10 customers?
17.1%. Ten customers (2% of buyers) generate about one dollar in every six.
WITH customer_revenue AS (
SELECT o.customer_id, SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY o.customer_id
),
ranked AS (
SELECT
revenue,
ROW_NUMBER() OVER (ORDER BY revenue DESC) AS rn
FROM customer_revenue
)
SELECT
ROUND(SUM(revenue) FILTER (WHERE rn <= 10), 2) AS top10_revenue,
ROUND(SUM(revenue), 2) AS total_revenue,
ROUND(100.0 * SUM(revenue) FILTER (WHERE rn <= 10) / SUM(revenue), 2) AS top10_pct
FROM ranked;| top10_revenue | total_revenue | top10_pct |
|---|---|---|
| $188,241.21 | $1,100,828.72 | 17.1% |
The final query creates one row per customer (all 500, including those who never ordered) with their order history, revenue, rankings, recency, and a value segment. It chains seven CTEs (first filtering out canceled orders) and uses joins, aggregation, RANK() overall and with PARTITION BY region, ROW_NUMBER(), a windowed total for each customer's share of revenue, date arithmetic, and a CASE expression for the segment:
In this data, every customer above the 1% line is already in the top 10, so the High Value segment is empty. days_since_last_order is measured from the date the query runs (CURRENT_DATE), and the data ends on 2025-07-31. Recency figures are therefore best read relative to each other.
WITH valid_orders AS (
SELECT
order_id,
customer_id,
order_date,
shipping_cost
FROM orders
WHERE LOWER(order_status) <> 'canceled'
OR order_status IS NULL
),
customer_order_summary AS (
SELECT
c.customer_id,
c.customer_name,
c.region,
c.customer_segment,
MIN(vo.order_date) AS first_order_date,
MAX(vo.order_date) AS last_order_date,
COUNT(DISTINCT vo.order_id) AS order_count
FROM customers c
LEFT JOIN valid_orders vo
ON c.customer_id = vo.customer_id
GROUP BY
c.customer_id,
c.customer_name,
c.region,
c.customer_segment
),
customer_product_metrics AS (
SELECT
c.customer_id,
COALESCE(SUM(oi.quantity), 0) AS total_quantity,
COALESCE(
SUM(oi.quantity * oi.unit_price), 0
) AS total_revenue
FROM customers c
LEFT JOIN valid_orders vo
ON c.customer_id = vo.customer_id
LEFT JOIN order_items oi
ON vo.order_id = oi.order_id
GROUP BY c.customer_id
),
customer_shipping AS (
SELECT
customer_id,
SUM(shipping_cost) AS total_shipping_cost
FROM valid_orders
GROUP BY customer_id
),
customer_metrics AS (
SELECT
cos.customer_id,
cos.customer_name,
cos.region,
cos.customer_segment,
cos.first_order_date,
cos.last_order_date,
cos.order_count,
cpm.total_quantity,
cpm.total_revenue,
COALESCE(cs.total_shipping_cost, 0)
AS total_shipping_cost,
CASE
WHEN cos.order_count = 0 THEN 0
ELSE cpm.total_revenue / cos.order_count
END AS average_order_value
FROM customer_order_summary cos
LEFT JOIN customer_product_metrics cpm
ON cos.customer_id = cpm.customer_id
LEFT JOIN customer_shipping cs
ON cos.customer_id = cs.customer_id
),
customer_rankings AS (
SELECT
*,
RANK() OVER (
ORDER BY total_revenue DESC
) AS customer_rank,
RANK() OVER (
PARTITION BY region
ORDER BY total_revenue DESC
) AS regional_rank,
ROW_NUMBER() OVER (
PARTITION BY region
ORDER BY
total_revenue DESC,
order_count DESC,
customer_id
) AS regional_row_number,
SUM(total_revenue) OVER ()
AS overall_revenue
FROM customer_metrics
),
final_metrics AS (
SELECT
*,
CASE
WHEN overall_revenue = 0 THEN 0
ELSE 100.0 * total_revenue / overall_revenue
END AS percentage_of_total_revenue,
DATE_DIFF(
'day',
CAST(last_order_date AS DATE),
CURRENT_DATE
) AS days_since_last_order
FROM customer_rankings
)
SELECT
customer_id,
customer_name,
region,
customer_segment,
first_order_date,
last_order_date,
order_count,
total_quantity,
ROUND(total_revenue, 2) AS total_revenue,
ROUND(average_order_value, 2) AS average_order_value,
customer_rank,
regional_rank,
ROUND(percentage_of_total_revenue, 2)
AS percentage_of_total_revenue,
days_since_last_order,
CASE
WHEN order_count = 0 THEN 'No Orders'
WHEN customer_rank <= 10 THEN 'Top 10 Customer'
WHEN percentage_of_total_revenue >= 1
THEN 'High Value'
WHEN percentage_of_total_revenue >= 0.1
THEN 'Medium Value'
ELSE 'Low Value'
END AS customer_value_segment
FROM final_metrics
ORDER BY
customer_rank,
customer_id;| customer_id | customer_name | region | customer_segment | first_order_date | last_order_date | order_count | total_quantity | total_revenue | average_order_value | customer_rank | regional_rank | percentage_of_total_revenue | days_since_last_order | customer_value_segment |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| C0210 | Henry Walker | South | Consumer | 2024-02-06 | 2025-07-30 | 162 | 513 | $58,402.23 | $360.51 | 1 | 1 | 5.31% | 440 | Top 10 Customer |
| C0479 | Irene Thomas | South | Consumer | 2024-01-09 | 2025-07-30 | 76 | 233 | $26,931.69 | $354.36 | 2 | 2 | 2.45% | 440 | Top 10 Customer |
| C0114 | Olivia Wilson | East | Consumer | 2025-04-01 | 2025-07-28 | 46 | 149 | $16,675.62 | $362.51 | 3 | 1 | 1.51% | 442 | Top 10 Customer |
| C0180 | Victoria Walker | West | Consumer | 2024-12-07 | 2025-07-31 | 42 | 136 | $14,890.16 | $354.53 | 4 | 1 | 1.35% | 439 | Top 10 Customer |
| C0221 | David Allen | East | Corporate | 2024-12-21 | 2025-07-26 | 37 | 135 | $14,820.59 | $400.56 | 5 | 2 | 1.35% | 444 | Top 10 Customer |
| C0253 | Emma Moore | North | Consumer | 2024-04-26 | 2025-07-09 | 34 | 125 | $14,097.36 | $414.63 | 6 | 1 | 1.28% | 461 | Top 10 Customer |
| C0136 | Brian Johnson | West | Consumer | 2024-01-09 | 2025-07-19 | 21 | 89 | $11,284.99 | $537.38 | 7 | 2 | 1.03% | 451 | Top 10 Customer |
| C0107 | William Anderson | West | Consumer | 2024-01-13 | 2025-06-28 | 28 | 95 | $10,691.65 | $381.84 | 8 | 3 | 0.97% | 472 | Top 10 Customer |
| C0285 | Rachel Miller | West | Consumer | 2024-01-11 | 2025-06-22 | 30 | 86 | $10,238.94 | $341.30 | 9 | 4 | 0.93% | 478 | Top 10 Customer |
| C0375 | Liam Smith | West | Consumer | 2025-06-06 | 2025-07-29 | 34 | 94 | $10,207.98 | $300.23 | 10 | 5 | 0.93% | 441 | Top 10 Customer |
First 10 of 500 rows. Scroll sideways for all columns.
WITH item_returns AS (
SELECT o.customer_id,
SUM(oi.quantity) AS quantity_bought,
SUM(COALESCE(r.return_quantity, 0)) AS quantity_returned
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
LEFT JOIN returns r ON oi.order_item_id = r.order_item_id
GROUP BY o.customer_id
)
SELECT
cs.customer_value_segment AS segment,
COUNT(*) AS customers,
ROUND(SUM(cs.total_revenue), 2) AS revenue,
ROUND(100.0 * SUM(cs.total_revenue)
/ SUM(SUM(cs.total_revenue)) OVER (), 1) AS pct_of_revenue,
ROUND(AVG(cs.order_count), 1) AS avg_orders,
ROUND(SUM(cs.total_revenue) / NULLIF(SUM(cs.order_count), 0), 2) AS avg_order_value,
ROUND(MEDIAN(cs.days_since_last_order), 0) AS median_days_since_last_order,
ROUND(100.0 * SUM(ir.quantity_returned)
/ NULLIF(SUM(ir.quantity_bought), 0), 1) AS return_rate,
ROUND(100.0 * COUNT(*) FILTER (WHERE cs.customer_segment = 'Corporate')
/ COUNT(*), 1) AS pct_corporate
FROM customer_segments cs
LEFT JOIN item_returns ir ON cs.customer_id = ir.customer_id
GROUP BY cs.customer_value_segment
ORDER BY MIN(cs.customer_rank);| segment | customers | revenue | pct_of_revenue | avg_orders | avg_order_value | median_days_since_last_order | return_rate | pct_corporate |
|---|---|---|---|---|---|---|---|---|
| Top 10 Customer | 10 | $188,241.21 | 17.1% | 51 | $369.10 | 443 | 7.6% | 10% |
| Medium Value | 254 | $814,740.08 | 74% | 8.4 | $381.43 | 472 | 7.1% | 17.7% |
| Low Value | 183 | $97,847.43 | 8.9% | 1.9 | $276.41 | 542 | 7% | 15.8% |
| No Orders | 53 | $0.00 | 0% | 0 | — | — | — | 15.1% |
SELECT
cs.customer_value_segment AS segment,
p.category,
ROUND(100.0 * SUM(oi.quantity * oi.unit_price)
/ SUM(SUM(oi.quantity * oi.unit_price)) OVER (PARTITION BY cs.customer_value_segment), 1) AS pct_of_segment_revenue
FROM customer_segments cs
JOIN orders o ON cs.customer_id = o.customer_id
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
GROUP BY cs.customer_value_segment, p.category
ORDER BY segment, pct_of_segment_revenue DESC;| segment | category | pct_of_segment_revenue |
|---|---|---|
| Top 10 Customer | Outdoor | 38.3% |
| Top 10 Customer | Apparel | 36.8% |
| Top 10 Customer | Footwear | 23.1% |
| Top 10 Customer | Accessories | 1.8% |
| Medium Value | Apparel | 39% |
| Medium Value | Outdoor | 38% |
| Medium Value | Footwear | 21.3% |
| Medium Value | Accessories | 1.7% |
| Low Value | Apparel | 44.1% |
| Low Value | Outdoor | 37.1% |
| Low Value | Footwear | 16.8% |
| Low Value | Accessories | 1.9% |
Share of each segment's revenue by product category.