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, andCOUNT(DISTINCT column)counts different non-NULL values.SUMover no rows, or over NULLs only, returns NULL, not 0: compareCOALESCE(SUM(amount), 0).AVGin SQL Server returns an integer for an integer column; cast to a decimal type first.MINandMAXwork on numbers, dates and text, which makes them a quick range check, such as the earliestcreated_atof 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;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.00WHERE 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.
- 1Read the rows6 rows: orders 1001 to 1006
FROM orders - 2Filter rows4 rows remain: 1001, 1002, 1004, 1006
WHERE status = 'PAID'row filterBranch: Removed: 1003 CANCELLED, 1005 PENDING
- 3Form groups3 groups: customer 7 (1001, 1002), 9 (1004), 12 (1006)
GROUP BY customer_id - 4Compute aggregates7: 2 orders, 200.00 · 9: 1 order, 200.00 · 12: 1 order, 15.00
COUNT(*), SUM(total_amount) - 5Filter groups1 group remains: customer 7
HAVING COUNT(*) > 1group filterBranch: Removed: customers 9 and 12, one paid order each
- 6Produce the output1 result row: customer 7, 2 orders, 200.00
SELECT 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.00Reconciliation 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 <> NULLis 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.00Common 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
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.
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- Identify the header, the detail and the join key.
- Pick a join type that keeps invoices without lines.
- Group by invoice, keep mismatches with HAVING and handle a NULL sum.
- Check each invoice by hand.
Show the 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
- PostgreSQL Documentation: SQL SyntaxLexical structure of SQL in PostgreSQL: identifiers, quoting, string constants and escaping.
- SQL As Understood By SQLiteThe SQL grammar accepted by SQLite, statement by statement, with railroad diagrams.
- ISO/IEC 9075-1:2023 — Information technology — Database languages SQL — Part 1: Framework (SQL/Framework)The framework part of the international SQL standard, which the dialects of PostgreSQL, MySQL, SQLite and others implement to different degrees.
- ISTQB GlossaryThe standard definitions of testing terms — test case, regression testing, severity, priority, boundary value analysis and the rest.
Practise with Mutqan tools
Check SQL statements for syntax problems, unbalanced parentheses, unterminated strings and risky patterns.
Format SQL queries with consistent keyword casing, indentation and line breaks (MySQL, PostgreSQL, SQL Server, Oracle, SQLite).
Generate INSERT statements from a table definition, sample rows, CSV or JSON.
Generate SQL seed scripts (CREATE TABLE + INSERTs) with realistic test rows for any dialect.
Convert tabular data into a SQL VALUES list or a VALUES-based derived table.
Convert CSV data into CREATE TABLE and INSERT statements with inferred column types.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.