Readineer
PATTERN 6SQL Patterns / Summarising Data

SQL conditional aggregation: calculating multiple metrics from the same data

Learn how to combine CASE with SUM and COUNT, calculate several business metrics in one query, use FILTER where supported, and avoid counting rows that do not actually match your condition.

8 min read13 sections2 predictions
01Foundation

Aggregating different subsets of the same data

In the previous articles, we learned two important ideas. GROUP BY lets us summarise multiple rows, and CASE lets us produce different values depending on a condition. Conditional aggregation combines these ideas: it allows us to calculate an aggregate using only the rows that satisfy a particular condition. Consider the same orders table:

Orders
order_idcustomerproductquantityunit_pricediscount_amountstatus
1001AshaWireless Mouse2750150paid
1002BenMechanical Keyboard13200NULLpending
1003CarlaLaptop Sleeve31200300paid
1004DevOffice Chair18500NULLcancelled
1005AshaWireless Mouse1750NULLpaid
1006BenMonitor Stand21800200paid

Suppose we need a summary showing the total number of orders, the number of paid, pending, and cancelled orders, and the total value of paid orders. One approach would be to write several separate queries, such as SELECT COUNT(*) FROM orders WHERE status = 'paid';, then another for pending, then another for cancelled. This works, but SQL can calculate all of these metrics together. That is where conditional aggregation becomes useful.

02Counting rows

Counting rows with SUM(CASE...)

One common conditional aggregation pattern is SUM(CASE WHEN condition THEN 1 ELSE 0 END). Suppose we want to count paid orders:

Query
SELECT
    SUM(
        CASE
            WHEN status = 'paid' THEN 1
            ELSE 0
        END
    ) AS paid_order_count
FROM orders;
how it works: CASE turns each row into 1 or 0
order_idstatusCASE result
1001paid1
1002pending0
1003paid1
1004cancelled0
1005paid1
1006paid1
1 + 0 + 1 + 0 + 1 + 1 = 4  →  paid_order_count
Key idea
CASE decides whether each row contributes to the aggregate: 1 for matching rows, 0 for non-matching rows. Then SUM produces the count.

Calculating several counts in one query

Once we understand the pattern, we can calculate several metrics from the same rows:

Query
SELECT
    COUNT(*) AS total_orders,
    SUM(CASE WHEN status = 'paid'      THEN 1 ELSE 0 END) AS paid_orders,
    SUM(CASE WHEN status = 'pending'   THEN 1 ELSE 0 END) AS pending_orders,
    SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled_orders
FROM orders;
Result
total_orderspaid_orderspending_orderscancelled_orders
6411

The table is read as one logical input set, while each aggregate applies its own condition. This is one of the main reasons conditional aggregation is useful: we can calculate several related metrics in the same result row.

Conditional SUM: adding values only when a condition matches

Conditional aggregation is not limited to counting rows. Suppose we want the total value of paid orders. The value of each order is quantity * unit_price, and we want that amount to contribute only when status = 'paid':

Query
SELECT
    SUM(
        CASE
            WHEN status = 'paid'
                THEN quantity * unit_price
            ELSE 0
        END
    ) AS paid_order_value
FROM orders;
value contributed per row
order_idstatusorder_amountcontributed
1001paid15001500
1002pending32000
1003paid36003600
1004cancelled85000
1005paid750750
1006paid36003600
1500 + 0 + 3600 + 0 + 750 + 3600 = 9450  →  paid_order_value

The pattern is SUM(CASE WHEN condition THEN value ELSE 0 END): for matching rows, contribute the value; for non-matching rows, contribute zero.

Multiple metrics from the same data

Now we can combine several business metrics in one query, such as a dashboard needing total orders, paid, pending, and cancelled counts, total order value, paid order value, and pending order value:

Query
SELECT
    COUNT(*) AS total_orders,
    SUM(CASE WHEN status = 'paid'      THEN 1 ELSE 0 END) AS paid_orders,
    SUM(CASE WHEN status = 'pending'   THEN 1 ELSE 0 END) AS pending_orders,
    SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled_orders,
    SUM(quantity * unit_price) AS total_order_value,
    SUM(CASE WHEN status = 'paid'
             THEN quantity * unit_price ELSE 0 END) AS paid_order_value,
    SUM(CASE WHEN status = 'pending'
             THEN quantity * unit_price ELSE 0 END) AS pending_order_value
FROM orders;
Result
total_orderspaid_orderspending_orderscancelled_orderstotal_order_valuepaid_order_valuepending_order_value
64112115094503200

One query now produces several metrics describing the same underlying data.

03With GROUP BY

Conditional aggregation with GROUP BY

Conditional aggregation becomes even more useful when combined with GROUP BY. “For each customer, show total orders, paid orders, pending orders, and total paid order value”:

Query
SELECT
    customer,
    COUNT(*) AS total_orders,
    SUM(CASE WHEN status = 'paid'    THEN 1 ELSE 0 END) AS paid_orders,
    SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending_orders,
    SUM(CASE WHEN status = 'paid'
             THEN quantity * unit_price ELSE 0 END) AS paid_order_value
FROM orders
GROUP BY customer
ORDER BY customer;
Result
customertotal_orderspaid_orderspending_orderspaid_order_value
Asha2202250
Ben2113600
Carla1103600
Dev1000

The result grain is still one row per customer, but each row now contains several measures calculated from different subsets of that customer’s orders. For Ben, order 1002 is pending (3200) and 1006 is paid (3600), so total_orders = 2, paid_orders = 1, pending_orders = 1, paid_order_value = 3600.

Why not put the condition in WHERE?

Consider SELECT customer, COUNT(*) AS paid_orders FROM orders WHERE status = 'paid' GROUP BY customer;. This correctly counts paid orders. But the WHERE clause removes every non-paid order before aggregation begins, so we cannot simultaneously calculate paid, pending, and cancelled counts from the same input rows. For example, WHERE status = 'paid' removes Ben’s pending order before any aggregates are calculated. Conditional aggregation keeps the row available; it simply contributes differently to each metric.

Key idea
WHERE decides whether a row participates in the query at all. Conditional aggregation decides whether that participating row contributes to a particular metric.
04The other form

COUNT(CASE WHEN...)

There is another common way to perform conditional counting: COUNT(CASE WHEN condition THEN 1 END). Why does this work? Remember that COUNT(expression) counts non-NULL values. The CASE expression produces paid → 1 and not paid → NULL, because there is no ELSE:

Query
SELECT
    COUNT(
        CASE
            WHEN status = 'paid' THEN 1
        END
    ) AS paid_orders
FROM orders;
CASE result
order_idstatusCASE result
1001paid1
1002pendingNULL
1003paid1
1004cancelledNULL
1005paid1
1006paid1

COUNT counts the four non-NULL results, so the query returns 4.

05The featured failure

The COUNT(CASE...) that counts every row

Suppose someone writes the query below. At first glance, this seems reasonable: for paid orders 1, for everything else 0, so you might expect COUNT to count only the ones. It does not.

PAUSE & PREDICTWhat does the query return?
Query
SELECT
    COUNT(
        CASE
            WHEN status = 'paid' THEN 1
            ELSE 0
        END
    ) AS paid_orders
FROM orders;
Your prediction

The fix

If you want to use COUNT(CASE...), allow non-matching rows to become NULL by dropping the ELSE. You could also explicitly write ELSE NULL, but that is unnecessary because NULL is already the default. Alternatively, use the SUM pattern. Both correctly return 4:

SUM(CASE...)

Match → 1, no match → 0, then add the values.

COUNT(CASE...)

Match → non-NULL, no match → NULL, then count the non-NULL values.

Query
COUNT(CASE WHEN status = 'paid' THEN 1 END)
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END)
Key idea
Do not mix the two mental models.
06Edges

SUM(CASE...) or COUNT(CASE...), and empty sums

For conditional counts, either pattern can be correct. Many teams prefer the SUM form because the 1 and 0 make the counting logic visually explicit. The most important thing is not which style you choose; it is understanding why it works.

SUM without ELSE can return NULL. If we sum CASE WHEN status = 'refunded' THEN quantity * unit_price END and the table contains no refunded orders, the CASE produces NULL for every row, and SUM of all-NULL can be NULL, not 0. If the required metric should be zero when there are no matching rows, provide ELSE 0 so every non-matching row contributes zero.

Conditions can use more than one column

The condition inside CASE can be as specific as the business requirement requires. “Total value of paid orders worth at least 3,000”:

Query
SELECT
    SUM(
        CASE
            WHEN status = 'paid'
             AND quantity * unit_price >= 3000
                THEN quantity * unit_price
            ELSE 0
        END
    ) AS high_value_paid_order_value
FROM orders;

The qualifying orders are 1003 → 3600 and 1006 → 3600, so the result is 7200. Conditional aggregation can use the same logical conditions we already learned with WHERE and CASE.

07A cleaner syntax

Using FILTER

Some SQL databases support the FILTER clause for aggregates. Instead of a nested CASE, you can write:

CASE
SUM(
    CASE
        WHEN status = 'paid'
            THEN quantity * unit_price
        ELSE 0
    END
)
FILTER
SUM(quantity * unit_price)
    FILTER (WHERE status = 'paid')

Similarly, instead of COUNT(CASE WHEN status = 'paid' THEN 1 END), you can write COUNT(*) FILTER (WHERE status = 'paid'). This makes the intent very clear: calculate this aggregate using only rows that satisfy this condition. Using FILTER, our earlier summary becomes:

Query
SELECT
    COUNT(*) AS total_orders,
    COUNT(*) FILTER (WHERE status = 'paid')      AS paid_orders,
    COUNT(*) FILTER (WHERE status = 'pending')   AS pending_orders,
    COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled_orders,
    SUM(quantity * unit_price) FILTER (WHERE status = 'paid') AS paid_order_value
FROM orders;
CASE or FILTER?
FILTER is concise and expressive, but it is not supported uniformly across every SQL database. The CASE approach is more widely portable, so when writing SQL intended to work across several database systems, conditional CASE expressions are often the safer common form. When your database supports FILTER and portability is not a concern, it can make queries with many conditional metrics easier to read. The underlying idea is the same: apply a condition to one aggregate without removing the row from every other aggregate.
08Different shapes

Conditional aggregation is not the same as GROUP BY

Suppose we want one row per status. GROUP BY status creates several result rows. Conditional aggregation solves a different presentation problem, producing several status-specific metrics as columns in the same result row.

GROUP BY status
one row per status
statusorder_count
cancelled1
paid4
pending1
conditional aggregation
one row, several columns
paid_orderspending_orderscancelled_orders
411

Both queries summarise the same data, but their result shapes are different. Use GROUP BY status when you want one row per status; use conditional aggregation when you want several status-specific metrics as columns in the same result row.

09Recognition cues

When conditional aggregation is useful

A dashboard needs several counts in one row, such as paid, pending, and cancelled together.
Different subsets of rows need different sums, or several metrics per customer, product, country, or date.
Using WHERE would remove rows needed by another metric.
You find yourself writing several nearly identical aggregate queries with different filters.

Look for common mistakes such as:

COUNT(CASE ... ELSE 0 END) unexpectedly counts every row.
SUM(CASE...) returns NULL when there are no matching rows.
A global WHERE filter removes rows needed by another conditional metric.
COUNT(column) is used when the requirement is actually to count rows.
Several separate queries calculate metrics that could be produced from the same grouped data.
10A practical mental model

Build a conditional aggregate from four questions

1 · What is the result grain?

One row for the whole dataset (no GROUP BY), or one row per GROUP BY key.

2 · What metric are we calculating?

e.g. paid order count.

3 · Which rows should contribute?

the condition, e.g. status = 'paid'.

4 · What should non-matching rows contribute?

0 for a SUM count; NULL for a COUNT.

Key idea
Once these four questions are clear, the SQL usually follows naturally.
11Check your understanding

One more prediction

Consider these orders. Requirement: return the total number of orders and the number of paid orders in the same result row. Which query is correct?

orders
order_idcustomerstatus
1001Ashapaid
1002Benpending
1003Carlapaid
1004Devcancelled
1005Ashapaid
1006Benpaid
ONE MORE PREDICTIONWhich query is correct?
Query
-- Option A
SELECT COUNT(*) AS total_orders,
    COUNT(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_orders
FROM orders;

-- Option B
SELECT COUNT(*) AS total_orders,
    SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_orders
FROM orders;

-- Option C
SELECT COUNT(*) AS total_orders, COUNT(*) AS paid_orders
FROM orders WHERE status = 'paid';

-- Option D
SELECT status, COUNT(*) AS total_orders
FROM orders GROUP BY status;
Your prediction
12Quick check

Lock these in

What is conditional aggregation?

Applying different conditions to different aggregate calculations over the same set of rows.

How do you conditionally count with SUM?

SUM(CASE WHEN condition THEN 1 ELSE 0 END).

How do you conditionally count with COUNT?

COUNT(CASE WHEN condition THEN 1 END).

Why is ELSE 0 wrong inside COUNT(CASE...)?

Because COUNT counts non-NULL values, and zero is still non-NULL.

Why can conditional SUM use ELSE 0?

Because zero means the non-matching row contributes nothing to the sum.

What does FILTER do?

It applies a condition to an individual aggregate where the SQL database supports that syntax.

Why use conditional aggregation instead of WHERE?

Because WHERE removes a row from the entire query, while conditional aggregation can let the same row contribute to some metrics and not others.

13Summary

Summary

Conditional aggregation combines conditional logic with aggregate functions. For conditional counts, a common pattern is SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END); for conditional sums, SUM(CASE WHEN status = 'paid' THEN quantity * unit_price ELSE 0 END). You can calculate several metrics from the same data, and combine conditional aggregation with GROUP BY. If your database supports FILTER, expressions such as COUNT(*) FILTER (WHERE status = 'paid') can provide a cleaner alternative.

Key idea
The most important COUNT(CASE...) rule is: COUNT counts non-NULL values, not values equal to 1. So COUNT(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) is wrong for conditional counting, because both 1 and 0 are counted. Use COUNT(CASE WHEN status = 'paid' THEN 1 END) or the SUM form instead.

Instead of removing rows globally, decide how each row should contribute to each metric. Next, we will move from summarising one table to combining related data across tables using SQL joins.

Go deeper

Ask Colearn about this pattern

Not sure why a conditional count is off, or whether to use CASE or FILTER? Ask about the concept, or paste a simplified version of your query.

Ask Colearn

3 of 3 free questions left

Instant answers, grounded in the same verified material the diagnostic grades against.

Why does my COUNT(CASE ... ELSE 0 END) count every row?

COUNT counts non-NULL values, not values equal to 1. With ELSE 0, every row produces a non-NULL number (1 or 0), so COUNT counts all of them.

Drop the ELSE so non-matching rows become NULL, or use SUM(CASE WHEN cond THEN 1 ELSE 0 END).

from the unit →
How do I count paid and pending orders in the same result row?

Give each metric its own conditional aggregate: SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid, SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending, all in one SELECT.

The table is read once; each aggregate applies its own condition, so several metrics land in one row.

from the unit →
Why did my SUM(CASE ...) come back NULL instead of 0?

When no row matches and there is no ELSE, every CASE result is NULL; SUM over all-NULL is NULL, not 0.

Add ELSE 0 so non-matching rows contribute zero and the metric reads 0 when nothing matches.

from the unit →
Should I use CASE or FILTER for conditional metrics?

FILTER (e.g. COUNT(*) FILTER (WHERE status = 'paid')) is concise and readable, but not supported by every database.

The SUM/COUNT(CASE ...) form is more portable; prefer it for cross-database SQL, and reach for FILTER when your database supports it and portability isn't a concern.

from the unit →

That’s the free taste

Two ways to go deeper.

Don’t paste credentials, personal data, or confidential production records.

Related patterns

P05Pattern · 7 minConditional logic & CASEThe CASE expression that powers every conditional aggregate.P04Pattern · 8 minAggregation & GROUP BYGrouping and the aggregates that conditional metrics build on.P02Pattern · 8 minNULL & UNKNOWNWhy COUNT counts non-NULL values, the crux of COUNT(CASE ...).
Up next · Pattern 7
Ready to keep going?

Next, we move from summarising one table to combining related data across tables with SQL joins.

Continue to P7 →Browse all patterns