Practice16problems on this pattern, easy to hardStart practising →
Pattern 4SQL course / 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 prediction16 practice problems
01

From Rows to Summaries

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

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

A query such as this one returns one result row for each order:

Query
SELECT
order_id,
customer,
quantity * unit_price AS order_amount
FROM orders;

But many requirements 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.

02

Understanding Aggregate 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:

Query
SELECT COUNT(*) AS order_count
FROM orders;

This 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;
Result1 row
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:

Query
SELECT COUNT(DISTINCT customer) AS customer_count
FROM orders;

This 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:

Query
SELECT SUM(quantity * unit_price) AS total_order_value
FROM orders;

This 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:

Query
SELECT AVG(quantity * unit_price) AS average_order_value
FROM orders;

This returns 3525, conceptually 21150 / 6 = 3525.

Tip
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;
Result1 row
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;
Result1 row
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.

03

Grouping Rows with GROUP BY

Consider this requirement:

RequirementReturn the number of orders each customer has placed.

The orders table stores one row for every order. A customer who places three orders appears in three separate rows. But our question is not about individual orders. We want one row per customer, showing how many orders they placed. GROUP BY makes this possible. It groups rows that share the same value, allowing aggregate functions such as COUNT(*) to calculate a result for each group.

Step 1: Understand how rows are grouped

Suppose our orders table contains six orders from four customers. When we write GROUP BY customer, SQL conceptually places every order into a group based on its customer value.

The grouping decision6 orders
order_idcustomerproductquantityunit_pricediscount_amountstatus
1001AshaWireless Mouse2750150paid
1002BenMechanical Keyboard13200NULLpending
1003CarlaLaptop Sleeve31200300paid
1004DevOffice Chair18500NULLcancelled
1005AshaWireless Mouse1750NULLpaid
1006BenMonitor Stand21800200paid

Notice that orders 1001 and 1005 belong to the same group because both have customer = 'Asha'. The same happens for Ben’s two orders, 1002 and 1006. Carla and Dev each have one order, so each forms a group of one.

The important distinction: GROUP BY does not discard repeated customer rows. Every order still contributes to its group.

Step 2: Count the rows in each group

Once the groups are formed, COUNT(*) counts how many rows belong to each one.

The per-group count4 groups
customerOrders in the groupCOUNT(*)
Asha1001, 10052
Ben1002, 10062
Carla10031
Dev10041

We started with six orders, but now have only four groups because there are four distinct customers. Each group produces one summary row.

Step 3: Write the SQL query

Query
SELECT
customer,
COUNT(*) AS order_count
FROM orders
GROUP BY customer
ORDER BY customer;

Read the query in parts:

  • GROUP BY customer forms one group for each distinct customer.
  • COUNT(*) counts the orders inside each group.
  • SELECT customer identifies which customer each count belongs to.
  • ORDER BY customer sorts the final result alphabetically.
Result4 rows
customerorder_count
Asha2
Ben2
Carla1
Dev1

What changed?

The most important change is the grain of the result, meaning what one row represents.

Before grouping: One row represents one order. We have six rows.

After grouping: One row represents one customer. We have four rows.

This is the central idea behind GROUP BY: many individual rows become one summary row per group.

Remember this distinction

  • WHERE asks: Should this individual row be included?
  • GROUP BY asks: Which group does this row belong to?
  • An aggregate function such as COUNT(*) then asks: What is the summary value for this group?

Once you understand these three questions, grouped queries become much easier to reason about.

Calculating several metrics per group

Once rows are grouped, we can calculate several aggregate values for each group.

RequirementFor each customer, return the number of orders, the total order value, and the 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;
Result4 rows
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.

RequirementReturn the number of orders and the total 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;
Result3 rows
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.

RequirementReturn the number of orders each customer has in each status.
Query
SELECT
customer,
status,
COUNT(*) AS order_count
FROM orders
GROUP BY
customer,
status
ORDER BY
customer,
status;
Result5 rows
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.

04

Choosing the Result Grain

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.

Tip
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 this query:

Query
SELECT
customer,
status,
SUM(quantity * unit_price) AS total_order_value
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 the following query, customer defines each group, and the other selected expressions produce one value for each group:

Query
SELECT
customer,
COUNT(*) AS order_count,
SUM(quantity * unit_price) AS total_order_value
FROM orders
GROUP BY customer;
05

Filtering with WHERE and HAVING

Suppose the requirement is:

RequirementReturn the 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;
Result3 rows
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:

RequirementReturn 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;
Result2 rows
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:

RequirementAmong 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;
Result2 rows
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.

Tip
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.
06

Looking for Aggregation Mistakes

When a grouped query returns unexpected results, look for these common problems:

  • Wrong grouping: If the requirement says “per customer” or “per product,” check whether GROUP BY matches that requirement.
  • Too many result rows: If you expected one row per customer but got several, check the grouping columns.
  • Extra grouping columns: Adding another column to GROUP BY can split existing groups into smaller ones.
  • Missing aggregation: If a selected column has different values within a group, decide whether it needs to be grouped or aggregated.
  • Unexpected counts: COUNT(*) counts all rows, while COUNT(column) ignores NULL values.
  • Incorrect averages: AVG() ignores NULL values. Check whether excluding them matches the requirement.
  • Aggregate conditions in WHERE: Use HAVING to filter groups based on SUM, COUNT, or AVG.
  • Row filters in HAVING: Use WHERE when you need to filter individual rows before grouping.

The key question: What should one row in the result represent?

Start with this question whenever an aggregation produces unexpected results. It often helps you identify the mistake.

07

Building a Grouped Query

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;
Tip
This structure follows the business question instead of starting from SQL keywords.
08

Practice: Predict the Result

Consider the following orders:

orders6 rows
order_idcustomerstatusorder_amount
1001Ashapaid1500
1002Benpending3200
1003Carlapaid3600
1004Devcancelled8500
1005Ashapaid750
1006Benpaid3600
RequirementAmong paid orders, return customers whose total paid order value is greater than 3,000.
Pause and predictPredict before you continue

Which query correctly expresses the requirement?

The 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
09

Key Takeaways

Rules to Remember

1. COUNT(*) vs. COUNT(column)
COUNT(*) counts all rows. COUNT(column) counts only non-NULL values.

2. Counting unique values
COUNT(DISTINCT column) counts distinct non-NULL values, not the total number of rows.

3. Aggregate functions
SUM adds values, AVG calculates the average, and MIN and MAX find the smallest and largest values. These functions ignore NULL.

4. Aggregation without GROUP BY
Without GROUP BY, aggregate functions summarise all qualifying rows into one result row.

5. GROUP BY determines the result
GROUP BY customer returns one row per customer. Adding status creates separate groups for each customer and status combination.

6. WHERE vs. HAVING
WHERE filters individual rows before grouping. HAVING filters groups after aggregation.

10

Summary: Aggregation Rules

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.

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

Practise this pattern

Now write it yourselfAll 16 Aggregation & GROUP BY problems →

Graded problems on this pattern, easy to hard. They run in your browser against real tables – no sign-up needed.

Up next · Pattern 5Conditional logic & CASE

Next, we express business rules and create different values based on conditions with 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.

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

3 of 3 free questions left
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.

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.

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.

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.

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