GROUP BY and HAVING

SQL · Essentials · 12 min

Per-customer totals, per-day counts and per-category averages are all GROUP BY queries.

GROUP BY splits the rows into groups that share the same values in the listed columns, then computes the aggregates once per group:

SELECT customer_id, COUNT(*) AS orders, SUM(total) AS spent
FROM orders
GROUP BY customer_id;

The rule that follows: every column in the select list must either appear in GROUP BY or be inside an aggregate. Selecting status here would be ambiguous, since a customer has orders with several statuses. PostgreSQL relaxes this slightly: if you group by a table's primary key, you may select that table's other columns, because they are determined by the key. MySQL with ONLY_FULL_GROUP_BY disabled lets you select anything and returns an arbitrary value, which hides bugs.

WHERE and HAVING both filter, at different moments:

  • WHERE filters rows before grouping. It cannot use aggregates.
  • HAVING filters groups after aggregation, so it can: HAVING SUM(total) > 200.

The logical evaluation order makes this clear: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. A condition on a plain column belongs in WHERE, where it reduces the work early; put only aggregate conditions in HAVING.

You can group by expressions, such as date_trunc('month', ordered_at) to get monthly figures. NULLs form their own group: all customers with an unknown city land in one NULL group.

A group exists only if at least one row falls into it. A per-customer report built from orders alone will never show Dev, who has no orders; to include zero rows you need an outer join, coming up in the Joins module.

Example

SELECT customer_id,
       COUNT(*)   AS orders,
       SUM(total) AS spent
FROM orders
WHERE status <> 'cancelled'          -- row filter, before grouping
GROUP BY customer_id
HAVING SUM(total) > 200              -- group filter, after aggregation
ORDER BY spent DESC;
--  customer_id | orders | spent
--            1 |      2 | 257.50
--            2 |      2 | 247.99

SELECT date_trunc('month', ordered_at) AS month, COUNT(*)
FROM orders
GROUP BY month
ORDER BY month;
--  2026-01-01 00:00:00+00 | 1
--  2026-02-01 00:00:00+00 | 2
--  2026-03-01 00:00:00+00 | 2

Remember

  • Each selected column must be grouped or aggregated
  • WHERE filters rows before grouping; HAVING filters groups after
  • Logical order: FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT
  • Groups only exist for values present in the data

Watch out for

  • Putting non-aggregate conditions in HAVING instead of WHERE, which works but filters late and reads oddly.
  • Expecting groups with zero rows to appear in the result.

Check yourself

  1. Where must the condition 'only customers who spent more than 200 in total' go?
  2. Why is SELECT customer_id, status, COUNT(*) FROM orders GROUP BY customer_id rejected?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.postgresql.org/docs/current/queries-table-expressions.html#QUERIES-GROUP