SQL GROUP BY and HAVING: Full Guide (2026)
On an older MySQL setup with a relaxed SQL mode, a query like SELECT customer_id, name, COUNT(*) FROM orders GROUP BY customer_id runs without error — and quietly attaches an arbitrary, possibly wrong name to each customer's order count. GROUP BY is where SQL stops returning individual rows and starts summarizing data, but the rules about which columns are actually allowed in the SELECT list trip up almost everyone at first. This guide covers aggregation, the GROUP BY column rule, HAVING vs WHERE, multi-column grouping, and the specific trap above.
Quick Answer: What Do GROUP BY and HAVING Do?
GROUP BY collapses rows that share the same value in one or more columns into a single group, so aggregate functions like COUNT(), SUM(), and AVG() can be calculated per group instead of across the whole table. HAVING then filters those groups based on an aggregate condition — the group-level equivalent of WHERE, which filters individual rows before any grouping happens. Every non-aggregated column in SELECT must appear in GROUP BY, or the result is ambiguous.
The Customer Report With the Wrong Name
Older MySQL configurations, with a relaxed sql_mode, allow a query like SELECT customer_id, name, COUNT(*) FROM orders GROUP BY customer_id to run without complaint — even though name isn't in GROUP BY and isn't wrapped in an aggregate function. The database silently picks some value of name from one of the rows in each group, with no guarantee it's the right one if a customer's name was ever updated mid-dataset. The query returns real-looking output: a customer ID, a name, and a count. It's just that the name attached to the count might not actually belong together.
Modern PostgreSQL, BigQuery, and MySQL with standard settings reject this outright with an error — which is the better outcome, because a loud error is far easier to catch than a silently wrong report. Understanding exactly why the rule exists is the fastest way to stop fighting it.
Anatomy of GROUP BY
GROUP BY pairs almost always with at least one aggregate function — otherwise there's nothing to compute once rows are collapsed.
-- how many orders does each customer have? SELECT customer_id, COUNT(*) AS order_count FROM orders GROUP BY customer_id;
Reading this: every row in orders gets bucketed by its customer_id, one bucket per unique customer. COUNT(*) then counts how many rows landed in each bucket. The result isn't the original rows anymore — it's one summarized row per customer, exactly what "aggregation" means.
The GROUP BY Column Rule
This is the rule behind the hook at the top of this article: every column in the SELECT list must either be in GROUP BY, or be wrapped in an aggregate function. There is no third option in standard SQL.
-- INVALID on Postgres/BigQuery/standard MySQL: name is neither grouped nor aggregated SELECT customer_id, name, COUNT(*) FROM orders GROUP BY customer_id; -- FIX 1: add name to GROUP BY SELECT customer_id, name, COUNT(*) FROM orders GROUP BY customer_id, name; -- FIX 2: aggregate it instead, if multiple names per group is expected SELECT customer_id, MAX(name) AS name, COUNT(*) FROM orders GROUP BY customer_id;
The reasoning: once rows collapse into a group, a non-aggregated column could hold several different values within that same group — the database has no defensible rule for which one to show, so standard SQL simply forbids it. Adding the column to GROUP BY is correct when it's genuinely one-to-one with the grouping key (a customer really only has one name); wrapping it in an aggregate is correct when you're deliberately picking one value from several.
Grouping by Multiple Columns
GROUP BY isn't limited to one column — listing several creates one group per unique combination of those columns, not one group per column.
-- total revenue per country, per product category SELECT country, product_category, SUM(order_total) AS revenue FROM orders GROUP BY country, product_category;
If there are 5 countries and 4 product categories, this can produce up to 20 groups — one for every country/category pair that actually appears in the data. This is the standard pattern for cross-tabulated reports: "revenue by region and category" is always a multi-column GROUP BY under the hood.
HAVING vs WHERE: Filtering Groups, Not Rows
WHERE and HAVING both filter, but they operate at different stages — this is the single most important distinction in this article.
-- WHERE removes individual rows before grouping SELECT customer_id, COUNT(*) AS order_count FROM orders WHERE status != 'cancelled' GROUP BY customer_id; -- HAVING removes entire groups after aggregation SELECT customer_id, COUNT(*) AS order_count FROM orders GROUP BY customer_id HAVING COUNT(*) > 5; -- both together: exclude cancelled orders, then keep only customers with 5+ remaining SELECT customer_id, COUNT(*) AS order_count FROM orders WHERE status != 'cancelled' GROUP BY customer_id HAVING COUNT(*) > 5;
WHERE runs before GROUP BY has aggregated anything — at that point in the logical order, COUNT(*) doesn't exist yet for any group, so referencing it in WHERE throws an error on every major database. That's specifically what HAVING exists to solve.
A practical habit: filter with WHERE whenever possible, since it discards rows before the (more expensive) grouping and aggregation work happens. Reach for HAVING only for conditions that genuinely depend on the aggregate itself, like "customers with more than 5 orders" — there's no way to know that count without aggregating first.
Putting It All Together: Clause Order
A full query with every clause covered in this cluster so far follows a fixed written order, which differs from the order the database actually evaluates it in.
| Written order | Logical evaluation order |
|---|---|
| SELECT | 4th — after grouping |
| FROM | 1st — the source table |
| WHERE | 2nd — filters raw rows |
| GROUP BY | 3rd — collapses rows into groups |
| HAVING | 5th — filters groups |
| ORDER BY | 6th — sorts the final result |
| LIMIT | 7th — trims the sorted result |
This mismatch between written order and evaluation order is exactly why WHERE can't see a SELECT alias (SELECT hasn't run yet) and why WHERE can't use an aggregate (GROUP BY hasn't run yet). Once this table clicks, most of the "why doesn't this work" questions in SQL answer themselves.
COUNT(*) vs COUNT(column): The NULL Difference
These look interchangeable and are not. COUNT(*) counts every row in a group, full stop. COUNT(column) counts only the rows where that specific column is not NULL.
-- total orders per customer, including ones with a missing discount_code SELECT customer_id, COUNT(*) AS total_orders FROM orders GROUP BY customer_id; -- only orders where a discount_code was actually used SELECT customer_id, COUNT(discount_code) AS orders_with_discount FROM orders GROUP BY customer_id;
If a customer placed 10 orders and only 3 used a discount code, COUNT(*) returns 10 and COUNT(discount_code) returns 3 — same table, same group, genuinely different numbers, both correct for what they're asking. Picking the wrong one is a quiet way to under- or over-report a metric without any error to flag it.
Mistakes That Give Away a Beginner
- Selecting a non-grouped, non-aggregated column — the exact trap from this article's opening. Either add it to GROUP BY or wrap it in an aggregate function.
- Trying to filter an aggregate with WHERE instead of HAVING. COUNT, SUM, and friends don't exist yet at the point WHERE runs.
- Using HAVING for a condition that doesn't need aggregation. If a filter only touches raw columns, WHERE is both correct and faster, since it discards rows before the grouping work happens.
- Confusing COUNT(*) and COUNT(column) on a table where the column has NULLs, silently under- or over-counting.
- Forgetting that GROUP BY with multiple columns groups by the combination, not producing one group per column independently.
Practice: Write These Three Queries Yourself
Using the same orders table (order_id, customer_id, product_name, price, quantity, order_date, status), try writing these:
Basic aggregation
Return the total revenue (SUM of price × quantity) for each distinct status.
Filter rows, then group
Excluding cancelled orders, return the number of orders per customer_id.
Filter the groups themselves
From that same per-customer count, return only customers with more than 3 orders, using HAVING.
Frequently Asked Questions
What does GROUP BY do in SQL?
GROUP BY collapses rows that share the same value in one or more columns into a single group, so aggregate functions like COUNT, SUM, and AVG can be calculated per group instead of across the whole table. It turns individual rows into summarized groups.
What is the difference between WHERE and HAVING?
WHERE filters individual rows before grouping happens, and cannot reference aggregate functions. HAVING filters groups after GROUP BY has aggregated them, and is specifically designed to reference aggregate functions like COUNT() or SUM() to keep or discard entire groups.
Why does SQL require every SELECT column to appear in GROUP BY?
Once rows are collapsed into groups, a non-aggregated column could have multiple different values within one group, and the database has no rule for which one to display. Standard SQL requires every selected column to either be in GROUP BY or wrapped in an aggregate function so the result is unambiguous.
What is the difference between COUNT(*) and COUNT(column)?
COUNT(*) counts every row in a group regardless of NULL values. COUNT(column) counts only the rows where that specific column is not NULL, so the two can return different numbers on the same group if the column has missing values.
Can you group by multiple columns in SQL?
Yes. GROUP BY country, product_type creates a separate group for every unique combination of country and product type, not one group per column. Aggregate functions then calculate a value for each of those combined groups.
What is MySQL's ONLY_FULL_GROUP_BY setting?
It is a MySQL SQL mode that enforces the standard rule requiring every non-aggregated SELECT column to appear in GROUP BY. Older MySQL configurations sometimes disabled this, silently allowing invalid GROUP BY queries to run and return an arbitrary, unpredictable value for ungrouped columns instead of an error.
Conclusion: Group Deliberately, Filter at the Right Stage
GROUP BY turns individual rows into summarized groups, and the one rule to internalize is that every SELECT column needs to be either a grouping key or wrapped in an aggregate — there's no ambiguous middle ground in correct SQL. Filter rows with WHERE before grouping whenever you can; reach for HAVING only when the condition genuinely depends on an aggregate value. And remember COUNT(*) and COUNT(column) are not interchangeable the moment NULLs are in play.
Practice the three queries above against a real table, then move to the next article in this cluster, which covers JOINs — how tables get combined before you're even aggregating across them.
Next Up: SQL JOINs Explained
Real reports rarely come from one table. Learn INNER, LEFT, RIGHT, and FULL joins next.
▶ Read the SQL JOINs GuideKhalid Hussain
Founder of Review Publically. MSc holder and Google Advanced Data Analytics certified. Teaches SQL and Python data analysis with a focus on current, correct, production-ready syntax rather than outdated conventions.
Related Articles