TEC-DATA85-04 · Lesson 3 · 11 clock hours · practice
Filtering, Aggregation, and Grouping
Objective. Students write queries using GROUP BY, HAVING, and aggregate functions (COUNT, SUM, AVG, MIN, MAX) to produce summary statistics from multi-row datasets.
⚠ Pending professional review
This lesson, its worked examples and its practice questions has not yet been verified by an industry-certified professional or by a certification or approval body. It is a study draft and may contain errors. Do not use it as instructions for work on a job site or on live equipment. Always follow your instructor, your supervising professional, and current official references and regulations.
VoTechWorks program approval and accreditation are still in progress. Credentials are issued only by the certifying bodies named in each program, after their own exams.
Why Aggregation Matters in Data Analytics
Raw tables store one fact per row. A table of library checkouts might have hundreds of thousands of rows, each recording a single transaction. Decision-makers rarely need every row; they need summaries: How many books did each branch lend last month? Which patrons check out the most items? Which branches are underperforming? SQL's aggregation tools let you collapse many rows into meaningful summary numbers without ever leaving the database.
Aggregate Functions
PostgreSQL (and standard SQL) provides five core aggregate functions you will use constantly:
- COUNT(*) — counts all rows in a group, including rows with NULL values in other columns.
- COUNT(column) — counts only non-NULL values in that column.
- SUM(column) — adds up all numeric values in the column.
- AVG(column) — returns the arithmetic mean of non-NULL numeric values.
- MIN(column) / MAX(column) — return the smallest or largest value, and work on numbers, dates, and text alike.
These functions appear in the SELECT list. On their own, without GROUP BY, they collapse the entire result set into a single summary row:
```sql
SELECT COUNT(*) AS total_checkouts,
AVG(days_borrowed) AS avg_days
FROM checkouts;
```
GROUP BY: Splitting Results into Groups
When you add GROUP BY, PostgreSQL divides the rows into subsets and applies the aggregate function to each subset independently. Every column in the SELECT list must either appear inside an aggregate function or be listed in the GROUP BY clause — otherwise the query will raise an error, because the engine would not know which row's value to display for that column.
```sql
SELECT branch_name,
COUNT(*) AS total_checkouts
FROM checkouts
GROUP BY branch_name
ORDER BY total_checkouts DESC;
```
This returns one row per branch with the total checkouts for that branch.
WHERE vs. HAVING: A Critical Distinction
This is the most common source of errors for new SQL writers, and it appears on certification exams:
- WHERE filters individual rows before groups are formed. It cannot reference aggregate functions because aggregation has not happened yet.
- HAVING filters groups after aggregation is complete. It can reference aggregate functions.
Mnemonic: WHERE comes Where the raw data lives; HAVING comes Hafter (after) aggregation.
```sql
-- Correct: filter raw rows with WHERE, then group
SELECT branch_name, COUNT(*) AS total
FROM checkouts
WHERE checkout_year = 2023
GROUP BY branch_name
HAVING COUNT(*) > 500;
```
The WHERE clause removes rows from years other than 2023 before any grouping occurs. The HAVING clause then keeps only branches whose 2023 count exceeds 500. Swapping HAVING for WHERE here would cause an error because WHERE cannot evaluate COUNT(*).
Query Execution Order
Understanding the logical order in which PostgreSQL processes clauses prevents many mistakes:
1. FROM — identify the table(s)
2. WHERE — filter rows
3. GROUP BY — form groups
4. HAVING — filter groups
5. SELECT — compute output columns and aliases
6. ORDER BY — sort results
7. LIMIT — restrict row count
Because SELECT is evaluated at step 5, column aliases created in SELECT are not available in WHERE or HAVING in standard SQL. In PostgreSQL, ORDER BY can reference SELECT aliases, but HAVING generally cannot.
Combining Filters and Aggregates
You can use WHERE and HAVING together in the same query. A realistic example: find branches that served more than 200 patrons in the first quarter of 2024:
```sql
SELECT branch_name,
COUNT(DISTINCT patron_id) AS unique_patrons
FROM checkouts
WHERE checkout_date BETWEEN '2024-01-01' AND '2024-03-31'
GROUP BY branch_name
HAVING COUNT(DISTINCT patron_id) > 200
ORDER BY unique_patrons DESC;
```
COUNT(DISTINCT patron_id) counts each patron only once per branch even if they checked out multiple items.
Readability and Professional Standards
In professional settings, readable SQL is as important as correct SQL. Follow these conventions:
- Write SQL keywords in uppercase (SELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY).
- Use meaningful aliases with AS.
- Indent continuation lines two to four spaces.
- Add a comment for any non-obvious logic using -- or / /.
Data Integrity and Safety Notes
Data analytics work does not involve physical hazards in the traditional sense, but it carries its own professional safety obligations:
- Never run UPDATE, DELETE, or DROP statements on production data without explicit authorization and a verified backup. In this course you will only query the practice library database, but form the habit now.
- Verify row counts before and after transformations. A query that silently drops rows due to an incorrect JOIN or WHERE condition produces wrong summaries — a form of analytic error that can mislead real decisions.
- Protect personally identifiable information (PII). Even in practice datasets, treat patron IDs and names as if they were real. Do not share query results containing PII outside the classroom environment.
Summary
Aggregate functions (COUNT, SUM, AVG, MIN, MAX) collapse multiple rows into single summary values. GROUP BY splits the data into subsets before aggregation. WHERE filters raw rows before grouping; HAVING filters groups after aggregation. Keeping the logical execution order in mind and writing readable, well-commented SQL are foundational professional skills that every credential exam will test.
Key terms8
Aggregate functionA SQL function that computes a single summary value from a set of rows; the five core functions are COUNT, SUM, AVG, MIN, and MAX.
GROUP BYA SQL clause that divides query results into subsets based on one or more columns, applying aggregate functions to each subset independently.
HAVINGA SQL clause that filters groups produced by GROUP BY; it is evaluated after aggregation and can reference aggregate functions, unlike WHERE.
WHEREA SQL clause that filters individual rows before any grouping or aggregation occurs; it cannot reference aggregate functions.
COUNT(DISTINCT column)A variant of COUNT that counts only unique non-NULL values in a column, eliminating duplicates from the total.
Query execution orderThe logical sequence in which a SQL engine processes clauses: FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT.
AliasA temporary name assigned to a column or table in a query using the AS keyword, improving readability of output without changing the underlying data.
AVGAn aggregate function that returns the arithmetic mean of non-NULL numeric values in a column across all rows in a group.
Worked examples4
1. Write a query that returns each branch name and its total number of checkout transactions, ordered from most to fewest checkouts, using the checkouts table.
- Identify the grouping column: branch_name. Each branch should appear once in the output.
- Choose the aggregate function: COUNT(*) counts every row in each group, giving the total transactions per branch.
- Write the SELECT list: include branch_name (must appear in GROUP BY) and COUNT(*) aliased as total_checkouts.
- Add GROUP BY branch_name so PostgreSQL forms one group per branch.
- Add ORDER BY total_checkouts DESC to rank branches from most to fewest.
Answer: SELECT branch_name,
COUNT(*) AS total_checkouts
FROM checkouts
GROUP BY branch_name
ORDER BY total_checkouts DESC;
2. Find every branch whose average number of days_borrowed per checkout exceeds 14, but only considering checkouts made during the year 2023.
- Use WHERE checkout_year = 2023 to remove rows from other years before any grouping — WHERE operates on raw rows.
- Add GROUP BY branch_name to form one group per branch from the filtered rows.
- Use AVG(days_borrowed) to compute the mean loan duration for each branch group.
- Use HAVING AVG(days_borrowed) > 14 to keep only groups whose average exceeds 14. HAVING is required here because the condition references an aggregate result.
- Select branch_name and AVG(days_borrowed) aliased as avg_days in the SELECT list.
Answer: SELECT branch_name,
AVG(days_borrowed) AS avg_days
FROM checkouts
WHERE checkout_year = 2023
GROUP BY branch_name
HAVING AVG(days_borrowed) > 14
ORDER BY avg_days DESC;
3. Count the number of distinct patrons served by each branch. A patron who visited the same branch three times should be counted only once for that branch.
- Recognize that COUNT(*) would count all rows, including repeat visits by the same patron, which is not what is asked.
- Use COUNT(DISTINCT patron_id) instead, which counts each patron_id value only once per group regardless of how many rows contain it.
- Group by branch_name so each branch produces one output row.
- Alias the result as unique_patrons for clarity.
Answer: SELECT branch_name,
COUNT(DISTINCT patron_id) AS unique_patrons
FROM checkouts
GROUP BY branch_name
ORDER BY unique_patrons DESC;
4. A student writes the following query intending to find branches with more than 100 checkouts in 2023, but it produces an error. Identify and fix it.
SELECT branch_name, COUNT(*) AS total
FROM checkouts
WHERE COUNT(*) > 100
AND checkout_year = 2023
GROUP BY branch_name;
- Locate the error: COUNT(*) appears inside a WHERE clause. WHERE is processed before aggregation, so aggregate functions are not yet available there — this causes a syntax error in PostgreSQL.
- The filter on checkout_year = 2023 is a row-level filter and correctly belongs in WHERE.
- Move COUNT(*) > 100 out of WHERE and into a HAVING clause, which is evaluated after GROUP BY and can reference aggregate functions.
- Keep checkout_year = 2023 in the WHERE clause since it filters individual rows, not groups.
Answer: SELECT branch_name,
COUNT(*) AS total
FROM checkouts
WHERE checkout_year = 2023
GROUP BY branch_name
HAVING COUNT(*) > 100;
In class: Demonstration and guided notes: instructor live-codes progressively complex GROUP BY queries against the library dataset, narrating each clause; students follow along and annotate printed SQL syntax cards. · Error-correction exercise: students receive five intentionally broken queries (wrong GROUP BY columns, HAVING vs. WHERE confusion) and must identify and fix each error in psql. · Mini-project: students write a four-query analytical memo answering questions such as total checkouts by branch, average checkouts per patron, and branches whose monthly average exceeds a threshold, then share findings with a partner for peer review.
Materials: Library-checkouts PostgreSQL database from Lesson 2, SQL syntax reference cards (instructor-prepared), peer-review rubric
Check: Partner peer review scored with rubric (query correctness, readability, and correct use of HAVING vs. WHERE); both partners must earn 80% to log the activity complete; instructor spot-checks three pairs.
Practice check8
Answer each question, then check it. Nothing is recorded; this is your own practice.
Record this lesson on my transcript. Sign in with your key and enrollment email to record lessons (enroll free if you have no key). This page stays open to read and practise.