GROUP BY
Split data into groups and aggregate each one separately
What Does GROUP BY Do in SQL?
GROUP BY collects rows that have the same value in one or more columns into groups and returns one row per group. Aggregate functions such as COUNT, SUM and AVG then compute one value for each group instead of one value for the whole table.
GROUP BY category with COUNT(*) returns 4 rows: Electronics 4, Furniture 4, Appliances 1 and Stationery 1. Without GROUP BY, COUNT(*) returns a single row for the whole table: 10.GROUP BY col1, col2 makes one group for each combination of values that actually occurs in the data.Knowledge Canvas
How GROUP BY Works
Collapse rows into groups
- GROUP BY collects rows with matching values into a single group
- Each group produces exactly one output row
- Non-aggregated columns in SELECT MUST be in GROUP BY
- Multiple columns = one group per combination that occurs
Mental Model
Think of it as sorting into buckets
GROUP BY country → one box per country. GROUP BY country, city → one box per country+city combination.
- Imagine physically sorting rows into labeled boxes
- Each box = one unique combination of GROUP BY columns
- Aggregates (COUNT, SUM, AVG) summarize what's inside each box
- The result has one row per box
Logical Order
Where GROUP BY fits in the pipeline
GROUP BY Gotchas
The most common aggregation errors
- Non-aggregated column not in GROUP BY → error in PostgreSQL, MySQL and SQL Server; SQLite returns a value from an arbitrary row
- WHERE can't filter groups — use HAVING
- SQL Server rejects SELECT aliases in GROUP BY
- GROUP BY doesn't sort the output — add ORDER BY
GROUP BY Rules
Memorize these
- Every non-aggregate in SELECT must appear in GROUP BY
- GROUP BY runs after WHERE, before HAVING
- All NULLs in a grouped column form one group
- Column order in GROUP BY doesn't change the groups; only ORDER BY guarantees output order
Syntax
GROUP BY colOne output row per distinct value of colGROUP BY col1, col2One row per combination that occurs; the order you list the columns in doesn't change the groupsAGG(col)COUNT, SUM, AVG, MIN or MAX, computed once for each groupWHEREOptional. Filters rows before they are groupedHAVINGOptional. Filters groups after the aggregates are computedORDER BYThe only way to guarantee the order of the groupsRuleEvery non-aggregated SELECT column must be listed in GROUP BYSample Data
| id | customer_id | order_date | status | total |
|---|---|---|---|---|
| 5001 | C1001 | 2024-01-15 | delivered | 59.98 |
| 5002 | C1002 | 2024-02-08 | delivered | 449 |
| 5003 | C1001 | 2024-02-20 | shipped | 12.5 |
| 5004 | C1003 | 2024-03-05 | delivered | 89.99 |
| 5005 | C1006 | 2024-03-15 | pending | 245 |
| 5006 | C1005 | 2024-03-22 | delivered | 159 |
| 5007 | C1004 | 2024-04-02 | shipped | 45.5 |
| 5008 | C1009 | 2024-04-12 | cancelled | 79 |
| 5009 | C1012 | 2024-04-22 | delivered | 425 |
| 5010 | C1007 | 2024-05-05 | pending | 18.99 |
| 5011 | C1008 | 2024-05-18 | delivered | 134 |
| 5012 | C1010 | 2024-06-08 | cancelled | 65.5 |
| name | category | price |
|---|---|---|
| Wireless Mouse | Electronics | 24.99 |
| Office Desk | Furniture | 189 |
| LED Desk Lamp | Furniture | 45.5 |
| USB-C Cable | Electronics | 12.99 |
| Bluetooth Speaker | Electronics | 79 |
| Office Chair | Furniture | 95 |
| Coffee Maker | Appliances | 89.99 |
| Notebook Set | Stationery | 8.5 |
| Wireless Headphones | Electronics | 159 |
| Standing Desk | Furniture | 425 |
| id | name | country |
|---|---|---|
| C1001 | Aarav Sharma | India |
| C1002 | Sara Chen | Singapore |
| C1003 | James Wilson | UK |
| C1004 | Maria Garcia | Spain |
| C1005 | Yuki Tanaka | Japan |
| C1006 | Priya Patel | India |
| C1007 | Alex Johnson | USA |
| C1008 | Chen Wei | China |
| C1009 | Emma Brown | Australia |
| C1010 | Omar Hassan | UAE |
| C1011 | Lena Muller | Germany |
| C1012 | Ravi Kumar | India |
GROUP BY with COUNT and AVG
Find the number of products and the average price in each category.
| category | product_count | avg_price |
|---|---|---|
| Furniture | 4 | 188.63 |
| Appliances | 1 | 89.99 |
| Electronics | 4 | 69 |
| Stationery | 1 | 8.5 |
GROUP BY with WHERE and HAVING
Among products priced over 50, find the categories that still have more than one product, with their product count and average price.
COUNT(*) > 1 is false for it. WHERE also changes the averages: Furniture averages 236.33 here, against 188.63 for all 4 of its products, because the LED Desk Lamp (45.5) was filtered out before AVG ran.| category | product_count | avg_price |
|---|---|---|
| Furniture | 3 | 236.33 |
| Electronics | 2 | 119 |
GROUP BY Multiple Columns
Count the orders in each month, split by status.
GROUP BY status, order_month instead gives exactly the same 11 groups: the order of the columns in GROUP BY doesn't change the groups, and only ORDER BY guarantees the order of the rows. strftime('%Y-%m', order_date) turns 2024-03-05 into 2024-03. It is SQLite's date function; PostgreSQL uses to_char and MySQL DATE_FORMAT.| order_month | status | order_count |
|---|---|---|
| 2024-01 | delivered | 1 |
| 2024-02 | delivered | 1 |
| 2024-02 | shipped | 1 |
| 2024-03 | delivered | 2 |
| 2024-03 | pending | 1 |
| 2024-04 | cancelled | 1 |
| 2024-04 | delivered | 1 |
| 2024-04 | shipped | 1 |
| 2024-05 | delivered | 1 |
| 2024-05 | pending | 1 |
| 2024-06 | cancelled | 1 |
GROUP BY with a JOIN
Show the number of orders and the total order value for each customer country.
COUNT(o.id), because COUNT(*) would count her unmatched row as 1 (see COUNT). The column is called order_value rather than revenue because it includes cancelled and pending orders.| country | order_count | order_value |
|---|---|---|
| India | 4 | 742.48 |
| Singapore | 1 | 449 |
| Japan | 1 | 159 |
| China | 1 | 134 |
| UK | 1 | 89.99 |
| Australia | 1 | 79 |
| UAE | 1 | 65.5 |
| Spain | 1 | 45.5 |
| USA | 1 | 18.99 |
Logical Query Order and Column Aliases
You write SELECT first, but the database evaluates a grouped query in this logical order: FROM and JOIN → WHERE → GROUP BY → HAVING → SELECT (where aliases are created) → ORDER BY → LIMIT.
This order decides what each clause can see. The engine may physically run the steps in a different order, as SQL Server's documentation points out. Two consequences hold in every database:
ORDER BY avg_price DESC works everywhere.Whether the clauses that run before SELECT can use its aliases depends on the database:
GROUP BY order_month): SQLite, PostgreSQL and MySQL accept it; SQL Server doesn't. If the alias has the same name as a table column (strftime('%Y-%m', order_date) AS order_date ... GROUP BY order_date), all three group by the original column instead, so here you get 12 groups of 1 order rather than 6 months. Give the alias a new name such as order_month.HAVING product_count > 1): SQLite and MySQL accept it; PostgreSQL and SQL Server don't.HAVING COUNT(*) > 1. SQLite, PostgreSQL and MySQL (where it is deprecated) also accept GROUP BY 1 (the first SELECT column); SQL Server doesn't. It quietly changes meaning if someone reorders the SELECT list, so name the column instead.GROUP BY vs ORDER BY, DISTINCT and Window Functions
GROUP BY order_month, status comes back month by month while GROUP BY status, order_month comes back status by status. PostgreSQL often groups with a hash table and returns groups in no particular order, and MySQL 8.0 stopped sorting GROUP BY results. When order matters, add ORDER BY.SELECT country FROM customers GROUP BY country returns the same 10 countries as SELECT DISTINCT country FROM customers (see DISTINCT). Use DISTINCT when you only want the unique values, and GROUP BY when you want a number for each of them.SUM(total) OVER (PARTITION BY customer_id) instead.How GROUP BY Handles NULLs and Empty Results
GROUP BY comment returns a single NULL row with a count of 3. NULLs in Aggregations shows how NULLs also change COUNT, SUM and AVG.SELECT COUNT(*) FROM orders WHERE status = 'returned' returns one row containing 0, because an aggregate query without GROUP BY (and without HAVING) always returns exactly one row. Add GROUP BY status and the result is empty: no rows matched, so there are no groups.Common Mistakes
Selecting a column that isn't grouped or aggregated
Electronics has 4 products but becomes a single row, so there is no one name to show. SQLite runs the query anyway and fills name from an arbitrary row of each group, here Wireless Mouse for Electronics and Office Desk for Furniture. Nothing makes those the right products, and PostgreSQL, MySQL and SQL Server reject the query (their exact errors are below).
Remove name, or aggregate it so the value is defined: MIN(name) gives the first name alphabetically, and STRING_AGG(name, ', ') lists all of them. That spelling works here (SQLite 3.44+), in PostgreSQL and in SQL Server; MySQL uses GROUP_CONCAT(name). Adding name to GROUP BY also runs, but then every product becomes its own group.
Using a bare column to find the top row per group
This looks like it finds the most expensive product in each category, and in SQLite it does: Standing Desk (425), Wireless Headphones (159), Coffee Maker (89.99) and Notebook Set (8.5). That relies on a SQLite-only rule: when the query has exactly one MIN() or MAX(), bare columns come from the row that holds that extreme value. With ties the row is arbitrary, and PostgreSQL, MySQL and SQL Server reject the query.
Find the maximum first, then join back for the full row: compute MAX(price) per category in a CTE and join it to products on both category and price. Numbering the rows in each category with ROW_NUMBER() and keeping row 1 also works in every database; that lesson solves exactly this query.
Putting an aggregate in WHERE
SQLite stops with misuse of aggregate: COUNT(), and PostgreSQL with aggregate functions are not allowed in WHERE. WHERE runs before any group exists, so there is no count to compare yet.
Put conditions on aggregates in HAVING, after GROUP BY: HAVING COUNT(*) > 1 returns Electronics (4) and Furniture (4). Row conditions such as price > 50 stay in WHERE.
Grouping by a unique column like id
Adding every selected column to GROUP BY silences the ungrouped-column error that PostgreSQL, MySQL and SQL Server raise (quoted in the next section), but id is different on every order, so every group holds exactly one row. The query returns all 12 orders, each with its own total: the same as no GROUP BY at all.
Group only by the level you report on and drop id: GROUP BY customer_id returns 11 rows, one per customer who has ordered. C1001 is the only customer with 2 orders, worth 72.48 together (wrap the sum in ROUND, since the raw value is 72.47999999999999).
GROUP BY Errors in PostgreSQL, MySQL and SQL Server
SQLite, the engine behind this site, Python's sqlite3 module and DB Browser for SQLite, accepts "bare" columns: columns in a grouped query that are neither listed in GROUP BY nor inside an aggregate. It fills each one from some row of the group without saying which (SQLite lists this among its quirks), which is why the first two mistakes above run here without an error. Other databases refuse to guess. For SELECT category, name, COUNT(*) FROM products GROUP BY category they report:
column "products.name" must appear in the GROUP BY clause or be used in an aggregate functionExpression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'db.products.name' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by. ONLY_FULL_GROUP_BY has been on by default since MySQL 5.7.5; with it turned off, MySQL picks a value the way SQLite does.Column 'products.name' is invalid in the select list because it is not contained in either an aggregate function or the GROUP BY clause.The fix is the same in every database: add the column to GROUP BY, or wrap it in an aggregate that says which value you want. Two details differ:
GROUP BY c.id lets you select c.name from customers. SQL Server needs GROUP BY c.id, c.name.ANY_VALUE(name). SQLite 3.49, which this site runs, doesn't have it.Key Concepts
Pro Tip
Before writing the query, finish the sentence "one row per ___". Whatever fills the blank goes in GROUP BY, and every other SELECT column needs an aggregate. For "one row per customer", that means GROUP BY customer_id, not GROUP BY customer_id, id.
When to Use
Revenue by product category, orders per customer, average rating per product, monthly sales totals, users by country.
Excluding cancelled orders, show each month's number of orders and total order value from the orders table. Name the columns order_month (in YYYY-MM form), order_count and order_value, and sort by order_month.