🚧 Under construction — VoTechWorks is being built. A free tech program — complimentary, as our commitment to get America working.
voTechWorksGrade 13 · a vocational technology learning center, by urbanicity
newSchoolWorks.com › VoTechWorks › Technology & Design › Web & Software Development › SQL and PostgreSQL Database Development › Lesson 6
TEC-WEB46-04 · Lesson 6 · 15 clock hours · lab

Aggregation, Grouping, Subqueries, and CTEs

Objective. Students will use GROUP BY, HAVING, aggregate functions, correlated and non-correlated subqueries, and CTEs to answer analytical questions against relational data.

⚠ 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.

Aggregation, Grouping, Subqueries, and CTEs

Once you can retrieve rows from a database, the next step is to summarize them. Analytical questions like "How many books did each branch lend last quarter?" or "Which patron checked out the most items?" require you to collapse many rows into a single answer per group. PostgreSQL gives you powerful tools for this: aggregate functions, `GROUP BY`, `HAVING`, subqueries, and Common Table Expressions (CTEs).

Aggregate Functions

Aggregate functions operate on a set of rows and return one value per group. The five you will use constantly are:

Without `GROUP BY`, the aggregate collapses the entire result set into one row. With `GROUP BY`, you get one row per unique combination of the grouping columns.

GROUP BY and HAVING

`GROUP BY` tells PostgreSQL which column(s) define each group. Every column in your `SELECT` list that is not inside an aggregate function must appear in the `GROUP BY` clause, or the query will error.

```sql

SELECT branch_id, COUNT(*) AS total_loans

FROM loans

GROUP BY branch_id;

```

`WHERE` filters rows before grouping. `HAVING` filters after grouping, so you can use aggregate results as conditions:

```sql

SELECT branch_id, COUNT(*) AS total_loans

FROM loans

GROUP BY branch_id

HAVING COUNT(*) > 500;

```

A common mistake is writing `HAVING total_loans > 500` using a column alias. PostgreSQL evaluates `HAVING` before the alias is finalized in most contexts, so prefer repeating the aggregate expression inside `HAVING`.

Subqueries

A subquery is a `SELECT` statement nested inside another query. There are two main types.

Non-correlated subqueries run once and return a value (or set of values) that the outer query uses. They do not reference any column from the outer query:

```sql

SELECT title

FROM books

WHERE book_id IN (

SELECT book_id FROM loans WHERE return_date IS NULL

);

```

Correlated subqueries reference a column from the outer query, so they re-execute for every row the outer query processes. They are flexible but can be slow on large tables:

```sql

SELECT p.patron_name,

(SELECT COUNT(*) FROM loans l WHERE l.patron_id = p.patron_id) AS loan_count

FROM patrons p;

```

Use correlated subqueries when the logic genuinely depends on each outer row. Otherwise, a `JOIN` or CTE is usually faster and easier to read.

Common Table Expressions (CTEs)

A CTE creates a named, temporary result set at the top of your query using the `WITH` keyword. It exists only for the duration of that statement.

```sql

WITH branch_totals AS (

SELECT branch_id, COUNT(*) AS total_loans

FROM loans

GROUP BY branch_id

)

SELECT b.branch_name, bt.total_loans

FROM branch_totals bt

JOIN branches b ON b.branch_id = bt.branch_id

ORDER BY bt.total_loans DESC;

```

You can chain multiple CTEs by separating them with commas after the first `WITH`. Each later CTE can reference any earlier one. This step-by-step structure makes complex logic far easier to read, test, and maintain than deeply nested subqueries.

Introducing Window Functions: ROW_NUMBER and RANK

Aggregate functions collapse rows. Window functions compute a value for each row while keeping all rows visible. `ROW_NUMBER()` assigns a unique sequential integer to each row within a partition. `RANK()` assigns the same number to ties and then skips numbers.

Both require an `OVER` clause that defines the partition and ordering:

```sql

ROW_NUMBER() OVER (PARTITION BY branch_id ORDER BY loan_count DESC)

```

In the challenge exercise you will wrap this inside a CTE to filter down to a top-5 list. The outer query simply adds `WHERE rn <= 5`.

Trade-offs: Subquery vs. CTE

| Factor | Correlated Subquery | CTE |

|---|---|---|

| Readability | Low for complex logic | High — named, step-by-step |

| Performance | Can be slow (re-executes per row) | Often similar; optimizer may inline |

| Reusability | Cannot reference again | Can be referenced multiple times |

| Debugging | Hard to isolate | Easy — run each CTE alone |

Neither is always faster; use `EXPLAIN ANALYZE` in PostgreSQL to compare actual execution plans.

Safety and Professional Practice

SQL errors rarely cause physical hazards, but data integrity is your responsibility. Always run analytical queries against a development or read-only copy of production data first. Never use `DELETE` or `UPDATE` in the same session as exploratory `SELECT` queries without a transaction (`BEGIN`/`ROLLBACK`) as a safety net. Back up the library dataset before the lab. Keep credentials (database passwords) out of your `.sql` files — use environment variables or a `.pgpass` file instead.

Annotate every query: future maintainers (including yourself) need to understand why you grouped or filtered the way you did, not just what the SQL says.

Key terms8

Aggregate functionA function such as COUNT, SUM, AVG, MIN, or MAX that operates on a set of rows and returns a single summarized value per group.
GROUP BYA SQL clause that divides query results into groups based on one or more columns so that aggregate functions can be applied to each group separately.
HAVINGA clause that filters groups after aggregation has occurred, allowing conditions that reference aggregate results such as COUNT(*) > 100.
Non-correlated subqueryA subquery that runs independently of the outer query, executes once, and passes its result (a value, list, or table) to the outer query.
Correlated subqueryA subquery that references a column from the outer query and therefore re-executes once for every row the outer query processes.
Common Table Expression (CTE)A named, temporary result set defined with the WITH keyword at the start of a query; it exists only for that statement and can be referenced like a table within the query.
Window functionA function such as ROW_NUMBER() or RANK() that computes a value for each row while keeping all rows in the result set, using an OVER clause to define a partition and ordering.
EXPLAIN ANALYZEA PostgreSQL command that executes a query and returns the actual execution plan with timing information, used to compare query performance.

Worked examples3

1. Find each branch's total number of loans and include only branches with more than 200 loans, ordered from highest to lowest.
  1. Identify the table: loans. Identify the grouping column: branch_id. Identify the aggregate needed: COUNT(*) for total loans.
  2. Write the SELECT list: branch_id and COUNT(*) aliased as total_loans.
  3. Add GROUP BY branch_id to collapse rows per branch.
  4. Add HAVING COUNT(*) > 200 to filter after aggregation — WHERE cannot reference aggregates.
  5. Add ORDER BY total_loans DESC to sort results.
  6. Final query: SELECT branch_id, COUNT(*) AS total_loans FROM loans GROUP BY branch_id HAVING COUNT(*) > 200 ORDER BY total_loans DESC;
Answer: The query returns one row per branch that has more than 200 loan records, sorted so the busiest branch appears first.
2. Rewrite the above aggregation query as a CTE, then join the result to the branches table to show branch_name instead of branch_id.
  1. Move the aggregation into a CTE named branch_totals using WITH branch_totals AS ( ... ).
  2. Inside the CTE parentheses, place the GROUP BY query without the JOIN or ORDER BY.
  3. After the closing parenthesis, write the outer SELECT that joins branch_totals to the branches table on branch_id.
  4. Select b.branch_name and bt.total_loans in the outer SELECT, then add ORDER BY bt.total_loans DESC.
  5. Final query: WITH branch_totals AS ( SELECT branch_id, COUNT(*) AS total_loans FROM loans GROUP BY branch_id HAVING COUNT(*) > 200 ) SELECT b.branch_name, bt.total_loans FROM branch_totals bt JOIN branches b ON b.branch_id = bt.branch_id ORDER BY bt.total_loans DESC;
Answer: The CTE version is easier to read because the aggregation logic is isolated and named; the outer query reads like plain English: 'show branch names and their totals.'
3. Use a CTE chain with ROW_NUMBER() to produce a ranked top-5 list of patrons by total loans borrowed.
  1. Write a first CTE named patron_totals that counts loans per patron: SELECT patron_id, COUNT(*) AS loan_count FROM loans GROUP BY patron_id.
  2. Write a second CTE named ranked_patrons that selects from patron_totals and applies ROW_NUMBER() OVER (ORDER BY loan_count DESC) AS rn.
  3. Write the outer query that selects from ranked_patrons WHERE rn <= 5, joining to the patrons table to show patron names.
  4. Separate the two CTEs with a comma after the first closing parenthesis; only one WITH keyword is used.
  5. Final query: WITH patron_totals AS ( SELECT patron_id, COUNT(*) AS loan_count FROM loans GROUP BY patron_id ), ranked_patrons AS ( SELECT patron_id, loan_count, ROW_NUMBER() OVER (ORDER BY loan_count DESC) AS rn FROM patron_totals ) SELECT p.patron_name, rp.loan_count, rp.rn FROM ranked_patrons rp JOIN patrons p ON p.patron_id = rp.patron_id WHERE rp.rn <= 5 ORDER BY rp.rn;
Answer: The result is five rows: the five patrons who have borrowed the most items, numbered 1 through 5. ROW_NUMBER() assigns distinct ranks even if two patrons have the same count; use RANK() if you want ties to share a rank number.
In class: Answer 15 business questions about the library dataset (e.g., 'Which branch has the highest average loans per month?') using GROUP BY with COUNT, SUM, AVG, MIN, MAX, and HAVING filters; annotate each query with a plain-English explanation. · Rewrite three aggregation queries first as correlated subqueries, then as CTEs using WITH clauses; compare readability and discuss trade-offs in a short written reflection (one paragraph per rewrite). · Complete a challenge exercise: write a single CTE chain of three named queries that computes a ranked top-5 list, using ROW_NUMBER() or RANK() as an introduction to window functions, and explain the output row by row.
Materials: Library dataset, psql, pgAdmin, aggregation and CTE exercise worksheet, PostgreSQL Aggregate Functions documentation page
Check: Students submit annotated .sql files; instructor runs each query and checks output against answer key; pass = 80% of queries returning correct results with correct SQL constructs used.

Practice check8

Answer each question, then check it. Nothing is recorded; this is your own practice.

Database concepts and SQL fundamentals
1. A developer writes: SELECT branch_id, COUNT(*) AS loans FROM loans WHERE COUNT(*) > 100 GROUP BY branch_id; — What is wrong with this query?
Database concepts and SQL fundamentals
2. Which SQL clause filters rows BEFORE the GROUP BY operation is applied?
Database concepts and SQL fundamentals
3. A correlated subquery differs from a non-correlated subquery in that a correlated subquery:
Database concepts and SQL fundamentals
4. In a CTE chain using WITH, how do you define a second CTE that references the first?
Database concepts and SQL fundamentals
5. What does ROW_NUMBER() OVER (ORDER BY loan_count DESC) produce?
Database concepts and SQL fundamentals
6. You need to find the average number of loans per patron, but only for patrons who have borrowed at least 10 books. Which query structure is correct?
Database administration and performance
7. Which PostgreSQL command helps you compare the actual execution performance of a subquery versus a CTE version of the same logic?
Database concepts and SQL fundamentals
8. A student wants to list each book title alongside the total number of times it has been loaned, using a CTE for readability. The loans table has columns book_id and loan_id; the books table has book_id and title. Which CTE structure is correct?
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.