Lesson 7 of 12

SQL GROUP BY and HAVING

In SQL Aggregate Functions you turned a whole table into one number, such as the total salary bill. Often you need that number for each something: staff per department, customers per state, orders per customer. SQL GROUP BY does this by splitting rows into groups and running the aggregate once per group. HAVING then lets you keep only the groups you care about.

How SQL GROUP BY works

Picture the database sorting rows into buckets. Every row with the same value in the GROUP BY column lands in the same bucket. Each bucket then becomes one row in the result, and aggregate functions such as COUNT or AVG are calculated inside each bucket.

SELECT Department,
       COUNT(*)            AS Staff,
       ROUND(AVG(Salary))  AS AvgSalary
FROM Employees
GROUP BY Department;

Returns 5 rows, one per department. Development has 3 staff with an average of 75000; Sales has 2 with 61500.

Eight employee rows became five result rows – one per department. Without GROUP BY, the same COUNT(*) would have returned a single row saying 8.

The golden rule

Every column in the SELECT list must either be in the GROUP BY clause or be inside an aggregate function. In the query above, Department is grouped, and Salary only appears inside AVG(). If you added FirstName, which name should the database show for a group of three developers? There is no single answer.

Watch out: SQLite does not enforce this rule – it quietly picks a value from one row of the group. MySQL 5.7 and later reject such queries by default (the ONLY_FULL_GROUP_BY SQL mode), and so do PostgreSQL and SQL Server. Follow the rule even in SQLite so your queries work everywhere.

Grouping with WHERE and ORDER BY

GROUP BY sits after WHERE and before ORDER BY. WHERE removes rows before they are grouped, and ORDER BY can sort by the aggregate result. Here we count Indian customers per state and show the busiest states first:

SELECT State, COUNT(*) AS Customers
FROM Customers
WHERE Country = 'India'
GROUP BY State
ORDER BY Customers DESC, State;

Returns 7 rows: Tamil Nadu 3, Karnataka 2, then five states with 1 customer each.

You can group by more than one column, such as GROUP BY Country, State. Each different combination then forms its own group. You can also group by an expression. Because our dates are text, SQLite’s strftime('%Y-%m', OrderDate) extracts the month, so GROUP BY strftime('%Y-%m', OrderDate) gives orders per month. In MySQL you would use DATE_FORMAT(OrderDate, '%Y-%m').

Filtering groups with HAVING

Suppose you want only customers who have placed more than one order. You cannot write WHERE COUNT(*) > 1, because WHERE runs before the groups exist – at that point there is nothing to count. HAVING is the filter that runs after grouping, so it can use aggregate results.

SELECT CustomerID, COUNT(*) AS OrderCount
FROM Orders
GROUP BY CustomerID
HAVING COUNT(*) > 1;

Returns 2 rows: customer 1 and customer 3, each with 2 orders.

Showing CustomerID numbers is not very friendly. In SQL Joins you will combine this with the Customers table to show names instead.

WHERE and HAVING together

The two filters often work side by side. The next query looks only at products under ₹300 (WHERE), groups them by category, and keeps categories that still have at least three such products (HAVING).

SELECT CategoryID,
       COUNT(*)   AS Products,
       MAX(Price) AS Dearest
FROM Products
WHERE Price < 300
GROUP BY CategoryID
HAVING COUNT(*) >= 3
ORDER BY CategoryID;

Returns 3 rows: categories 1 (Beverages), 2 (Snacks) and 5 (Spices).

WHEREHAVING
FiltersIndividual rowsWhole groups
RunsBefore groupingAfter grouping
Can use aggregates?NoYes
ExampleWHERE Price < 300HAVING COUNT(*) >= 3

Tip: If a condition does not need an aggregate, put it in WHERE. Removing rows early means less work for the grouping step. SQLite and MySQL also let you use a column alias in HAVING (for example HAVING OrderCount > 1), but SQL Server does not, so repeating the expression is the portable choice.

The order SQL actually runs a query

You write SELECT first, but the database processes the clauses in a different order. Knowing this order explains most GROUP BY errors:

  1. FROM – pick the table (and joins)
  2. WHERE – remove rows
  3. GROUP BY – form groups
  4. HAVING – remove groups
  5. SELECT – calculate the output columns
  6. ORDER BY – sort
  7. LIMIT – cut down the rows

This is why a SELECT alias cannot normally be used in WHERE: when WHERE runs, the alias does not exist yet. ORDER BY comes after SELECT, so aliases work there. Next, learn to combine tables in SQL Joins.

Further reading

Key takeaways

  • GROUP BY puts rows with the same value into groups; each group becomes one result row.
  • Every selected column must be grouped or inside an aggregate function.
  • WHERE filters rows before grouping; HAVING filters groups after grouping.
  • HAVING can use aggregates such as COUNT(*) > 1; WHERE cannot.
  • Logical order: FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT.

Quick quiz

Check what you learned – pick an answer.

1. Which clause keeps only departments with more than 2 employees?

2. Why is SELECT Department, FirstName, COUNT(*) FROM Employees GROUP BY Department; a problem?

3. In which order does the database logically process these clauses?

Practice exercise

Using OrderDetails, show each ProductID and the total quantity ordered, but only for products with a total above 15. Sort by the total, highest first.

Show a hint

GROUP BY ProductID, use SUM(Quantity), then filter with HAVING.

Show the solution
SELECT ProductID, SUM(Quantity) AS TotalQty
FROM OrderDetails
GROUP BY ProductID
HAVING SUM(Quantity) > 15
ORDER BY TotalQty DESC;

Progress is saved in this browser only.