10.9. GROUP BY
Table of Contents
Why `GROUP BY` Matters
When you work with data, you often need to answer questions like:
- How many orders did each customer make?
- What is the total sales per day?
- What is the average salary per department?
All these questions require grouping rows that share some value and then applying aggregate functions (like COUNT, SUM, AVG) on each group.
That is exactly what GROUP BY does.
Rule: Use GROUP BY whenever you want to apply aggregate functions per group of rows, not just over the entire table.
This chapter focuses only on GROUP BY. Aggregate functions themselves (like COUNT, SUM, AVG) are covered in the Aggregate Functions chapter, but we will use them in examples here.
Basic `GROUP BY` Syntax
The logical structure looks like this:
SELECT
column_or_expression,
aggregate_function(...)
FROM table_name
GROUP BY column_or_expression;The idea:
GROUP BYsplits the rows fromFROMinto groups with the same values in the group-by columns.- Aggregate functions calculate one result per group.
- The
SELECTreturns one row per group.
Simple Example: Counting rows per value
Table: orders
| id | customer_id | status | total_amount |
|---|---|---|---|
| 1 | 10 | 'paid' | 100.00 |
| 2 | 10 | 'paid' | 50.00 |
| 3 | 11 | 'unpaid' | 70.00 |
| 4 | 12 | 'paid' | 120.00 |
Count how many orders each customer has:
SELECT
customer_id,
COUNT(*) AS order_count
FROM orders
GROUP BY customer_id;Result:
| customer_id | order_count |
|---|---|
| 10 | 2 |
| 11 | 1 |
| 12 | 1 |
Here, GROUP BY customer_id creates 3 groups (customer 10, 11, 12). COUNT(*) runs once per group.
Rules for Columns in `SELECT` with `GROUP BY`
This is the part that often confuses beginners.
Very important rule:
In a SELECT with GROUP BY:
- Every column or expression in the
SELECTmust be: - either listed in the
GROUP BYclause - or wrapped in an aggregate function (like
COUNT,SUM,MIN,MAX,AVG, etc.)
Valid vs invalid examples
Using orders table again.
β Valid:
SELECT
customer_id,
COUNT(*) AS order_count
FROM orders
GROUP BY customer_id;customer_idappears inGROUP BY.COUNT(*)is an aggregate.
β Invalid:
SELECT
customer_id,
status,
COUNT(*) AS order_count
FROM orders
GROUP BY customer_id;
Problem: status is in SELECT but not in GROUP BY and not aggregated. The database does not know which status to show for each customer_id, since a customer could have multiple statuses across orders.
To fix it, either:
- Add
statusto the grouping:
SELECT
customer_id,
status,
COUNT(*) AS order_count
FROM orders
GROUP BY customer_id, status;
Now each group is a combination of (customer_id, status).
Result example:
| customer_id | status | order_count |
|---|---|---|
| 10 | 'paid' | 2 |
| 11 | 'unpaid' | 1 |
| 12 | 'paid' | 1 |
- Or aggregate
status, for example, withMIN(status)orMAX(status):
SELECT
customer_id,
MIN(status) AS some_status,
COUNT(*) AS order_count
FROM orders
GROUP BY customer_id;
Now MIN(status) chooses one status per customer according to sorting rules. Whether this makes sense depends on the data and the question you are answering.
Grouping by Multiple Columns
You are not limited to a single column. You can group by multiple columns to create more detailed groups.
Example: Sales per customer per status
SELECT
customer_id,
status,
COUNT(*) AS order_count,
SUM(total_amount) AS total_spent
FROM orders
GROUP BY customer_id, status;
Here the groups are defined by both customer_id and status. For each unique pair of (customer_id, status) you get one row.
With our sample data:
| id | customer_id | status | total_amount |
|---|---|---|---|
| 1 | 10 | 'paid' | 100.00 |
| 2 | 10 | 'paid' | 50.00 |
| 3 | 11 | 'unpaid' | 70.00 |
| 4 | 12 | 'paid' | 120.00 |
Result:
| customer_id | status | order_count | total_spent |
|---|---|---|---|
| 10 | 'paid' | 2 | 150.00 |
| 11 | 'unpaid' | 1 | 70.00 |
| 12 | 'paid' | 1 | 120.00 |
Grouping by expressions
You can group by calculated expressions too.
Suppose orders has a created_at column of type timestamp.
| id | customer_id | total_amount | created_at |
|---|---|---|---|
| 1 | 10 | 100.00 | 2024-01-01 10:15:00 |
| 2 | 10 | 50.00 | 2024-01-01 11:00:00 |
| 3 | 11 | 70.00 | 2024-01-02 09:30:00 |
Group by date only, ignoring time:
SELECT
DATE(created_at) AS order_date,
COUNT(*) AS orders_on_date,
SUM(total_amount) AS total_on_date
FROM orders
GROUP BY DATE(created_at);Result:
| order_date | orders_on_date | total_on_date |
|---|---|---|
| 2024-01-01 | 2 | 150.00 |
| 2024-01-02 | 1 | 70.00 |
Note that the expression DATE(created_at) appears both in SELECT and GROUP BY.
`GROUP BY` and `WHERE`: Filtering Before Grouping
Often you do not want to group all rows, only some of them. Use WHERE to filter rows before grouping.
Order of operations (simplified):
FROMandJOINWHERE(filters individual rows)GROUP BY(groups remaining rows)HAVING(filters groups, covered later)SELECTORDER BY
Example: Count only paid orders per customer
SELECT
customer_id,
COUNT(*) AS paid_orders
FROM orders
WHERE status = 'paid'
GROUP BY customer_id;Here:
- First,
WHERE status = 'paid'keeps only paid orders. - Then
GROUP BY customer_idgroups those. - Then
COUNT(*)counts how many paid orders in each group.
If the original table was:
| id | customer_id | status |
|---|---|---|
| 1 | 10 | 'paid' |
| 2 | 10 | 'paid' |
| 3 | 10 | 'unpaid' |
| 4 | 11 | 'paid' |
| 5 | 12 | 'unpaid' |
After WHERE status = 'paid', remaining rows:
| id | customer_id | status |
|---|---|---|
| 1 | 10 | 'paid' |
| 2 | 10 | 'paid' |
| 4 | 11 | 'paid' |
Then grouping and counting gives:
| customer_id | paid_orders |
|---|---|
| 10 | 2 |
| 11 | 1 |
`GROUP BY` with `HAVING`: Filtering After Grouping
Sometimes you want to filter groups, not individual rows. For example, "only customers with more than 3 orders".
You cannot do that with WHERE, because WHERE does not know about group results like COUNT(*).
For that, SQL has the HAVING clause. The Aggregate Functions chapter will discuss this more deeply, but here is the basic idea connected to GROUP BY.
Basic pattern with `HAVING`
SELECT
group_column,
aggregate_function(...) AS agg
FROM table_name
GROUP BY group_column
HAVING aggregate_function(...) condition;Example: Customers with at least 2 orders
SELECT
customer_id,
COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
HAVING COUNT(*) >= 2;Interpretation:
GROUP BY customer_idcreates groups.COUNT(*)is computed for each group.HAVING COUNT(*) >= 2removes the groups that do not meet the condition.
Given this data:
| id | customer_id |
|---|---|
| 1 | 10 |
| 2 | 10 |
| 3 | 11 |
| 4 | 12 |
| 5 | 12 |
Result:
| customer_id | order_count |
|---|---|
| 10 | 2 |
| 12 | 2 |
Combining `WHERE` and `HAVING`
You can and often should use both.
Example: Find customers who have at least 2 paid orders.
SELECT
customer_id,
COUNT(*) AS paid_orders
FROM orders
WHERE status = 'paid' -- filter rows first
GROUP BY customer_id
HAVING COUNT(*) >= 2; -- then filter groupsWHERElimits to paid orders.GROUP BYgroups by customer.HAVINGkeeps only groups with at least 2 paid orders.
Practical Examples of `GROUP BY`
Let us look at different question types and how GROUP BY helps.
Example 1: Counting distinct values
Table: users
| id | country | age |
|---|---|---|
| 1 | 'US' | 22 |
| 2 | 'US' | 30 |
| 3 | 'DE' | 25 |
| 4 | 'FR' | 27 |
Question: How many users per country?
SELECT
country,
COUNT(*) AS user_count
FROM users
GROUP BY country;Result:
| country | user_count |
|---|---|
| 'US' | 2 |
| 'DE' | 1 |
| 'FR' | 1 |
Example 2: Average per group
Question: What is the average age per country?
SELECT
country,
AVG(age) AS avg_age
FROM users
GROUP BY country;Result:
| country | avg_age |
|---|---|
| 'US' | 26.0 |
| 'DE' | 25.0 |
| 'FR' | 27.0 |
Example 3: Grouping by multiple columns
Table: events
| id | user_id | event_type | created_at |
|---|---|---|---|
| 1 | 1 | 'login' | 2024-01-01 10:00:00 |
| 2 | 1 | 'click' | 2024-01-01 10:05:00 |
| 3 | 2 | 'login' | 2024-01-01 11:00:00 |
| 4 | 1 | 'login' | 2024-01-02 09:00:00 |
Question: How many events per user and per event type?
SELECT
user_id,
event_type,
COUNT(*) AS event_count
FROM events
GROUP BY user_id, event_type;Result:
| user_id | event_type | event_count |
|---|---|---|
| 1 | 'login' | 2 |
| 1 | 'click' | 1 |
| 2 | 'login' | 1 |
Example 4: Grouping by a transformed value
Question: How many logins per day?
SELECT
DATE(created_at) AS login_date,
COUNT(*) AS login_count
FROM events
WHERE event_type = 'login'
GROUP BY DATE(created_at)
ORDER BY login_date;If the timestamps are:
| id | event_type | created_at |
|---|---|---|
| 1 | 'login' | 2024-01-01 10:00:00 |
| 2 | 'login' | 2024-01-01 11:00:00 |
| 3 | 'login' | 2024-01-02 09:00:00 |
Result:
| login_date | login_count |
|---|---|
| 2024-01-01 | 2 |
| 2024-01-02 | 1 |
Common Pitfalls and How to Avoid Them
Pitfall 1: Selecting non grouped, non aggregated columns
You might try:
SELECT
customer_id,
status,
COUNT(*) AS order_count
FROM orders
GROUP BY customer_id;
This is usually not allowed (or it gives undefined results in some databases) because status is neither grouped nor aggregated.
Fix: Either group by status too, or aggregate it.
-- Option 1
GROUP BY customer_id, status;
-- Option 2
SELECT
customer_id,
MIN(status) AS some_status,
COUNT(*) AS order_count
FROM orders
GROUP BY customer_id;Pitfall 2: Using `WHERE` for group conditions
You cannot write:
SELECT
customer_id,
COUNT(*) AS order_count
FROM orders
WHERE COUNT(*) > 1
GROUP BY customer_id;
WHERE cannot use COUNT(*) because grouping is not done yet.
Fix: Use HAVING instead.
SELECT
customer_id,
COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
HAVING COUNT(*) > 1;Pitfall 3: Forgetting to group by all non aggregated columns
When you have more than one non aggregated column, all of them must appear in the GROUP BY.
β Wrong:
SELECT
country,
city,
COUNT(*) AS user_count
FROM users
GROUP BY country;β Correct:
SELECT
country,
city,
COUNT(*) AS user_count
FROM users
GROUP BY country, city;Mental Model for `GROUP BY`
It helps to imagine GROUP BY in steps:
- Start with all rows from
FROMandWHERE. - Place each row into a bucket based on the group-by column values.
- Group by
countrymeans one bucket per country. - Group by
country, citymeans one bucket per pair of values. - For each bucket, compute aggregate functions using only rows in that bucket.
- Output one row per bucket.
For example, GROUP BY country:
- Bucket "US": all rows where
country = 'US'. - Bucket "DE": all rows where
country = 'DE'. - Bucket "FR": all rows where
country = 'FR'.
COUNT(*) counts how many rows are in each bucket. SUM(amount) adds amounts in each bucket. AVG(age) averages ages in each bucket.
Summary
GROUP BYis used to group rows that have the same values in one or more columns.- Combine
GROUP BYwith aggregate functions to compute results per group, such as counts, sums, or averages. - Every column in
SELECTmust either: - appear in
GROUP BY, or - be inside an aggregate function.
- Use
WHEREto filter individual rows before grouping. - Use
HAVINGto filter groups after aggregation. - You can group by multiple columns and by expressions, not just simple column names.
Understanding GROUP BY is essential for any backend developer who reads or writes SQL for reporting, analytics, or even simple statistics in APIs.
Views: 9
KAHIBARO