TEC-DATA85-04 · Lesson 5 · 12 clock hours · demo
Subqueries, CTEs, and Window Functions
Objective. Students rewrite nested subqueries as Common Table Expressions (CTEs) and apply window functions (ROW_NUMBER, RANK, LAG, LEAD, SUM OVER) to answer analytical questions that aggregate and detail simultaneously.
⚠ 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 Nested Subqueries Become a Problem
A subquery is a SELECT statement placed inside another SQL statement. Nesting subqueries is perfectly valid, but once you add two or three levels, the query becomes hard to read, hard to debug, and hard to hand off to a teammate. A correlated subquery—one that references columns from the outer query—re-executes for every row the outer query processes. On a large table this can be slow and the logic is often buried where reviewers miss it.
The goal of this lesson is to give you two tools that solve these problems cleanly: Common Table Expressions (CTEs) and window functions.
---
Common Table Expressions (CTEs)
A CTE is a named, temporary result set that lives only for the duration of the query. You define it with the `WITH` keyword before your main SELECT.
```sql
WITH monthly_sales AS (
SELECT
DATE_TRUNC('month', sale_date) AS month,
region,
SUM(amount) AS total
FROM sales_transactions
GROUP BY 1, 2
)
SELECT *
FROM monthly_sales
WHERE total > 50000;
```
The CTE `monthly_sales` is computed once, given a name, and then referenced like a table. Benefits over a nested subquery include:
- Readability – the logic is at the top, named, and self-documenting.
- Reusability – the same CTE name can appear multiple times in the main query.
- Debuggability – you can SELECT from the CTE alone while building the query.
You can chain multiple CTEs by separating them with commas after the WITH keyword.
```sql
WITH step1 AS ( ... ),
step2 AS (
SELECT * FROM step1 WHERE ...
)
SELECT * FROM step2;
```
---
Window Functions: Aggregating Without Collapsing Rows
A regular aggregate like `SUM()` with `GROUP BY` reduces many rows to one. A window function performs a calculation across a set of rows related to the current row but keeps every row in the output. This lets you see both the detail row and the aggregate answer at the same time—something impossible with GROUP BY alone.
The syntax uses the `OVER` clause:
```sql
function_name(column) OVER (
PARTITION BY partition_column
ORDER BY sort_column
ROWS/RANGE frame_specification
)
```
PARTITION BY divides rows into groups (like GROUP BY, but without collapsing). ORDER BY inside OVER sets the row order within each partition.
Five Key Window Functions
ROW_NUMBER() assigns a unique sequential integer to each row within its partition, starting at 1. Ties receive different numbers—whichever row PostgreSQL processes first gets the lower number.
RANK() assigns the same rank to tied rows, then skips numbers. If two rows tie for rank 1, the next rank is 3.
LAG(column, n) returns the value of `column` from the row that is `n` rows before the current row in the partition order. Useful for period-over-period comparisons.
LEAD(column, n) does the same thing forward—the row `n` rows after the current row.
SUM() OVER (...) computes a running total or a partition total without collapsing rows.
---
Combining CTEs and Window Functions
The real power appears when you use a CTE to prepare clean data and then apply a window function in the main SELECT—or in a second CTE.
```sql
WITH permit_totals AS (
SELECT
contractor_id,
permit_category,
SUM(permit_value) AS total_value
FROM city_permits
GROUP BY contractor_id, permit_category
),
ranked AS (
SELECT
contractor_id,
permit_category,
total_value,
RANK() OVER (
PARTITION BY permit_category
ORDER BY total_value DESC
) AS category_rank
FROM permit_totals
)
SELECT
contractor_id,
permit_category,
total_value,
category_rank,
CASE WHEN category_rank = 1 THEN TRUE ELSE FALSE END AS top_performer
FROM ranked
ORDER BY permit_category, category_rank;
```
This pattern—aggregate in CTE, rank in second CTE or main SELECT, then flag—is the template for the lesson challenge query.
---
Data Accuracy and Safe Practice
Data analysts do not work with high-voltage equipment, but data safety is real and professionally serious.
- Never run a destructive statement (DELETE, UPDATE, DROP) on a shared or production database without a verified backup and explicit authorization. Treat shared datasets the way a lab technician treats a shared instrument: leave it in the same condition you found it.
- When testing window function queries, use a local copy or a dedicated practice schema so that no other student's work is affected.
- Validate query results before sharing them with stakeholders. A misread RANK() (mistaking it for ROW_NUMBER()) can produce an incorrect "top performer" that drives a real business decision. Review your output against a small, hand-checked sample.
- Version-control your `.sql` files. Saving query history protects you when a result is questioned later.
---
Quick Reference: Subquery vs. CTE
| Factor | Nested Subquery | CTE |
|---|---|---|
| Readability | Decreases with depth | Stays flat and named |
| Reuse in same query | Must repeat code | Reference by name |
| Debugging | Must extract manually | SELECT from CTE name |
| Performance | Varies; correlated can be slow | Optimizer treats similarly in PostgreSQL |
PostgreSQL's query planner generally handles both approaches, but CTEs in PostgreSQL are sometimes treated as optimization fences (the planner may not push predicates through them). Be aware of this when working with very large tables and test execution plans with `EXPLAIN ANALYZE`.
Key terms9
Common Table Expression (CTE)A named, temporary result set defined with the WITH keyword that exists only for the duration of the query; it improves readability and allows a logical step to be referenced by name more than once.
Window functionA SQL function that performs a calculation across a set of rows related to the current row while preserving all rows in the output, using the OVER clause to define the window.
PARTITION BYA clause inside OVER that divides the rows into independent groups before a window function is applied, analogous to GROUP BY but without collapsing rows.
ROW_NUMBER()A window function that assigns a unique sequential integer to each row within its partition, with no ties—each row receives a distinct number regardless of identical values.
RANK()A window function that assigns the same rank to tied rows and then skips subsequent rank numbers; for example, two rows tied at rank 1 mean the next distinct rank is 3.
LAG()A window function that returns the value of a specified column from a row a given number of positions before the current row in the partition order, used for period-over-period comparisons.
LEAD()A window function that returns the value of a specified column from a row a given number of positions after the current row in the partition order.
Correlated subqueryA subquery that references one or more columns from the outer query and is therefore re-executed for each row the outer query processes, which can significantly reduce performance on large tables.
Optimization fenceA behavior in PostgreSQL where the query planner treats a CTE as a separate execution unit and may not push filters or optimizations through it, potentially affecting query performance.
Worked examples4
1. Rewrite the following correlated subquery as a CTE. The query returns each salesperson's name and their total sales, but only for salespersons whose total exceeds the average total across all salespersons.
Original:
SELECT name, total
FROM (
SELECT name, SUM(amount) AS total
FROM sales_transactions
GROUP BY name
) AS sub
WHERE total > (SELECT AVG(total) FROM (SELECT SUM(amount) AS total FROM sales_transactions GROUP BY name) AS avg_sub);
- Step 1 – Identify the repeated logic. The inner aggregation SUM(amount) GROUP BY name appears twice. Name it once as a CTE called rep_totals.
- Step 2 – Write the first CTE: WITH rep_totals AS ( SELECT name, SUM(amount) AS total FROM sales_transactions GROUP BY name )
- Step 3 – Compute the average total in a second CTE that reads from rep_totals: , avg_total AS ( SELECT AVG(total) AS avg_val FROM rep_totals )
- Step 4 – Write the main SELECT that joins the two CTEs: SELECT r.name, r.total FROM rep_totals r CROSS JOIN avg_total a WHERE r.total > a.avg_val ORDER BY r.total DESC;
Answer: WITH rep_totals AS (
SELECT name, SUM(amount) AS total
FROM sales_transactions
GROUP BY name
),
avg_total AS (
SELECT AVG(total) AS avg_val
FROM rep_totals
)
SELECT r.name, r.total
FROM rep_totals r
CROSS JOIN avg_total a
WHERE r.total > a.avg_val
ORDER BY r.total DESC;
2. Using the city_permits table with columns (permit_id, contractor_id, permit_category, permit_value), write a query that shows every row plus a running total of permit_value ordered by permit_id within each permit_category.
- Step 1 – Identify the window: the running total resets for each permit_category (PARTITION BY permit_category) and advances in permit_id order (ORDER BY permit_id).
- Step 2 – Choose the function: SUM(permit_value) OVER (...) with no explicit frame specification; PostgreSQL defaults to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which gives a running total.
- Step 3 – Write the SELECT: SELECT permit_id, contractor_id, permit_category, permit_value, SUM(permit_value) OVER (PARTITION BY permit_category ORDER BY permit_id) AS running_total FROM city_permits ORDER BY permit_category, permit_id;
Answer: SELECT
permit_id,
contractor_id,
permit_category,
permit_value,
SUM(permit_value) OVER (
PARTITION BY permit_category
ORDER BY permit_id
) AS running_total
FROM city_permits
ORDER BY permit_category, permit_id;
3. Using the sales_transactions table with columns (sale_id, salesperson_id, sale_date, amount), write a query that shows each row with the previous row's amount (by sale_date order) for the same salesperson, and compute the difference between the current amount and the previous amount.
- Step 1 – Use LAG(amount, 1) partitioned by salesperson_id and ordered by sale_date to retrieve the prior row's amount.
- Step 2 – Compute the difference as amount - LAG(amount, 1) OVER (...). When there is no prior row (the first row in the partition), LAG returns NULL, so the difference will also be NULL—this is expected and correct.
- Step 3 – Write the full SELECT: SELECT sale_id, salesperson_id, sale_date, amount, LAG(amount, 1) OVER (PARTITION BY salesperson_id ORDER BY sale_date) AS prev_amount, amount - LAG(amount, 1) OVER (PARTITION BY salesperson_id ORDER BY sale_date) AS amount_change FROM sales_transactions ORDER BY salesperson_id, sale_date;
Answer: SELECT
sale_id,
salesperson_id,
sale_date,
amount,
LAG(amount, 1) OVER (
PARTITION BY salesperson_id
ORDER BY sale_date
) AS prev_amount,
amount - LAG(amount, 1) OVER (
PARTITION BY salesperson_id
ORDER BY sale_date
) AS amount_change
FROM sales_transactions
ORDER BY salesperson_id, sale_date;
4. Using the city_permits table, write a CTE-plus-window-function query that ranks contractors by total permit value within each permit_category and adds a boolean column top_performer that is TRUE only for the contractor ranked 1 in each category.
- Step 1 – Aggregate first: CTE permit_totals computes SUM(permit_value) grouped by contractor_id and permit_category.
- Step 2 – Rank inside a second CTE called ranked: use RANK() OVER (PARTITION BY permit_category ORDER BY total_value DESC) AS category_rank reading from permit_totals.
- Step 3 – In the main SELECT, reference ranked and add the flag: CASE WHEN category_rank = 1 THEN TRUE ELSE FALSE END AS top_performer.
- Step 4 – Verify correctness: the top_performer flag should appear exactly once per permit_category because RANK() = 1 is unique unless two contractors have an identical total. If ties exist, both would be flagged—note this in comments and clarify requirements with the instructor.
Answer: WITH permit_totals AS (
SELECT
contractor_id,
permit_category,
SUM(permit_value) AS total_value
FROM city_permits
GROUP BY contractor_id, permit_category
),
ranked AS (
SELECT
contractor_id,
permit_category,
total_value,
RANK() OVER (
PARTITION BY permit_category
ORDER BY total_value DESC
) AS category_rank
FROM permit_totals
)
SELECT
contractor_id,
permit_category,
total_value,
category_rank,
CASE WHEN category_rank = 1 THEN TRUE ELSE FALSE END AS top_performer
FROM ranked
ORDER BY permit_category, category_rank;
In class: Instructor demonstration: live-code comparison of a correlated subquery versus an equivalent CTE against a sales transactions dataset; students identify which is more readable and explain why in a two-sentence written response. · Window-function gallery walk: six printed 'station cards' are posted around the room, each showing a window-function use case; student pairs rotate through, run each query in psql, record the output, and write a plain-English interpretation. · Challenge query: students independently write a CTE-plus-window-function query against the city permits schema that ranks contractors by total permit value within each permit category and flags the top performer in each category.
Materials: Sales transactions PostgreSQL dataset (instructor-provided), city permits schema from Lesson 4, station-card printouts, PostgreSQL window-function documentation
Check: The challenge query is submitted as a .sql file; instructor runs it and checks that the ranking is correct and that the top-performer flag appears exactly once per category; pass requires a fully correct result with no runtime errors.
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.