More SQL Please: CASE

pop out tables

products

| id | name | category | price | remaining_stock | vendor_id | |----|--------------------------|------------|-------|-----------------|-----------| | 1 | Cool Splash Cologne | Toiletries | 49.99 | 25 | 2 | | 2 | More Spice Please Chips | Food | 3.99 | 60 | 1 | | 3 | Night Shift Coffee | Food | 6.99 | 1 | 1 | | 4 | Crunchy Munchy Trail Mix | Food | 8.99 | 5 | 3 | | 5 | Super Glide Floss | Toiletries | 1.99 | 19 | 4 | | 6 | Big Bark Dog Food | Pet Care | 30 | 2 | 2 | | 7 | Soft Purr Cat Food | Pet Care | 30 | 2 | 2 | | 8 | Tiny Fresh Scent Tree | Automotive | 2 | 7 | 4 |

vendors

| id | name | |----|-------------------------| | 1 | Quicky Express Food Co | | 2 | Good Times Distributors | | 3 | Natural Goodness | | 4 | Hyper Logistics | | 5 | Vend-tertainment |

orders

| id | product_id | quantity | date_ordered | date_arrived | |----|------------|----------|---------------------|---------------------| | 1 | 4 | 40 | 2024-06-01 07:58:05 | 2024-06-03 09:04:05 | | 2 | 1 | 20 | 2024-06-14 19:30:10 | 2024-06-26 14:20:10 | | 3 | 4 | 40 | 2024-07-01 09:01:05 | 2024-07-13 09:01:05 | | 4 | 5 | 50 | 2024-07-03 01:00:07 | 2024-07-09 12:50:12 | | 5 | 2 | 60 | 2024-07-24 06:06:03 | 2024-07-30 15:05:00 | | 6 | 3 | 60 | 2024-08-15 16:40:07 | 2024-08-19 07:05:00 | | 7 | 6 | 10 | 2024-09-01 08:01:01 | NULL | | 8 | 7 | 10 | 2024-09-01 08:05:09 | NULL |

The CASE expression allows you to add conditional logic to your queries. It is similar to the if ... else or switch expressions in other programming languages.

Most database engines allow a CASE expression to return any data type (text, numbers, etc.) but each WHEN "branch" of the statement must return the same type as each other. SQLite allows any type to be returned but for compatibility, these examples will return the same type from all branches.

This example shows a common use case where we want to create custom groups or "buckets" for the data that are not part of the database schema.

SELECT name,
    CASE
        WHEN price < 5.00 THEN 'Budget'
        WHEN price < 40.00 THEN 'Standard'
        ELSE 'Luxury'
    END AS category
FROM products;
| name | category | |--------------------------|----------| | Cool Splash Cologne | Luxury | | More Spice Please Chips | Budget | | Night Shift Coffee | Standard | | Crunchy Munchy Trail Mix | Standard | | Super Glide Floss | Budget | | Big Bark Dog Food | Standard | | Soft Purr Cat Food | Standard | | Tiny Fresh Scent Tree | Budget |

The conditions inside a CASE statement can be compounded using logical operators like AND and OR.

Another thing to note about the CASE statement is that it is evaluated in order from top to bottom, left to right. For each row it will return a result for only the first match it encounters. If there are no matches and you supply an ELSE expression, that value will be the result. If you don't supply an ELSE, NULL is returned.

Notice how the "Night Shift Coffee" product could technically match all of the "Critical Restock", "Urgent Restock" and "Restock Soon" categories. However once it matches "Critical Restock" the rest of the CASE is not evaluated and we move on to the next product. You can utilize this behaviour or you can design your queries such that none of the branches in your CASE overlap.

SELECT name,
    remaining_stock,
    CASE
        WHEN remaining_stock < 5
            THEN 'Critical Restock'
        WHEN remaining_stock <= 10 AND category = 'Food'
            THEN 'Urgent Restock'
        WHEN remaining_stock <= 20
            THEN 'Restock Soon'
        ELSE 'No Restock Needed'
    END
AS needs_restock
FROM products
ORDER BY needs_restock;
| name | remaining_stock | needs_restock | |--------------------------|-----------------|-------------------| | Night Shift Coffee | 1 | Critical Restock | | Big Bark Dog Food | 2 | Critical Restock | | Soft Purr Cat Food | 2 | Critical Restock | | Cool Splash Cologne | 25 | No Restock Needed | | More Spice Please Chips | 60 | No Restock Needed | | Super Glide Floss | 19 | Restock Soon | | Tiny Fresh Scent Tree | 7 | Restock Soon | | Crunchy Munchy Trail Mix | 5 | Urgent Restock |

The previous query is ordered alphabetically because our newly computed column needs_restock has a text data type. If we wanted to apply a custom order to the query we can also apply a CASE statement to our ORDER BY clause.

Notice that this example also shows that you can add an expression directly after the CASE keyword; this is called a "base" expression. In this case it is needs_restock. When a base expression is present it is evaluated once at the beginning. For each row, the value of needs_restock determines which integer is returned.

As an aside, a better way to structure this query would be to use Common Table Expressions to set up our results and ordering rules. An example of this can be seen at the bottom of the page.

SELECT name,
    remaining_stock,
    CASE
        WHEN remaining_stock < 5 
            THEN 'Critical Restock'
        WHEN remaining_stock <= 10 AND category = 'Food'
            THEN 'Urgent Restock'
        WHEN remaining_stock <= 20
            THEN 'Restock Soon'
        ELSE 'No Restock Needed'
    END
AS needs_restock
FROM products
ORDER BY 
    CASE needs_restock
        WHEN 'Critical Restock' THEN 1
        WHEN 'Urgent Restock' THEN 2
        WHEN 'Restock Soon' THEN 3
        WHEN 'No Restock Needed' THEN 4
    END
ASC;
| name | remaining_stock | needs_restock | |--------------------------|-----------------|-------------------| | Night Shift Coffee | 1 | Critical Restock | | Big Bark Dog Food | 2 | Critical Restock | | Soft Purr Cat Food | 2 | Critical Restock | | Crunchy Munchy Trail Mix | 5 | Urgent Restock | | Super Glide Floss | 19 | Restock Soon | | Tiny Fresh Scent Tree | 7 | Restock Soon | | Cool Splash Cologne | 25 | No Restock Needed | | More Spice Please Chips | 60 | No Restock Needed |

CASE can be used to check for NULL and display a more human-readable value.

In simple cases like this, COALESCE() performs the same function.

SELECT CASE
    WHEN date_arrived IS NULL
        THEN 'Pending'
    ELSE 'Arrived: ' || date_arrived
END AS arrival_status
FROM orders;
| arrival_status | |------------------------------| | Arrived: 2024-06-03 09:04:05 | | Arrived: 2024-06-26 14:20:10 | | Arrived: 2024-07-13 09:01:05 | | Arrived: 2024-07-09 12:50:12 | | Arrived: 2024-07-30 15:05:00 | | Arrived: 2024-08-19 07:05:00 | | Pending | | Pending |

CASE expressions can be used as part of aggregate queries using GROUP BY and aggregate functions like avg().

SELECT avg(quantity),
    CASE
        WHEN quantity <= 10 THEN 'Small'
        WHEN quantity <= 50 THEN 'Medium'
        WHEN quantity > 50 THEN 'Large'
    END AS order_size
FROM orders
GROUP BY order_size;
| avg(quantity) | order_size | |---------------|------------| | 60 | Large | | 37.5 | Medium | | 10 | Small |

CASE can also be used as an input for aggregate functions. This technique is useful when you need to create "buckets" of data as above but group the data using another separate category. If you are familiar with spreadsheets, this technique is similar to that of a pivot table .

This example shows us how many of each price class of product each vendor has for sale.

Notice how in this example we use a LEFT JOIN which will include vendors that have no products for sale. The vendor "Vend-tertainment" will return a NULL value for p.price.

This causes our CASE expressions to fall back to their ELSE return value of 0.

SELECT 
    v.name AS vendor,
    SUM(CASE
        WHEN p.price < 5.00 THEN 1
        ELSE 0
    END) AS budget,
    SUM(CASE
        WHEN p.price < 40.00 THEN 1
        ELSE 0
    END) AS standard,
    SUM(CASE
        WHEN p.price >= 40.00 THEN 1
        ELSE 0
    END) AS luxury
FROM vendors v
LEFT JOIN products p
ON v.id = p.vendor_id
GROUP BY v.id;
| vendor | budget | standard | luxury | |-------------------------|--------|----------|--------| | Quicky Express Food Co | 1 | 2 | 0 | | Good Times Distributors | 0 | 2 | 1 | | Natural Goodness | 0 | 1 | 0 | | Hyper Logistics | 2 | 2 | 0 | | Vend-tertainment | 0 | 0 | 0 |

Here is the example above that is restructured using Common Table Expressions and UNION to be easier to maintain and understand. Instead of using the order status labels as our column names to match on, we use the integer id we created which reduces the likelihood of human error by misspelling one of the labels somewhere.

When you need to re-use the same statements in multiple places in your query consider techniques like this that limit the amount of duplicate code you need to write.

WITH stock_statuses AS (
    SELECT name,
        remaining_stock,
        CASE
            WHEN remaining_stock < 5
                THEN 1
            WHEN remaining_stock <= 10 AND category = 'Food'
                THEN 2
            WHEN remaining_stock <= 20
                THEN 3
            ELSE 4
        END
    AS id
    FROM products
),
status_labels AS (
    SELECT 1 AS status_id, 
    'Critical Restock' AS label
    UNION SELECT 2, 'Urgent Restock'
    UNION SELECT 3, 'Restock Soon'
    UNION SELECT 4, 'No Restock Needed'
)
SELECT ss.name, 
    ss.remaining_stock, 
    sl.label AS needs_restock
FROM stock_statuses ss
INNER JOIN status_labels sl
    ON ss.id = sl.status_id
ORDER BY ss.id;
| name | remaining_stock | needs_restock | |--------------------------|-----------------|-------------------| | Night Shift Coffee | 1 | Critical Restock | | Big Bark Dog Food | 2 | Critical Restock | | Soft Purr Cat Food | 2 | Critical Restock | | Crunchy Munchy Trail Mix | 5 | Urgent Restock | | Super Glide Floss | 19 | Restock Soon | | Tiny Fresh Scent Tree | 7 | Restock Soon | | Cool Splash Cologne | 25 | No Restock Needed | | More Spice Please Chips | 60 | No Restock Needed |
previous: Common Table Expressions home