Skip to content

Beginner: asking questions · Aggregation: GROUP BY and HAVING · topic 19 of 62 · 35 min

Conditional aggregation and string aggregation

Explanation

Put a CASE inside SUM or COUNT to count only some rows, and you can turn values into columns: one row per category with an in-stock count and an out-of-stock count, or one row with a column per order status. PostgreSQL and SQLite also offer FILTER (WHERE ...). GROUP_CONCAT (SQLite, MySQL) and STRING_AGG (PostgreSQL, SQL Server) join a group's text into one value.

Why conditional aggregation?

GROUP BY status gives one row per status, going down the page. Managers often want the same numbers across the page instead, as columns: Placed 2, Shipped 1, Delivered 4, Cancelled 1, all in one row. Turning row values into columns like this is called a pivot, and in plain SQL it is done with a CASE inside an aggregate.

SUM(CASE WHEN ...)

CASE WHEN status = 'Delivered' THEN 1 ELSE 0 END gives 1 for delivered rows and 0 for the rest. SUM of those ones and zeros is the number of delivered rows. One such SUM per status makes one column per status.

sql
SELECT SUM(CASE WHEN status = 'Placed'    THEN 1 ELSE 0 END) AS placed,
       SUM(CASE WHEN status = 'Shipped'   THEN 1 ELSE 0 END) AS shipped,
       SUM(CASE WHEN status = 'Delivered' THEN 1 ELSE 0 END) AS delivered,
       SUM(CASE WHEN status = 'Cancelled' THEN 1 ELSE 0 END) AS cancelled
FROM orders;
-- 2 | 1 | 4 | 1

Add GROUP BY and you get a pivot per group. Here, for each category, how many products are in stock and how many are sold out:

sql
SELECT category,
       SUM(CASE WHEN stock > 0 THEN 1 ELSE 0 END) AS in_stock,
       SUM(CASE WHEN stock = 0 THEN 1 ELSE 0 END) AS out_of_stock
FROM products
GROUP BY category;
-- Electronics | 1 | 1
-- Fashion     | 2 | 0
-- Grocery     | 2 | 0
-- Kitchen     | 2 | 0

Two other forms do the same job. COUNT(CASE WHEN cond THEN 1 END) works because CASE gives NULL when nothing matches and COUNT skips NULLs. And SUM(CASE WHEN cond THEN amount ELSE 0 END) adds up an amount for the matching rows only, for example delivered revenue next to total revenue.

FILTER: a cleaner way

sql
SELECT city,
       COUNT(*) AS students,
       COUNT(*) FILTER (WHERE fees_paid = 1) AS paid
FROM students
GROUP BY city;
-- Chennai | 1 | 1
-- Delhi   | 2 | 1
-- Pune    | 3 | 2   ... 5 rows
FILTER (WHERE ...) after an aggregate limits the rows that aggregate sees. It is standard SQL, supported by PostgreSQL 9.4+ and SQLite 3.30+, but not by MySQL or SQL Server, where you use the CASE form.

Percentages

A percentage is part × 100.0 / whole, worked out per group. Multiply by 100.0 first so integer division does not cut it to 0 (module 3), then round.

sql
SELECT city, ROUND(100.0 * SUM(fees_paid) / COUNT(*), 1) AS paid_pct
FROM students GROUP BY city;
-- Delhi | 50
-- Pune  | 66.7   ... (fees_paid is 1 or 0, so SUM counts the payers)

Joining text inside a group

Sometimes you want the members of a group listed in one cell: the products in each category, the team in each department. GROUP_CONCAT(x, separator) in SQLite and MySQL joins the values of a group into one text. The default separator is a comma.

sql
SELECT category, GROUP_CONCAT(name, ', ' ORDER BY name) AS products
FROM products
GROUP BY category;
-- Electronics | Bluetooth Earbuds, Power Bank 10000mAh
-- Kitchen     | Pressure Cooker 3L, Steel Tiffin Box   ... 4 rows
ORDER BY inside the aggregate (SQLite 3.44+) fixes the order of the names. Without it, the order is whatever the database met first and is not guaranteed.
DatabaseComma-separated names, sorted
SQLite 3.44+GROUP_CONCAT(name, ', ' ORDER BY name) (or string_agg)
MySQLGROUP_CONCAT(name ORDER BY name SEPARATOR ', ')
PostgreSQLSTRING_AGG(name, ', ' ORDER BY name)
SQL Server 2017+STRING_AGG(name, ', ') WITHIN GROUP (ORDER BY name)
Careful: MySQL cuts GROUP_CONCAT results at 1024 characters by default (the group_concat_max_len setting) without an error. Long lists come back silently truncated; raise the setting if you need more.

Common mistakes

  • Forgetting ELSE 0 in SUM(CASE ...): a group with no matches then shows NULL instead of 0. Use ELSE 0, or COUNT(CASE ... THEN 1 END), which gives 0.
  • Writing COUNT(CASE WHEN cond THEN 1 ELSE 0 END): COUNT counts the zeros too, so you get the row count. With COUNT, leave out the ELSE.
  • Integer percentages: SUM(x) * 100 / COUNT(*) drops the fraction. Use 100.0.
  • Relying on the order inside GROUP_CONCAT without ORDER BY. Add ORDER BY inside the aggregate.
  • Hard-coding a pivot column for each status and forgetting a new status added later. Check with a plain GROUP BY status that the columns add up to the total.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Conditional aggregation and string aggregation · 1/7

Conditional aggregation

GROUP BY gives numbers going down the page. Managers often want them across the page, as columns. That is a pivot.

Prefer a video by a teacher? Find free videos on “sql conditional aggregation sum case when pivot group_concat string_agg” on YouTube ↗

In real life: An order status board

Operations teams at delivery and e-commerce companies watch a status board: per city or warehouse, how many orders are placed, shipped, delivered and cancelled. Each column is a conditional count, and a total column next to them lets anyone check that the parts add up.

sql
SELECT customer_id,
       COUNT(*) AS total,
       SUM(CASE WHEN status = 'Delivered' THEN 1 ELSE 0 END) AS delivered,
       SUM(CASE WHEN status = 'Cancelled' THEN 1 ELSE 0 END) AS cancelled
FROM orders
GROUP BY customer_id;
Customer 1: 3 orders, 2 delivered, 0 cancelled. Customer 3: 1 order, 0 delivered, 1 cancelled.

Practice (0/4 done)

Task 1 · Code · SQL

For each category, return category, the number of products with stock above 0 named in_stock, and the number with stock 0 named out_of_stock.

Task 2 · Code · SQL

Make a one-row status board from orders with four columns in this order: placed, shipped, delivered, cancelled (the number of orders with each status).

Task 3 · Code · SQL

For each category, return category and the names of its products joined with ', ' in alphabetical order, named products. Use GROUP_CONCAT with ORDER BY inside it.

Task 4 · Code · SQL

For each city, return city, the number of students named students, the number who paid fees (fees_paid = 1) named paid, and the percentage who paid rounded to 1 decimal place named paid_pct.

Quick quiz

Question 1

1.What does SUM(CASE WHEN status = 'Placed' THEN 1 ELSE 0 END) give?

Question 2

2.Why is COUNT(CASE WHEN stock = 0 THEN 1 ELSE 0 END) wrong for counting sold-out products?

Question 3

3.Which database does NOT support the FILTER (WHERE ...) clause on aggregates?

Question 4

4.What is the PostgreSQL way to list names per group, separated by commas, in order?

Question 5

5.GROUP_CONCAT(name) without ORDER BY: in what order are the names?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

What does SUM(CASE WHEN status = 'Placed' THEN 1 ELSE 0 END) give?

Answer: The number of rows with status Placed

Matching rows add 1, the others add 0.

Why is COUNT(CASE WHEN stock = 0 THEN 1 ELSE 0 END) wrong for counting sold-out products?

Answer: COUNT counts the 0s too, because 0 is not NULL, so it returns the number of all rows

With COUNT, leave out ELSE so non-matches become NULL and are skipped.

Which database does NOT support the FILTER (WHERE ...) clause on aggregates?

Answer: MySQL

MySQL and SQL Server need SUM(CASE ...) instead.

What is the PostgreSQL way to list names per group, separated by commas, in order?

Answer: STRING_AGG(name, ', ' ORDER BY name)

PostgreSQL uses STRING_AGG with ORDER BY inside the brackets.

GROUP_CONCAT(name) without ORDER BY: in what order are the names?

Answer: Not guaranteed; add ORDER BY inside the aggregate

Only ORDER BY inside the aggregate fixes the order.

You write SELECT 100.0 * COUNT(*) / COUNT(*) FROM orders WHERE status = 'Delivered' to get the delivered share. What is wrong?Related

Answer: WHERE leaves only delivered rows, so the answer is always 100

Count the part with CASE and the whole with COUNT(*), without filtering the whole away.

From Writing reports with aggregation

Why does PostgreSQL reject SELECT city, name, SUM(amount) FROM t GROUP BY city?Related

Answer: name is neither grouped nor aggregated, so one city has many possible names

Each output row is one group. A column must be the same for the whole group (grouped) or combined from it (aggregated).

From GROUP BY and HAVING

A table has 10 rows; the email column is NULL in 3 of them. What is COUNT(email)?Related

Answer: 7

COUNT(column) counts only rows where the column has a value: 10 - 3 = 7.

From Counting and totals: aggregate functions

orders JOIN order_items: what does one row of the result stand for?Related

Answer: One order line

The grain becomes the more detailed table, order_items.

From Joining three or more tables

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. 1 more topic in “Aggregation: GROUP BY and HAVING” before its exam: Writing reports with aggregation.
  2. Module 4 exam: Aggregation, GROUP BY and HAVING · 15 questions · 25 min, without help. 5 topics of this module still to finish first.
  3. Then the next module, JOINs. 62 topics left in the course before the final exam.

← Previous: HAVING versus WHERE