Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 35

Optimizing Nested Subqueries with CTEs

Recommended resources

Hello! Welcome back to your SQL course.

In the last two lessons, we explored how to use Common Table Expressions (CTEs), first to clarify a single logical step, and then to chain multiple CTEs into a clean, sequential data pipeline. You've learned how to build complex queries from scratch in a structured way.

Today, we'll apply that knowledge to a different, but equally critical, real-world task: improving existing code. The learning outcome for this lesson is to refactor a query with nested subqueries into a more maintainable CTE-based structure. In any technical job, you will inevitably encounter complex, hard-to-read "legacy" code. Being able to understand, clean up, and improve it is a highly valued skill.

This process is analogous to refactoring a large, monolithic function in a language like C into a set of smaller, well-defined functions. You're not changing what the code does, but you're dramatically improving its structure, making it easier to read, debug, and maintain.

The Problem: "Spaghetti SQL"

Complex business questions often lead to SQL queries where one subquery is nested inside another, which might be nested inside another. This is often called "Spaghetti SQL" because tracing the logic is like trying to follow a single noodle in a bowl of spaghetti.

Consider a query like this:

-- This is a conceptual example of a messy query
SELECT *
FROM employees e
WHERE e.department_id IN (
    SELECT department_id
    FROM departments d
    WHERE d.avg_sales > (
        SELECT AVG(s.total)
        FROM sales s
        WHERE s.region = d.region
    )
);

While the database can execute this, it's very difficult for a human to read. What is the business logic? If it produces an error or an incorrect result, how would you debug it? This is the problem we'll solve today.

Refactoring with CTEs allows us to untangle this mess. We can isolate each logical block, give it a meaningful name, and arrange the query in a clear, top-to-bottom sequence.

SubQuery vs. CTE Comparison
This image shows a simple side-by-side comparison. On the left, the subquery is buried inside the `FROM` clause. On the right, the logic is extracted into a named CTE, `employee_sales`, making the final `SELECT` statement clean and self-explanatory.

A Guided Refactoring Example

Let's watch a practical demonstration of refactoring. The following video first builds a query using nested subqueries to solve a problem and then rewrites that exact query using CTEs, allowing for a direct comparison of the two approaches.

SQL WITH Clause | How to write SQL Queries using WITH Clause | SQL CTE (Common Table Expression)

This video from techTFQ provides a perfect walkthrough. It first presents a complex problem and solves it using messy, nested subqueries, highlighting the readability issues. Then, it refactors that same query into a clean, maintainable CTE-based structure.

This video is best watched in two parts: The 'Before' State (06:17 - 15:02): Watch this segment to see how the presenter builds a query with multiple, nested subqueries to find stores with sales better than the average. Notice how the final query becomes hard to read, with one query nested inside another. The 'After' State (15:02 - 20:24): Now, watch how the presenter refactors this complex query into two CTEs, total_sales and average_sales. Pay close attention to how the final SELECT statement becomes incredibly simple and readable, and how the total_sales logic is defined once and reused. You'll see that both queries produce the same result, but the CTE version is far superior in terms of clarity and maintainability.

As the video demonstrates, refactoring provides several key benefits:

  • Readability: The main query logic is no longer hidden in nested parentheses. It's a clean SELECT from named, logical blocks.
  • Maintainability: If you need to change how "total sales" are calculated, you only need to edit the total_sales CTE. The change is isolated and easy to perform.
  • Reusability: The total_sales result was calculated once and then used by the average_sales CTE. The subquery version had to repeat that logic, which is inefficient and error-prone.

A Step-by-Step Method for Untangling Queries

Now that you've seen the "before and after," let's establish a formal process for refactoring. The goal is to be methodical, not just haphazardly copy-pasting code.

Simplify Complex Queries with Postgres CTEs in Supabase

This article, 'Simplify Complex Queries with Postgres CTEs in Supabase', provides an excellent, detailed walkthrough of this exact refactoring process. It starts with a piece of 'spaghetti SQL' for a music app and methodically untangles it.

Read the sections 'The Problem with Complex SQL Queries' and 'Solving Spaghetti SQL with CTEs'. Then, jump to the subsection 'Tips for Testing and Debugging CTEs'. First, look at the initial query. Try to mentally trace the logic – it's difficult! Then, follow the author's process. Notice how they break the problem down into logical chunks (user_recent_play_count, user_top_genre, etc.) and create a CTE for each. Crucially, absorb the advice on debugging: After writing each CTE, run a SELECT * FROM your_cte_name to ensure it works as expected before building the next one. This iterative, test-as-you-go approach is a fundamental professional habit.

From the article, we can summarize the refactoring process into these steps:

  1. Understand the Goal: Before touching the code, read the query and figure out the business question it's trying to answer.
  2. Identify the Logical Blocks: Look at the innermost subqueries first. What piece of information is each one trying to produce? Give it a mental name (e.g., "this finds all active promotions," "this gets user play counts").
  3. Extract the Innermost Logic: Take the innermost subquery and make it your first CTE. Give it a descriptive name.
  4. Test As You Go: Immediately after creating your first CTE, comment out the rest of the old query and write SELECT * FROM your_new_cte;. Run it and verify the results. This ensures your foundation is solid before you build on it.
  5. Build Outward: Move to the next subquery that depended on the one you just extracted. Rewrite it as a second CTE, but instead of using a subquery, have it SELECT from your first CTE. Test this new CTE.
  6. Repeat: Continue this process, replacing each layer of subqueries with a new, tested CTE that references a previous one.
  7. Construct the Final Query: Once all the messy logic is encapsulated in a clean sequence of CTEs, write your final, simple SELECT statement that orchestrates them.
Test your understanding!

You are given a messy query that finds all employees who earn more than the average salary of their respective department, but only for departments located in 'New York'.

The messy query:

SELECT
  e.employee_name,
  e.salary
FROM
  employees e
JOIN
  departments d ON e.department_id = d.department_id
WHERE
  d.location = 'New York' AND e.salary > (
    SELECT AVG(e2.salary)
    FROM employees e2
    WHERE e2.department_id = e.department_id
  );

This query uses a correlated subquery, which can be inefficient and hard to read. Refactor it using CTEs. Describe the two CTEs you would create and the final SELECT statement.

Show answer

Here is the refactored structure following our method:

  1. CTE 1: department_averages

    • Purpose: Calculate the average salary for every single department. This isolates the aggregation logic.
    • Code: It would GROUP BY department_id on the employees table and calculate AVG(salary).
  2. CTE 2: new_york_departments

    • Purpose: Get the list of department IDs that are located in 'New York'. This isolates the location filter.
    • Code: It would SELECT department_id FROM departments WHERE location = 'New York'.
  3. Final SELECT Statement:

    • Purpose: Bring all the pieces together.
    • Code:
      • Join employees with department_averages on department_id.
      • Join the result with new_york_departments on department_id (this effectively filters for only NY departments).
      • In the WHERE clause, compare the employee's salary to the avg_dept_salary from the department_averages CTE.

The complete refactored query:

WITH department_averages AS (
  -- Step 1: Calculate the average salary for each department
  SELECT
    department_id,
    AVG(salary) AS avg_dept_salary
  FROM
    employees
  GROUP BY
    department_id
),
new_york_departments AS (
  -- Step 2: Identify the departments in New York
  SELECT
    department_id
  FROM
    departments
  WHERE
    location = 'New York'
)
-- Final Step: Join the data and apply the final filter
SELECT
  e.employee_name,
  e.salary
FROM
  employees e
JOIN
  department_averages da ON e.department_id = da.department_id
JOIN
  new_york_departments nyd ON e.department_id = nyd.department_id
WHERE
  e.salary > da.avg_dept_salary;

Notice how each CTE has one clear job, and the final SELECT is just for joining and filtering based on the pre-calculated results. It tells a story from top to bottom.


Conclusion

You have now completed the core lessons on Common Table Expressions. You know how to build queries with them and, just as importantly, how to refactor existing queries to use them. This is a powerful combination of skills that separates a beginner from a proficient SQL user.

Key Takeaways:

  • Nested subqueries ("spaghetti SQL") are hard to read, debug, and maintain.
  • Refactoring involves methodically extracting nested logic into a sequence of named CTEs.
  • A reliable process is to start from the innermost query, extract it to a CTE, test it, and build outwards.
  • The final result is a query that is self-documenting, modular, and much easier to work with for you and your future teammates.

Preview of the Next Lesson:
We are now going to shift our focus from reading data to manipulating it. In the next module, you will learn the fundamentals of Data Definition Language (DDL) and Data Manipulation Language (DML). We will start by learning how to CREATE, ALTER, and DROP tables, which are the foundational commands for defining the very structure of your database.