Readineer
PATTERN 4SQL Patterns / Summarising Data

SQL GROUP BY and aggregations: summarising data correctly

Learn how to summarise rows with COUNT, SUM, AVG, MIN, and MAX, create groups with GROUP BY, filter aggregated results with HAVING, and avoid grouping at the wrong level.

8 min read10 sections1 prediction
01Foundation

From individual rows to summaries

So far, most of our queries have returned information about individual rows. Consider the following orders table:

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

A query such as SELECT order_id, customer, quantity * unit_price AS order_amount FROM orders; returns one result row for each order. But many business questions are not about individual orders. We may want to know: how many orders were placed? What was the total order value? The average order value? How many orders did each customer place? These questions require us to summarise multiple rows.

SQL provides aggregate functions for this purpose. The most commonly used are COUNT, SUM, AVG, MIN, and MAX. When combined with GROUP BY, they allow us to calculate summaries for different groups of rows.

02Aggregate functions

COUNT: how many rows are there?

COUNT is used when we want to know how many rows meet a requirement. To count all orders, SELECT COUNT(*) AS order_count FROM orders; returns 6, because there are six rows in the table.

COUNT(*) and COUNT(column) are different

Query
SELECT
    COUNT(*) AS total_orders,
    COUNT(discount_amount) AS orders_with_discount_amount
FROM orders;
Result
total_ordersorders_with_discount_amount
63

COUNT(*) counts every row. But COUNT(discount_amount) counts only rows where discount_amount is not NULL. Our values are 150, NULL, 300, NULL, NULL, 200, so only three rows contain a known discount amount.

COUNT(*)       → counts rows
COUNT(column)  → counts non-NULL values

This distinction becomes important when nullable columns are involved.

Counting distinct values

Sometimes we want to count unique values rather than rows. SELECT COUNT(DISTINCT customer) AS customer_count FROM orders; returns 4: the table contains six orders, but only four different customers (Asha, Ben, Carla, Dev).

SUM: adding values together

SUM calculates the total of a numeric expression. To get the total value of all orders, SELECT SUM(quantity * unit_price) AS total_order_value FROM orders; returns 21150. SQL calculates the amount for each order and then adds the results together: 1500 + 3200 + 3600 + 8500 + 750 + 3600 = 21150.

AVG: calculating the average

AVG calculates the arithmetic mean. SELECT AVG(quantity * unit_price) AS average_order_value FROM orders; returns 3525, conceptually 21150 / 6 = 3525.

AVG ignores NULL values. Over the discount_amount column (150, NULL, 300, NULL, NULL, 200), AVG(discount_amount) does not calculate (150 + 0 + 300 + 0 + 0 + 200) / 6. Instead it calculates (150 + 300 + 200) / 3 ≈ 216.67. A missing value is not automatically treated as zero. If the business explicitly says missing discount amounts should count as zero, you would need to express that rule with AVG(COALESCE(discount_amount, 0)), which is a different calculation because the business meaning has changed.

MIN and MAX: smallest and largest values

Query
SELECT
    MIN(quantity * unit_price) AS smallest_order,
    MAX(quantity * unit_price) AS largest_order
FROM orders;
Result
smallest_orderlargest_order
7508500

These return the values themselves. They do not automatically tell us which orders contain those values; finding the complete row associated with a minimum or maximum is a different problem for later.

Aggregation without GROUP BY

When an aggregate function is used without GROUP BY, SQL summarises all qualifying rows into one group.

Query
SELECT
    COUNT(*) AS order_count,
    SUM(quantity * unit_price) AS total_order_value,
    AVG(quantity * unit_price) AS average_order_value,
    MIN(quantity * unit_price) AS smallest_order,
    MAX(quantity * unit_price) AS largest_order
FROM orders;
Result
order_counttotal_order_valueaverage_order_valuesmallest_orderlargest_order
62115035257508500

Six input rows have become one summary row. But what if we need one summary for each customer? That is where GROUP BY becomes useful.

03Creating groups

GROUP BY: one summary per group

Consider this requirement: how many orders has each customer placed? SQL can group rows that have the same customer, then COUNT(*) can count the rows inside each group.

Query
SELECT
    customer,
    COUNT(*) AS order_count
FROM orders
GROUP BY customer
ORDER BY customer;
Result
customerorder_count
Asha2
Ben2
Carla1
Dev1

The important change is the grain of the result. Before grouping, one row represents one order. After GROUP BY customer, one row represents one customer. This is the central idea behind GROUP BY.

Calculating several metrics per group

Once rows are grouped, we can calculate several aggregate values for each group. For each customer, show the number of orders, total order value, and average order value:

Query
SELECT
    customer,
    COUNT(*) AS order_count,
    SUM(quantity * unit_price) AS total_order_value,
    AVG(quantity * unit_price) AS average_order_value
FROM orders
GROUP BY customer
ORDER BY customer;
Result
customerorder_counttotal_order_valueaverage_order_value
Asha222501125
Ben268003400
Carla136003600
Dev185008500

SQL creates one group for each customer, then calculates each aggregate independently within that group. For Asha, orders 1001 = 1500 and 1005 = 750 give COUNT = 2, SUM = 2250, AVG = 1125.

Grouping by another column

We can change the question simply by changing the grouping column. “How many orders and how much order value for each status?”

Query
SELECT
    status,
    COUNT(*) AS order_count,
    SUM(quantity * unit_price) AS total_order_value
FROM orders
GROUP BY status
ORDER BY status;
Result
statusorder_counttotal_order_value
cancelled18500
paid49450
pending13200

The input data has not changed. Only the required summary has changed. This time the result grain is one row per status.

Grouping by multiple columns

GROUP BY can contain more than one column. “How many orders does each customer have in each status?”

Query
SELECT
    customer,
    status,
    COUNT(*) AS order_count
FROM orders
GROUP BY
    customer,
    status
ORDER BY
    customer,
    status;
Result
customerstatusorder_count
Ashapaid2
Benpaid1
Benpending1
Carlapaid1
Devcancelled1

Now one result row represents one customer-and-status combination. For Ben, Ben | pending and Ben | paid are separate groups.

04The mental model

Decide the result grain first

Before writing GROUP BY, complete this sentence: one result row should represent __________. GROUP BY customer is one row per customer; GROUP BY status is one row per status; GROUP BY customer, status is one row per customer and status. These are three different result grains.

Key idea
A common mistake is to add columns to GROUP BY simply because SQL requires them for the selected output. Instead, first ask whether that column actually belongs in the definition of a group. If adding the column changes “one row per customer” into “one row per customer and status,” then you have changed the question being answered.

Why selected columns usually need to be grouped or aggregated

Consider SELECT customer, status, SUM(quantity * unit_price) FROM orders GROUP BY customer;. What should SQL return for Ben’s status? Ben has both pending and paid. There is only one result row for Ben, but two possible status values, and SQL cannot choose one without another rule.

That is why, in SQL, selected expressions in a grouped query generally need to be either part of the grouping key, or reduced to one value using an aggregate function. In SELECT customer, COUNT(*), SUM(...) FROM orders GROUP BY customer;, customer defines each group, and the other selected expressions produce one value for each group.

05Filtering

WHERE filters rows; HAVING filters groups

Suppose the requirement is “show total paid order value for each customer.” The word paid describes which individual orders should participate, so filter those rows first with WHERE:

Query
SELECT
    customer,
    SUM(quantity * unit_price) AS paid_order_value
FROM orders
WHERE status = 'paid'
GROUP BY customer
ORDER BY customer;
Result
customerpaid_order_value
Asha2250
Ben3600
Carla3600

Dev does not appear because his order is cancelled. Ben’s pending order is also excluded before the customer groups are summarised. A useful mental sequence is: filter rows, then group rows, then calculate aggregates. This is where WHERE belongs.

Filtering aggregated results with HAVING

Now consider a different requirement: “return customers whose total order value is greater than 5,000.” We cannot know whether a customer qualifies until their orders have been grouped and summed (Asha = 2250, Ben = 6800, Carla = 3600, Dev = 8500). The condition applies to the aggregate result, not to an individual order. This is what HAVING is for.

Query
SELECT
    customer,
    SUM(quantity * unit_price) AS total_order_value
FROM orders
GROUP BY customer
HAVING SUM(quantity * unit_price) > 5000
ORDER BY customer;
Result
customertotal_order_value
Ben6800
Dev8500

WHERE and HAVING solve different problems

WHERE status = 'paid' asks “should this order participate?” HAVING SUM(quantity * unit_price) > 5000 asks “should this completed customer group appear in the result?” You can also use both. “Among paid orders, return customers whose total paid order value exceeds 3,000”:

Query
SELECT
    customer,
    SUM(quantity * unit_price) AS paid_order_value
FROM orders
WHERE status = 'paid'
GROUP BY customer
HAVING SUM(quantity * unit_price) > 3000
ORDER BY customer;
Result
customerpaid_order_value
Ben3600
Carla3600

Conceptually: WHERE keeps paid orders, GROUP BY creates one group per customer, SUM calculates each customer’s paid order value, and HAVING keeps groups above 3000.

WHERE cannot filter on an aggregate result. WHERE SUM(quantity * unit_price) > 5000 is not the correct place for the aggregate condition, because WHERE operates on rows before the aggregation has produced the SUM. Use HAVING SUM(quantity * unit_price) > 5000 instead. The rule: row-level condition → WHERE; group-level condition → HAVING.
06Recognition cues

How to spot grouping problems

Look for these signs when reviewing grouped queries:

The requirement says “per customer,” “per product,” “per status,” or “per day.”
The query returns more rows per entity than expected.
An extra column has been added to GROUP BY and suddenly creates more groups.
A selected column has multiple possible values inside one intended group.
COUNT(*) and COUNT(column) return different values.
An average seems unexpectedly high because NULL values were ignored.
A condition on SUM, COUNT, or AVG has been placed in WHERE.
HAVING is being used for a simple row-level filter that belongs in WHERE.
The business question asks for one summary row, but the query still returns individual records.

When something looks wrong, ask first: what does one result row represent? That question often reveals the problem immediately.

07A practical mental model

Work through four questions

1 · Which rows should participate?

Use WHERE when necessary, e.g. WHERE status = 'paid'.

2 · What should one result row represent?

Define the grain with GROUP BY customer.

3 · What should be calculated per group?

Use aggregate functions: COUNT(*), SUM(...), AVG(...).

4 · Which completed groups should remain?

Use HAVING SUM(...) > 3000.

Query
SELECT
    customer,
    COUNT(*) AS paid_order_count,
    SUM(quantity * unit_price) AS paid_order_value
FROM orders
WHERE status = 'paid'
GROUP BY customer
HAVING SUM(quantity * unit_price) > 3000
ORDER BY customer;
Key idea
This structure follows the business question instead of starting from SQL keywords.
08Check your understanding

One more prediction

Consider the following orders:

orders
order_idcustomerstatusorder_amount
1001Ashapaid1500
1002Benpending3200
1003Carlapaid3600
1004Devcancelled8500
1005Ashapaid750
1006Benpaid3600

Requirement: among paid orders, return customers whose total paid order value is greater than 3,000. Which query correctly expresses the requirement?

ONE MORE PREDICTIONWhich query correctly expresses the requirement?
Query
-- Option A
WHERE status = 'paid'
  AND SUM(order_amount) > 3000
GROUP BY customer

-- Option B
GROUP BY customer
HAVING SUM(order_amount) > 3000

-- Option C
WHERE status = 'paid'
GROUP BY customer
HAVING SUM(order_amount) > 3000

-- Option D
GROUP BY customer, status
Your prediction
09Quick check

Lock these in

What does COUNT(*) count?

Rows.

What does COUNT(column) count?

Non-NULL values in that column.

What does SUM do?

Adds numeric values within the current set or group.

Does AVG treat NULL as zero?

No. NULL values are normally ignored.

What determines one row per group?

The columns or expressions in GROUP BY.

What does WHERE filter?

Individual rows before grouping.

What does HAVING filter?

Groups after aggregation.

What is the most important question before writing GROUP BY?

What should one result row represent?

10Summary

Summary

Aggregate functions summarise multiple rows. Use COUNT(*) to count rows, SUM(amount) to calculate totals, AVG(amount) to calculate averages, and MIN(amount) / MAX(amount) to find the smallest and largest values. Without GROUP BY, an aggregate query summarises all qualifying rows into one result group. With GROUP BY customer, the result becomes one row per customer; with GROUP BY customer, status, the grain changes to one row per customer and status combination. That distinction is critical. Use WHERE for conditions on individual rows and HAVING for conditions on aggregated groups.

Key idea
Decide what one result row should represent before writing the GROUP BY clause. Adding another column to GROUP BY does not simply provide more detail; it can change the grain of the result and make the query answer a different business question. Correct aggregation starts by defining the level at which the answer should exist.

Next, we will learn how to express business rules and create different values based on conditions using CASE WHEN.

Go deeper

Ask Colearn about this pattern

Not sure why your grouped query returns too many rows, or why a total looks off? 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 is my COUNT lower than the number of rows?

Counting a specific column skips the rows where that column is blank — it counts only the non-missing values. Counting the rows themselves counts every row regardless.

For a row total, count the rows (COUNT(*)); count a column only when you specifically want its non-blank values.

from the unit →
Why does my SUM return NULL instead of 0?

A sum over no rows, or a group whose values are all missing, returns NULL — not zero. Downstream math on that NULL then spreads the emptiness.

Wrap the sum so it falls back to 0 when there is nothing to add.

from the unit →
Should my filter go in WHERE or HAVING?

WHERE runs before grouping, so it filters individual rows. HAVING runs after, so it filters whole groups by their totals.

A test on a single row belongs in WHERE; a test on a group's total (like “more than 10 orders”) belongs in HAVING.

from the unit →
Why does my average look off across groups?

If you average each group's average, every group counts equally no matter how many rows it has — so a tiny group sways the result as much as a huge one.

For the true overall average, compute it from the underlying totals, not from the per-group averages.

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

P02Pattern · 8 minNULL & UNKNOWNWhy COUNT and AVG skip missing values, and how NULL changes a total.P05Pattern · 7 minConditional logic & CASETurn business rules into categories, then fold them into your aggregates.P06Pattern · 7 minConditional aggregationFold CASE into SUM and COUNT to compute several metrics in one query.
Up next · Pattern 5
Conditional logic & CASE

Next, we express business rules and create different values based on conditions with CASE WHEN.

Continue to P5 →Browse all patterns