Skip to main content
← SQL

Customer Value Analysis

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.

  • ToolSQL (DuckDB)
  • RoleData Analyst
  • Data5 tables · 500 customers
  • StatusComplete
$1.10M
Total revenue, Jan 2024 – Jul 2025
447 / 500
Customers who have purchased (53 never have)
74%
Revenue from the 254 Medium Value customers
17.1%
Revenue from the top 10 customers

Summary

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.

Data

Five related tables, loaded from CSV into DuckDB:

  • customers (500 rows): customer_id, customer_name, customer_segment (Consumer / Corporate), region, signup_date
  • orders (3,000 rows): order_id, customer_id, order_date, order_status, shipping_cost
  • order_items (6,698 rows): order_item_id, order_id, product_id, quantity, unit_price
  • products (50 rows): product_id, product_name, category, subcategory, unit_cost, list_price
  • returns (568 rows): return_id, order_item_id, return_date, return_quantity, return_reason

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.

Part 1: Ten Business Questions

Click SQL under any question to see the query behind the answer.

Q1

Revenue — What is total revenue?

$1,100,828.72 from 3,000 completed orders (Jan 2024 – Jul 2025).

SQL
SELECT ROUND(SUM(quantity * unit_price), 2) AS total_revenue
FROM order_items;
total_revenue
$1,100,828.72
Q2

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.

SQL
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_purchasedcustomers_never_purchased
44753
Q3

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.

SQL
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_namecategoryquantity_soldrevenue
Winter JacketApparel2,060$268,856.21
Camping TentOutdoor466$95,556.19
Camping Tent - OrangeOutdoor301$58,412.96
Camping Tent - GreenOutdoor224$47,324.18
Camping Tent - WhiteOutdoor210$45,154.18
Trail Running Shoes - NavyFootwear362$41,749.76
Hiking Backpack - WhiteOutdoor378$39,747.43
Winter Jacket - BlackApparel307$39,581.36
Hiking Boots - NavyFootwear260$38,005.02
Fleece Pullover - GreenApparel498$37,321.98
Q4

Categories — Which category generates the most revenue?

Apparel (39.1%), just ahead of Outdoor (38.0%). Accessories brings in less than 2%.

SQL
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;
categoryrevenuepct_of_revenue
Apparel$429,893.3239.05%
Outdoor$418,151.2837.99%
Footwear$233,518.9321.21%
Accessories$19,265.191.75%
Q5

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.

SQL
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_idcustomer_nameregioncustomer_segmentorder_countrevenue
C0210Henry WalkerSouthConsumer162$58,402.23
C0479Irene ThomasSouthConsumer76$26,931.69
C0114Olivia WilsonEastConsumer46$16,675.62
C0180Victoria WalkerWestConsumer42$14,890.16
C0221David AllenEastCorporate37$14,820.59
C0253Emma MooreNorthConsumer34$14,097.36
C0136Brian JohnsonWestConsumer21$11,284.99
C0107William AndersonWestConsumer28$10,691.65
C0285Rachel MillerWestConsumer30$10,238.94
C0375Liam SmithWestConsumer34$10,207.98
Q6

Geography — Which region generates the most revenue?

West, with $349,727 (32% of revenue) and the most customers and orders. North is last.

SQL
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;
regioncustomersordersrevenue
West137978$349,727.21
South102847$310,062.46
East124716$266,496.80
North84459$174,542.25
Q7

Returns — Which category has the highest return rate?

Footwear, with 9.9% of units sold returned, compared with 4.0% for Accessories.

SQL
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;
categoryquantity_soldquantity_returnedreturn_rate
Footwear1,7671759.9%
Apparel4,2313267.71%
Outdoor3,0191695.6%
Accessories804323.98%

Return rate = units returned ÷ units sold.

Q8

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.

SQL
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;
monthrevenue
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.

Q9

Growth — Which month had the highest month-over-month growth?

June 2025, up 39.9% over May. July followed with another +32.4%.

SQL
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;
monthrevenueprevious_month_revenuemom_growth_pct
Jun 2025$114,988.58$82,168.0939.94%
Jul 2025$152,237.90$114,988.5832.39%
Mar 2025$79,531.66$60,569.5931.31%
Oct 2024$49,693.45$38,212.5830.04%
Apr 2024$40,674.34$31,796.7427.92%

Top 5 months shown. Uses LAG() over monthly revenue.

Q10

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.

SQL
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_revenuetotal_revenuetop10_pct
$188,241.21$1,100,828.7217.1%

Part 2: Customer Segmentation

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:

  • Top 10 Customer: overall revenue rank 1–10
  • High Value: at least 1% of total revenue
  • Medium Value: 0.1% – 1% of total revenue
  • Low Value: under 0.1% of total revenue
  • No Orders: signed up, never purchased

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.

SQL
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_idcustomer_nameregioncustomer_segmentfirst_order_datelast_order_dateorder_counttotal_quantitytotal_revenueaverage_order_valuecustomer_rankregional_rankpercentage_of_total_revenuedays_since_last_ordercustomer_value_segment
C0210Henry WalkerSouthConsumer2024-02-062025-07-30162513$58,402.23$360.51115.31%440Top 10 Customer
C0479Irene ThomasSouthConsumer2024-01-092025-07-3076233$26,931.69$354.36222.45%440Top 10 Customer
C0114Olivia WilsonEastConsumer2025-04-012025-07-2846149$16,675.62$362.51311.51%442Top 10 Customer
C0180Victoria WalkerWestConsumer2024-12-072025-07-3142136$14,890.16$354.53411.35%439Top 10 Customer
C0221David AllenEastCorporate2024-12-212025-07-2637135$14,820.59$400.56521.35%444Top 10 Customer
C0253Emma MooreNorthConsumer2024-04-262025-07-0934125$14,097.36$414.63611.28%461Top 10 Customer
C0136Brian JohnsonWestConsumer2024-01-092025-07-192189$11,284.99$537.38721.03%451Top 10 Customer
C0107William AndersonWestConsumer2024-01-132025-06-282895$10,691.65$381.84830.97%472Top 10 Customer
C0285Rachel MillerWestConsumer2024-01-112025-06-223086$10,238.94$341.30940.93%478Top 10 Customer
C0375Liam SmithWestConsumer2025-06-062025-07-293494$10,207.98$300.231050.93%441Top 10 Customer

First 10 of 500 rows. Scroll sideways for all columns.

How the segments compare

SQL
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);
segmentcustomersrevenuepct_of_revenueavg_ordersavg_order_valuemedian_days_since_last_orderreturn_ratepct_corporate
Top 10 Customer10$188,241.2117.1%51$369.104437.6%10%
Medium Value254$814,740.0874%8.4$381.434727.1%17.7%
Low Value183$97,847.438.9%1.9$276.415427%15.8%
No Orders53$0.000%0———15.1%

What each segment buys

SQL
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;
segmentcategorypct_of_segment_revenue
Top 10 CustomerOutdoor38.3%
Top 10 CustomerApparel36.8%
Top 10 CustomerFootwear23.1%
Top 10 CustomerAccessories1.8%
Medium ValueApparel39%
Medium ValueOutdoor38%
Medium ValueFootwear21.3%
Medium ValueAccessories1.7%
Low ValueApparel44.1%
Low ValueOutdoor37.1%
Low ValueFootwear16.8%
Low ValueAccessories1.9%

Share of each segment's revenue by product category.

Key Insights

  • The top 10 customers are in a league of their own. Ten customers (2% of the base) generate 17.1% of revenue. Most of the remaining revenue (74%) comes from the 254 Medium Value customers.
  • Top customers are valuable because they buy often, not because they spend more per order. They average 51 orders, compared with 8.4 for Medium Value and 1.9 for Low Value customers. Their average order value (~$369) is actually a little below the Medium segment's (~$381).
  • Top customers are the most active. The median top-10 customer last ordered about 4 days before the end of the data, compared with about 33 days for Medium Value and about 103 days for Low Value customers.
  • They buy the same things as everyone else. Every segment splits its spending in roughly the same way: 37–38% Outdoor, 37–44% Apparel, 17–23% Footwear, ~2% Accessories. Top customers lean slightly more toward Footwear, but what sets them apart is how often they buy, not what they buy.
  • Corporate status doesn't predict value. Only 1 of the top 10 is a Corporate account, and Corporate accounts make up 16–18% of the other segments.
  • Low Value customers place smaller orders. Their average order is about $276, roughly 28% below everyone else's. Return rates are similar across segments (7.0–7.6%).

Recommendations

  • Protect the top customers. Launch a loyalty or early-access program and treat a rise in days_since_last_order as an early warning that a top customer may be leaving. One customer (Henry Walker) alone is 5.3% of revenue, which is a concentration risk.
  • Increase purchase frequency, not basket size. Medium Value customers already spend as much per order as the top 10. Replenishment reminders and seasonal campaigns could move them up.
  • Activate the 53 customers who never ordered with a first-purchase offer.
  • Look into Footwear returns (9.9%, the highest of any category). Better sizing guidance could pay off quickly.

Tools & Skills

  • SQL
  • DuckDB
  • Joins
  • CTEs
  • Window Functions
  • RANK / ROW_NUMBER / LAG
  • CASE Logic
  • Date Calculations
  • Customer Segmentation