Skip to main content
All SQL lessons

Level 8 of 13 · The counting club

SQL GROUP BY and HAVING Explained: COUNT, SUM, AVG

Squashing many rows into one answer with COUNT, SUM, AVG, MIN and MAX, grouping them with GROUP BY, and filtering the groups with HAVING.

Covers: COUNT · SUM · AVG · MIN · MAX · GROUP BY · HAVING

The lesson

Every example runs against the same small pizza shop database: pizzas, customers, orders and staff.

# What is GROUP BY? GROUP BY puts rows that share a value into groups, then gives one answer per group: how many rows, their total, their average. Why you need it: many questions are about groups, not single rows. "How many customers live in each city?" and "how much has each customer spent?" are GROUP BY questions. - `GROUP BY city` one group, and one result row, per city - `COUNT(*)` how many rows are in each group The query answers the first question. The next slides cover counting and adding up, then the groups. > Most reports and dashboard charts are a GROUP BY query underneath.

SELECT city, COUNT(*)
FROM customers
GROUP BY city

# Summarise many rows into one Aggregate functions take many rows and return one value. - `COUNT(*)` how many rows - `SUM(…)` the values added up - `AVG(price)` the average - `MIN(price)` `MAX(price)` the smallest and the biggest > Aggregates skip NULL values (COUNT(*) is the exception). An AVG over a column with NULLs averages only the filled-in rows.

SELECT COUNT(*),
  AVG(price), MIN(price), MAX(price)
FROM pizzas

# COUNT(*) and COUNT(column) - `COUNT(*)` counts every row - `COUNT(tip)` counts only the rows where tip isn't NULL > To count the orders without a tip: COUNT(*) - COUNT(tip).

SELECT COUNT(*), COUNT(tip)
FROM orders

# Groups: GROUP BY GROUP BY puts rows with the same value into groups. The aggregate then runs once per group. - `GROUP BY city` one group per city - `COUNT(*)` counts the rows in each group > The result has one row per group. Customers in 4 cities give 4 rows.

SELECT city, COUNT(*)
FROM customers
GROUP BY city

# Filter groups: HAVING - `WHERE` filters rows, before they are grouped - `HAVING COUNT(*) > 1` filters groups, after they are counted > You can't use COUNT in a WHERE: the counting hasn't happened yet. Conditions on aggregates go in HAVING.

SELECT city, COUNT(*)
FROM customers
GROUP BY city
HAVING COUNT(*) > 1

# The GROUP BY rule Every column in the SELECT must be in the GROUP BY, or inside an aggregate. A city group holds two names: which one should it show? MySQL refuses with "isn't in GROUP BY". The practice database picks one at random, so don't copy this. - `--` a comment: the database ignores the rest of the line > MariaDB, which Appian Cloud uses, allows this query by default and returns any one of the names. Don't rely on it: the answer can change.

SELECT city, name, COUNT(*)
FROM customers
GROUP BY city;
-- MySQL: Expression #2 …
-- isn’t in GROUP BY

# Group by a calculated value - `DATE(ordered_at)` keeps only the day of a date and time - `AS day` names the result column - `ordered_at >= '2026-10-01' AND ordered_at < '2026-11-01'` every moment in October: from the 1st, up to but not including 1 November Each day becomes one group, so this counts the orders per October day. > GROUP BY the same expression you SELECT. MySQL also lets you write GROUP BY day, using the alias, but many databases don't.

SELECT DATE(ordered_at) AS day,
  COUNT(*) AS orders
FROM orders
WHERE ordered_at >= '2026-10-01'
  AND ordered_at < '2026-11-01'
GROUP BY DATE(ordered_at)

Appian corner

In Appian, use a!aggregationFields(groupings: a!grouping(field: …city), measures: a!measure(function: "COUNT", field: …id)). Measures can be COUNT, SUM, AVG, MIN or MAX. There is no HAVING, so filter the totals afterwards or put it in a database view.

Practice questions

Try each one before opening the answer. To write and run them against the database in your browser, practise the level.

  1. 1. The average pizza price.

    SELECT ___(price) FROM pizzas
    Show the answer
    SELECT AVG(price) FROM pizzas

    Short for average.

  2. 2. orders has 12 rows, and 5 of them have no tip (NULL). What does this return?

    SELECT COUNT(tip) FROM orders
    • 12
    • 5
    • 7
    • 0
    Show the answer

    7. COUNT(tip) skips the NULLs: 12 − 5 = 7.

  3. 3. The cheapest and the most expensive pizza price, in one row.

    Show the answer
    SELECT MIN(price), MAX(price)
    FROM pizzas

    MIN(price), MAX(price)

  4. 4. How many customers live in each city?

    Show the answer
    SELECT city, COUNT(*)
    FROM customers
    GROUP BY city

    GROUP BY city

  5. 5. The average tip for each order status.

    Show the answer
    SELECT status, AVG(tip)
    FROM orders
    GROUP BY status

    GROUP BY status

  6. 6. Total pizzas ordered (the sum of qty) for each status.

    Show the answer
    SELECT status, SUM(qty)
    FROM orders
    GROUP BY status

    SELECT status, SUM(qty) … GROUP BY status

  7. 7. How many orders were placed on each day of October 2026? Show the day and the count.

    Show the answer
    SELECT DATE(ordered_at), COUNT(*)
    FROM orders
    WHERE ordered_at >= '2026-10-01'
      AND ordered_at < '2026-11-01'
    GROUP BY DATE(ordered_at)

    WHERE keeps October (ordered_at >= '2026-10-01' AND ordered_at < '2026-11-01'), then GROUP BY DATE(ordered_at)

  8. 8. Why does MySQL refuse this?

    SELECT status, customer_id, COUNT(*)
    FROM orders
    GROUP BY status
    • COUNT(*) needs a column
    • customer_id is neither grouped nor inside an aggregate
    • status can’t be grouped
    • GROUP BY must come before FROM
    Show the answer

    customer_id is neither grouped nor inside an aggregate. Each status pile holds many customer_ids, so which one should it show? Group by it too, or wrap it in COUNT, MIN, MAX…

  9. 9. Boss question! Each customer_id that placed more than 2 orders, with how many orders.

    Show the answer
    SELECT customer_id, COUNT(*)
    FROM orders
    GROUP BY customer_id
    HAVING COUNT(*) > 2

    GROUP BY customer_id HAVING COUNT(*) > 2