SQL Aggregation and GROUP BY

An aggregate function such as COUNT, SUM, AVG, MIN or MAX reads a column across a set of rows and returns one value. GROUP BY splits the rows into groups so that the function runs once per group: SELECT status, COUNT(*) FROM orders GROUP BY status returns one row per status with its number of orders.

This is how a tester asks the database about sets rather than single records, building on SELECT, WHERE and JOIN. HAVING then filters the groups, and reconciliation checks compare the same count or total computed in two places.

Why it matters

Many data defects are invisible one row at a time: an order whose total differs from its lines, an import that loaded 9,998 of 10,000 rows, a report that counts cancelled orders. Each row looks plausible; the defect appears only when a set is counted or summed and compared with an expected value.

One aggregate query checks a report, an invoice run or a migration that nobody could read row by row. The risk is a wrong query: a skipped NULL, a join that repeats rows or a filter in the wrong clause gives a false pass or a false alarm.

Aggregate functions: COUNT, SUM, AVG, MIN and MAX

An aggregate function returns one value for a set of rows. Without GROUP BY, every row that passes WHERE forms one set, so the query returns exactly one row, even when nothing matched. The code block shows the six-row orders table used throughout this lesson.

NULL is the detail that matters for testing: every aggregate except COUNT(*) ignores it. Three orders have no rating, so COUNT(rating) is 3 and AVG(rating) is 12 / 3 = 4, not 12 / 6 = 2. If the business rule treats a missing value as zero, write AVG(COALESCE(rating, 0)) and note it in the test case.

  • COUNT(*) counts rows, COUNT(column) counts non-NULL values, and COUNT(DISTINCT column) counts different non-NULL values.
  • SUM over no rows, or over NULLs only, returns NULL, not 0: compare COALESCE(SUM(amount), 0).
  • AVG in SQL Server returns an integer for an integer column; cast to a decimal type first.
  • MIN and MAX work on numbers, dates and text, which makes them a quick range check, such as the earliest created_at of an import.
-- orders: the example table used in this lesson
-- order_id  customer_id  status     total_amount  rating
-- 1001      7            PAID             120.00       5
-- 1002      7            PAID              80.00    NULL
-- 1003      9            CANCELLED         50.00    NULL
-- 1004      9            PAID             200.00       4
-- 1005      12           PENDING           35.00    NULL
-- 1006      12           PAID              15.00       3

SELECT COUNT(*)                    AS all_rows,       -- 6
       COUNT(rating)               AS rated,          -- 3
       COUNT(DISTINCT customer_id) AS customers,      -- 3
       SUM(total_amount)           AS amount,         -- 500.00
       AVG(rating)                 AS avg_rating,     -- 4 = 12 / 3, not 12 / 6
       MIN(total_amount)           AS smallest,       -- 15.00
       MAX(total_amount)           AS largest         -- 200.00
FROM orders;

Try it: SQL INSERT Generator Try it: SQL Dataset Generator

GROUP BY: one result row per group

GROUP BY puts rows with the same value in the grouping columns into one group and computes each aggregate once per group. GROUP BY status turns the six orders into PAID (4 rows), CANCELLED (1) and PENDING (1); grouping by customer_id, status makes one group per combination.

Every column in the SELECT list must be grouped or aggregated. SELECT status, order_id, COUNT(*) … GROUP BY status asks for one order_id where PAID has four. PostgreSQL, SQL Server and MySQL in its default ONLY_FULL_GROUP_BY mode reject it; SQLite returns a value from an arbitrary row, so a check can look right by accident.

  • NULL in a grouping column forms a group of its own, which is often a finding in itself.
  • Groups have no guaranteed order: add ORDER BY before comparing with a report.
  • Group totals must add up to the ungrouped total: 50.00 + 415.00 + 35.00 = 500.00 shows that no row was lost or counted twice.
SELECT status,
       COUNT(*)          AS orders,
       SUM(total_amount) AS amount
FROM orders
GROUP BY status
ORDER BY status;

-- status     orders  amount
-- CANCELLED       1   50.00
-- PAID            4  415.00
-- PENDING         1   35.00

Try it: SQL Query Validator Try it: SQL Formatter

WHERE filters rows, HAVING filters groups

A grouped query is evaluated in a logical order: FROM and JOIN, WHERE, GROUP BY, the aggregates, HAVING, SELECT, ORDER BY. The engine may execute it differently, but the result is always as if it followed this order. That is why WHERE cannot use an aggregate, since the groups do not exist yet, and HAVING can.

Put conditions on single rows, such as a status or a date range, in WHERE, and conditions on group results, such as COUNT(*) > 1, in HAVING. The query below finds customers with more than one paid order: only customer 7, with two orders and 200.00. Remove the WHERE line and customers 9 and 12 appear too, because their cancelled and pending orders now count.

The same shape is the usual duplicate check: SELECT email, COUNT(*) FROM customers GROUP BY email HAVING COUNT(*) > 1 lists every email address stored on more than one customer.

Logical order of the paid-orders query on six orders: WHERE keeps 4 PAID rows, GROUP BY makes 3 customer groups, and HAVING COUNT(*) > 1 keeps only customer 7.
  1. 1Read the rows6 rows: orders 1001 to 1006FROM orders
  2. 2Filter rows4 rows remain: 1001, 1002, 1004, 1006WHERE status = 'PAID'row filter

    Branch: Removed: 1003 CANCELLED, 1005 PENDING

  3. 3Form groups3 groups: customer 7 (1001, 1002), 9 (1004), 12 (1006)GROUP BY customer_id
  4. 4Compute aggregates7: 2 orders, 200.00 · 9: 1 order, 200.00 · 12: 1 order, 15.00COUNT(*), SUM(total_amount)
  5. 5Filter groups1 group remains: customer 7HAVING COUNT(*) > 1group filter

    Branch: Removed: customers 9 and 12, one paid order each

  6. 6Produce the output1 result row: customer 7, 2 orders, 200.00SELECT customer_id, …

Throughout steps 4–6 Aggregates exist here: HAVING can test them, WHERE cannot

Logical order: the engine may execute the query differently, with the same result.

-- Customers with more than one PAID order
SELECT customer_id,
       COUNT(*)          AS paid_orders,
       SUM(total_amount) AS paid_amount
FROM orders
WHERE status = 'PAID'      -- row filter, before grouping
GROUP BY customer_id
HAVING COUNT(*) > 1;       -- group filter, after aggregation

-- customer_id  paid_orders  paid_amount
-- 7                      2       200.00

Reconciliation checks: two numbers that must agree

Reconciliation computes the same count or total in two places and compares them. The second place is the test oracle, the source of the expected result: a document header against its lines, the source system against the target after an import, the database against a report or an API response.

Write the check so that a correct database returns zero rows: group by the shared key and keep only the groups that disagree with HAVING. Every row returned is evidence for a bug report. Compare per group, not only the grand total, where two opposite errors can cancel out.

  • Join fan-out: joining orders to their lines repeats each order once per line, so SUM(o.total_amount) after the join inflates the total. Aggregate the lines per order, as the code block does.
  • Missing rows: an order without lines vanishes from an inner join. A LEFT JOIN keeps it, but its SUM is NULL and total_amount <> NULL is never true, so HAVING still drops it; COALESCE turns the NULL into 0 and the mismatch shows.
-- Rule: an order's total_amount equals the sum of its item lines
SELECT o.order_id,
       o.total_amount,
       COALESCE(SUM(oi.quantity * oi.unit_price), 0) AS lines_total
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.order_id, o.total_amount
HAVING o.total_amount <> COALESCE(SUM(oi.quantity * oi.unit_price), 0);

-- Zero rows: the rule holds. Every row returned is a mismatch to report.

Worked example: reconciling a daily sales report

Requirement: "The daily sales report shows, for each day, the number of PAID orders and their total amount, in Riyadh time." created_at is stored in Riyadh time, so you group the paid orders by date for 1 to 3 September and compare each day with the report. CAST(created_at AS DATE) drops the time of day, so all orders of one date fall into one group.

Two days match; 2 September does not. A difference in both count and amount suggests extra rows rather than wrong amounts, so drill into that day grouped by every status.

  • Database: 38 orders, 5720.50. Report: 40 orders, 5905.50. Difference: 2 orders, 185.00.
  • By status: CANCELLED has 2 orders and 185.00, exactly the difference. Matching figures are a strong hint, not proof: if the report can list its orders, confirm that these two are in it.
  • Bug report: daily sales appear to include orders cancelled after payment. Attach both queries and their results, and ask the product owner whether such orders belong in daily sales, since the requirement does not say.
-- 1. Expected values: PAID orders per day, 1 to 3 September
--    created_at holds Riyadh local time; in SQLite write date(created_at)
SELECT CAST(created_at AS DATE) AS sales_day,
       COUNT(*)                 AS paid_orders,
       SUM(total_amount)        AS paid_amount
FROM orders
WHERE status = 'PAID'
  AND created_at >= '2026-09-01' AND created_at < '2026-09-04'
GROUP BY CAST(created_at AS DATE)
ORDER BY sales_day;

-- sales_day   database        report          result
-- 2026-09-01  41  6150.00     41  6150.00     match
-- 2026-09-02  38  5720.50     40  5905.50     mismatch
-- 2026-09-03  44  6480.00     44  6480.00     match

-- 2. Drill down into 2 September, every status
SELECT status, COUNT(*) AS orders, SUM(total_amount) AS amount
FROM orders
WHERE created_at >= '2026-09-02' AND created_at < '2026-09-03'
GROUP BY status
ORDER BY status;

-- CANCELLED   2   185.00    <- exactly the difference
-- PAID       38  5720.50
-- PENDING     3   240.00

Try it: SQL VALUES Generator Try it: CSV to SQL

Common mistakes

  • Using COUNT(column) to count rows; it skips NULLs.
  • Comparing SUM(...) without COALESCE, so a group whose sum is NULL drops out of a mismatch check.
  • Selecting columns that are neither grouped nor aggregated and trusting whatever the engine returns.
  • Filtering rows in HAVING, or trying to use an aggregate in WHERE.
  • Summing header amounts after joining them to their lines.
  • Reconciling only the grand total instead of per day, status or batch.

The QA perspective

Aggregate checks belong wherever a feature produces numbers: reports, dashboards, invoices, imports, exports and migrations. In test analysis, write each rule as a sentence that can become a query and agree its exact filters with the product owner: which statuses count, which time zone defines a day, whether test accounts are excluded.

Run queries with a read-only account, bounded by dates or keys. Record both sides of each comparison with the test case: the query, its result, the other figure and the difference. When a check fails, drill down with a finer GROUP BY until the difference is explained, and put the queries in the bug report so the developer can rerun them.

Practice exercise

Reconcile invoices with their lines

Your task

An invoicing service stores headers and lines. Rule: an invoice's total equals the sum of quantity * unit_price of its lines, and every invoice has at least one line. The tables below are the complete test data.

Write a query that returns only the invoices that break the rule and work out its result on paper. Then write a query that counts invoices and sums their totals per customer.

Material
invoices
invoice_id  customer_id  total
501         21           90.00
502         21           45.00
503         22           60.00
504         23           25.00

invoice_lines
line_id  invoice_id  quantity  unit_price
1        501         2         30.00
2        501         1         30.00
3        502         3         15.00
4        503         1         40.00
5        503         1         15.00
Steps
  1. Identify the header, the detail and the join key.
  2. Pick a join type that keeps invoices without lines.
  3. Group by invoice, keep mismatches with HAVING and handle a NULL sum.
  4. Check each invoice by hand.
Show the model answer
Model answer

Query: SELECT i.invoice_id, i.total, COALESCE(SUM(l.quantity * l.unit_price), 0) AS lines_total FROM invoices i LEFT JOIN invoice_lines l ON l.invoice_id = i.invoice_id GROUP BY i.invoice_id, i.total HAVING i.total <> COALESCE(SUM(l.quantity * l.unit_price), 0).

Result: invoice 503 (total 60.00, lines 40.00 + 15.00 = 55.00) and invoice 504 (total 25.00, no lines, lines_total 0). Invoices 501 (90.00) and 502 (45.00) agree. With an inner join or without COALESCE, 504 would be missed. The amount check would also miss an invoice without lines whose total is 0.00; adding OR COUNT(l.line_id) = 0 to HAVING tests the second part of the rule on its own.

Per customer: SELECT customer_id, COUNT(*) AS invoices, SUM(total) AS amount FROM invoices GROUP BY customer_id ORDER BY customer_id gives 21: 2 invoices, 135.00; 22: 1, 60.00; 23: 1, 25.00, which adds up to 220.00. Inner-joined to the lines first, customer 21 would wrongly show 3 invoices and 225.00, customer 22 would show 2 and 120.00, and customer 23 would disappear.

Frequently asked questions

What is the difference between WHERE and HAVING?

WHERE filters individual rows before they are grouped and cannot use aggregate functions. HAVING filters whole groups after the aggregates are computed, so it can use COUNT, SUM and the others. A query can use both: WHERE for the rows that belong in the calculation, HAVING for the groups you want to see.

Why does COUNT(*) give a different number from COUNT(column)?

COUNT(*) counts rows; COUNT(column) counts only the rows where that column is not NULL. The difference between the two is the number of rows with NULL in that column, which is itself a useful check on fields that should be required.

Can I use a SELECT alias in HAVING?

Not portably. In the logical order HAVING comes before SELECT, and PostgreSQL and SQL Server reject the alias there, while MySQL and SQLite accept it. Repeat the aggregate expression, as the examples in this lesson do, and the query runs on every engine.

Why is a group I expected missing from the result?

A group exists only if at least one row reaches GROUP BY. A day with no orders, or a status with no rows left after WHERE, produces no row at all, not a row with 0. To show missing groups, start from a list of the expected days or keys and LEFT JOIN the aggregated data to it.

References

Practise with Mutqan tools

Check SQL statements for syntax problems, unbalanced parentheses, unterminated strings and risky patterns.

Database

Format SQL queries with consistent keyword casing, indentation and line breaks (MySQL, PostgreSQL, SQL Server, Oracle, SQLite).

Database

Generate INSERT statements from a table definition, sample rows, CSV or JSON.

Database

Generate SQL seed scripts (CREATE TABLE + INSERTs) with realistic test rows for any dialect.

Test Data

Convert tabular data into a SQL VALUES list or a VALUES-based derived table.

Database

Convert CSV data into CREATE TABLE and INSERT statements with inferred column types.

Database

Go deeper

Your progress on this topic

Part of the QA Roadmap