🚧 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 › Data Science & Analytics › SQL and PostgreSQL for Data Analytics › Lesson 5
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:

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.

---

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);
  1. 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.
  2. Step 2 – Write the first CTE: WITH rep_totals AS ( SELECT name, SUM(amount) AS total FROM sales_transactions GROUP BY name )
  3. 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 )
  4. 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.
  1. 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).
  2. 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.
  3. 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.
  1. Step 1 – Use LAG(amount, 1) partitioned by salesperson_id and ordered by sale_date to retrieve the prior row's amount.
  2. 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.
  3. 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.
  1. Step 1 – Aggregate first: CTE permit_totals computes SUM(permit_value) grouped by contractor_id and permit_category.
  2. 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.
  3. 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.
  4. 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.

Data preparation and SQL — CompTIA Data+ / Google Data Analytics
1. A CTE is defined with which SQL keyword?
Data analysis and SQL functions — CompTIA Data+
2. Which of the following correctly describes how RANK() handles tied values?
Query optimization — IBM Data Science Professional / CompTIA Data+
3. A data analyst writes a correlated subquery that re-executes for every row in a 10-million-row table. What is the primary performance concern?
Data analysis with SQL window functions — Google Data Analytics / CompTIA Data+
4. You want to see each sale row alongside the sale amount from the immediately preceding row for the same salesperson, ordered by sale date. Which window function should you use?
Data analysis correctness and business logic — CompTIA Data+ / Google Data Analytics
5. In the challenge query, the top_performer flag is defined as TRUE when category_rank = 1. A colleague notices two contractors have identical total permit values in the same category. How many rows will have top_performer = TRUE for that category?
SQL window functions — IBM Data Science Professional / CompTIA Data+
6. What does SUM(permit_value) OVER (PARTITION BY permit_category ORDER BY permit_id) compute?
SQL design and readability — Google Data Analytics / CompTIA Data+
7. Which statement best describes the difference between a CTE and a derived table (inline subquery in the FROM clause)?
Data integrity and professional practice — CompTIA Data+ / IBM Data Science Professional
8. A Power BI report is built on a PostgreSQL view that uses a multi-level CTE with window functions. A stakeholder questions the 'Top Contractor' value shown in the report. What is the most professionally appropriate first step for the analyst?
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.