๐Ÿ”„ Subqueries & CTEs

Queries inside queries! Like Russian nesting dolls for data. Then CTEs โ€” the sticky notes that make complex queries readable.

๐Ÿ“ฆ Our Practice Dataset

Before we dive into subqueries and CTEs, let's set up some tables to work with. We'll use an employees table, a departments table, and an orders table โ€” the kind of data you'd find at any real company.

-- Create departments
CREATE TABLE departments (
    id    SERIAL PRIMARY KEY,
    name  VARCHAR(50)
);

INSERT INTO departments (name) VALUES
    ('Engineering'),
    ('Marketing'),
    ('Sales'),
    ('HR');

-- Create employees
CREATE TABLE employees (
    id            SERIAL PRIMARY KEY,
    name          VARCHAR(100),
    department_id INTEGER REFERENCES departments(id),
    salary        DECIMAL(10,2),
    manager_id    INTEGER REFERENCES employees(id),
    hire_date     DATE
);

INSERT INTO employees (name, department_id, salary, manager_id, hire_date) VALUES
    ('Alice Chen',     1, 120000, NULL, '2019-03-15'),
    ('Bob Martinez',   1, 95000,  1,    '2020-07-01'),
    ('Carol White',    1, 105000, 1,    '2020-01-10'),
    ('Diana Lopez',    2, 88000,  NULL, '2018-11-20'),
    ('Eve Johnson',    2, 72000,  4,    '2021-06-05'),
    ('Frank Kim',      3, 110000, NULL, '2017-09-12'),
    ('Grace Patel',    3, 68000,  6,    '2022-02-28'),
    ('Henry Davis',    3, 75000,  6,    '2021-08-15'),
    ('Irene Nguyen',   4, 92000,  NULL, '2019-05-01'),
    ('Jack Robinson',  4, 65000,  9,    '2023-01-15');

-- Create orders
CREATE TABLE orders (
    id          SERIAL PRIMARY KEY,
    employee_id INTEGER REFERENCES employees(id),
    amount      DECIMAL(10,2),
    category    VARCHAR(50),
    order_date  DATE
);

INSERT INTO orders (employee_id, amount, category, order_date) VALUES
    (6, 5000,  'Software',  '2024-01-15'),
    (6, 12000, 'Hardware',  '2024-02-20'),
    (7, 3500,  'Software',  '2024-01-25'),
    (8, 8000,  'Hardware',  '2024-03-10'),
    (7, 2500,  'Services', '2024-02-14'),
    (6, 15000, 'Software',  '2024-03-01'),
    (8, 4500,  'Services', '2024-03-22'),
    (2, 9500,  'Hardware',  '2024-01-30');

Here's a quick look at our employees data:

idnamedepartment_idsalarymanager_id
1Alice Chen1 (Engineering)120,000NULL
2Bob Martinez1 (Engineering)95,0001
3Carol White1 (Engineering)105,0001
4Diana Lopez2 (Marketing)88,000NULL
5Eve Johnson2 (Marketing)72,0004
6Frank Kim3 (Sales)110,000NULL
7Grace Patel3 (Sales)68,0006
8Henry Davis3 (Sales)75,0006
9Irene Nguyen4 (HR)92,000NULL
10Jack Robinson4 (HR)65,0009

๐Ÿ’ก Pro Tip โ€” Notice the manager_id Column!

See how manager_id points back to the same employees table? That's called a self-referencing foreign key. Employees with NULL manager_id are the top-level managers (department heads). This structure will be perfect for our recursive CTE example later!

1๏ธโƒฃ What Is a Subquery?

๐Ÿง’ ELI5 โ€” Questions Inside Questions

Imagine your teacher asks: "Who scored higher than the class average?" To answer that, you need to do two things: first figure out what the class average is, then find everyone who beat it. A subquery is exactly that โ€” a question (query) inside another question (query). The inner question runs first and feeds its answer into the outer question.

A subquery is a SELECT statement nested inside another SQL statement. It's wrapped in parentheses and can appear in WHERE, FROM, SELECT, or even HAVING clauses.

๐ŸŒ Real-Life Analogy

Think of it like ordering food at a restaurant. You say: "I'll have whatever the most popular dish is." The waiter first has to go check what the most popular dish is (inner query), then bring it to you (outer query). You asked one question, but the waiter had to answer a hidden question first to fulfill your request.

Here's the simplest possible subquery โ€” finding employees who earn more than the company average:

SELECT name, salary
FROM employees
WHERE salary > (
    SELECT AVG(salary) FROM employees
);

How PostgreSQL runs this:

Step 1
Inner query runs first: AVG(salary) = 89,000
Step 2
Outer query becomes: WHERE salary > 89000
Result
Returns employees beating the average

Result:

namesalary
Alice Chen120,000
Bob Martinez95,000
Carol White105,000
Frank Kim110,000
Irene Nguyen92,000

2๏ธโƒฃ Subqueries in WHERE

The most common place for subqueries is inside the WHERE clause. There are two flavors:

Scalar Subqueries โ€” Return ONE Value

๐Ÿง’ ELI5 โ€” One Answer

A scalar subquery returns a single number or value โ€” like asking "What's the highest score?" The answer is just one number, say 98. Then you can use that number in a comparison like WHERE score > 98. If a subquery returns more than one value when you expected one, PostgreSQL will throw an error!

-- Find the employee(s) with the highest salary
SELECT name, salary
FROM employees
WHERE salary = (
    SELECT MAX(salary) FROM employees
);

Result:

namesalary
Alice Chen120,000
-- Find employees in the same department as Alice Chen
SELECT name, department_id
FROM employees
WHERE department_id = (
    SELECT department_id
    FROM employees
    WHERE name = 'Alice Chen'
)
AND name <> 'Alice Chen';

Result:

namedepartment_id
Bob Martinez1
Carol White1

IN Subqueries โ€” Return a LIST of Values

๐Ÿง’ ELI5 โ€” A Whole List

Instead of one answer, imagine the inner question returns a list. Like: "Which departments have more than 2 employees?" The answer is a list: (1, 3). Then the outer query says: "Give me all employees in departments (1, 3)." The IN keyword checks if a value is anywhere in that list.

-- Find employees in departments that have 3 or more people
SELECT name, department_id
FROM employees
WHERE department_id IN (
    SELECT department_id
    FROM employees
    GROUP BY department_id
    HAVING COUNT(*) >= 3
);

Result:

namedepartment_id
Alice Chen1
Bob Martinez1
Carol White1
Frank Kim3
Grace Patel3
Henry Davis3
-- Find employees who have placed at least one order
SELECT name
FROM employees
WHERE id IN (
    SELECT DISTINCT employee_id FROM orders
);

Result:

name
Bob Martinez
Frank Kim
Grace Patel
Henry Davis

โš ๏ธ Scalar vs. List โ€” Don't Mix Them Up!

If you use = with a subquery that returns multiple rows, PostgreSQL throws an error: "more than one row returned by a subquery used as an expression." Use = only when the subquery returns exactly ONE value. Use IN when it might return multiple values.

3๏ธโƒฃ Subqueries in FROM โ€” Derived Tables

๐Ÿง’ ELI5 โ€” Temporary Pretend Tables

Imagine you do some math homework and write the answers on a sticky note. Now you use that sticky note as if it were a page in your textbook โ€” you look things up from it, compare numbers on it, etc. A derived table is exactly that: you write a query that creates a temporary result, and then you treat that result like it's a real table you can query from.

When you put a subquery in the FROM clause, the result becomes a temporary table. You must give it an alias (a name).

-- Find the top spender in each order category
SELECT category_totals.category,
       e.name,
       category_totals.total_spent
FROM (
    -- This subquery becomes a temporary table called "category_totals"
    SELECT employee_id,
           category,
           SUM(amount) AS total_spent
    FROM orders
    GROUP BY employee_id, category
) AS category_totals
JOIN employees e ON e.id = category_totals.employee_id
ORDER BY category_totals.category, category_totals.total_spent DESC;

Result:

categorynametotal_spent
HardwareFrank Kim12,000
HardwareBob Martinez9,500
HardwareHenry Davis8,000
ServicesHenry Davis4,500
ServicesGrace Patel2,500
SoftwareFrank Kim20,000
SoftwareGrace Patel3,500

Another Example โ€” Department Salary Stats

-- Compare each employee's salary to their department average
SELECT e.name,
       e.salary,
       dept_avg.avg_salary,
       ROUND(e.salary - dept_avg.avg_salary, 2) AS diff_from_avg
FROM employees e
JOIN (
    SELECT department_id,
           ROUND(AVG(salary), 2) AS avg_salary
    FROM employees
    GROUP BY department_id
) AS dept_avg ON e.department_id = dept_avg.department_id
ORDER BY e.department_id, e.salary DESC;

โš ๏ธ Always Alias Your Derived Tables!

PostgreSQL requires an alias for subqueries in FROM. Without one you'll get: "subquery in FROM must have an alias." Just add AS some_name after the closing parenthesis.

4๏ธโƒฃ Correlated Subqueries โ€” The Dependent Child

๐Ÿง’ ELI5 โ€” The Clingy Question

In a normal subquery, the inner question runs once and hands its answer to the outer query. But a correlated subquery is like a clingy little sibling โ€” it asks the outer query for help on every single row. Imagine going through a class roster and for each student asking: "Is THIS student's score higher than THEIR class average?" The "their class" part changes per student, so you have to recalculate each time.

A correlated subquery references columns from the outer query. This means it must run once for every row the outer query processes.

-- For each employee, compare salary to their OWN department average
SELECT e.name,
       e.salary,
       e.department_id,
       (
           SELECT ROUND(AVG(e2.salary), 2)
           FROM employees e2
           WHERE e2.department_id = e.department_id  -- references outer query!
       ) AS dept_avg_salary
FROM employees e
ORDER BY e.department_id;

Result:

namesalarydepartment_iddept_avg_salary
Alice Chen120,0001106,666.67
Bob Martinez95,0001106,666.67
Carol White105,0001106,666.67
Diana Lopez88,000280,000.00
Eve Johnson72,000280,000.00
Frank Kim110,000384,333.33
Grace Patel68,000384,333.33
Henry Davis75,000384,333.33
Irene Nguyen92,000478,500.00
Jack Robinson65,000478,500.00

How Does This Work Under the Hood?

PostgreSQL processes it row by row:

Row 1: Alice Chen (dept 1)

Inner query calculates AVG for department_id = 1 โ†’ 106,666.67

Row 2: Bob Martinez (dept 1)

Inner query calculates AVG for department_id = 1 โ†’ 106,666.67 (same dept, same result)

Row 4: Diana Lopez (dept 2)

Inner query recalculates AVG for department_id = 2 โ†’ 80,000.00 (different dept!)

โš ๏ธ Performance Warning โ€” Correlated Subqueries Can Be Slow!

Because the inner query runs once per row, correlated subqueries can be painfully slow on large tables. If you have 1 million rows, the inner query runs 1 million times! In many cases, you can rewrite a correlated subquery as a JOIN with a derived table (like we did in the previous section) for much better performance. PostgreSQL's optimizer sometimes does this automatically, but don't count on it.

5๏ธโƒฃ EXISTS & NOT EXISTS

๐Ÿง’ ELI5 โ€” Does It Exist?

Imagine you're a teacher checking homework. For each student, you peek into the homework box and ask: "Is there ANY homework from this student in here?" You don't care what the homework says โ€” you just want a YES or NO. That's EXISTS. It doesn't return data; it returns true/false โ€” "does at least one matching row exist?"

EXISTS โ€” Check If Related Data Exists

-- Find employees who have placed at least one order
SELECT e.name
FROM employees e
WHERE EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.employee_id = e.id
);

Result:

name
Bob Martinez
Frank Kim
Grace Patel
Henry Davis

๐Ÿ’ก Pro Tip โ€” SELECT 1 Inside EXISTS

Inside EXISTS, it doesn't matter what you SELECT โ€” PostgreSQL only checks if any row is returned, not what columns it has. Writing SELECT 1 or SELECT * or even SELECT 'banana' all work identically. Convention is SELECT 1 to signal "I only care about existence."

NOT EXISTS โ€” Find Rows WITHOUT Related Data

-- Find employees who have NEVER placed an order
SELECT e.name
FROM employees e
WHERE NOT EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.employee_id = e.id
);

Result:

name
Alice Chen
Carol White
Diana Lopez
Eve Johnson
Irene Nguyen
Jack Robinson

EXISTS vs. IN โ€” Which Is Better?

FeatureIN (subquery)EXISTS
How it worksRuns inner query first, builds a list, then checks each outer row against the listFor each outer row, checks if at least one inner row matches
NULL safetyNOT IN breaks with NULLs!NOT EXISTS handles NULLs correctly
Performance (large inner result)Slower โ€” must materialize entire listFaster โ€” can stop at first match
Performance (small inner result)Usually similarUsually similar
ReadabilitySimpler syntaxSlightly more verbose

๐ŸŒ Real-Life Analogy

IN is like getting a full guest list and checking if your name is on it. EXISTS is like walking up to the bouncer who checks one name at a time. For a short guest list, both are fast. For a list with 100,000 names, the bouncer (EXISTS) can be faster because he stops the moment he finds your name โ€” he doesn't need to read the whole list first.

6๏ธโƒฃ Common Table Expressions (CTEs)

๐Ÿง’ ELI5 โ€” Sticky Notes for SQL

Imagine you're solving a big math problem. Instead of writing one enormous equation, you grab some sticky notes. On the first note you write: "Step A answer = 42." On the second note: "Step B answer = 15." Then for the final answer, you just look at your sticky notes: "Final = Step A + Step B = 57." CTEs are those sticky notes! You name each piece, calculate it, and then use those names in your final query. It's way cleaner than stuffing everything into one giant nested mess.

A CTE is defined using the WITH keyword. It creates a named temporary result that you can reference in the main query.

Basic CTE Syntax

WITH cte_name AS (
    -- Your query here (the "sticky note")
    SELECT ...
)
-- Main query uses the CTE like a regular table
SELECT ... FROM cte_name;

Example 1 โ€” High Earners Report

WITH avg_salary AS (
    SELECT ROUND(AVG(salary), 2) AS company_avg
    FROM employees
)
SELECT e.name,
       e.salary,
       a.company_avg,
       e.salary - a.company_avg AS above_avg_by
FROM employees e, avg_salary a
WHERE e.salary > a.company_avg
ORDER BY above_avg_by DESC;

Result:

namesalarycompany_avgabove_avg_by
Alice Chen120,00089,00031,000
Frank Kim110,00089,00021,000
Carol White105,00089,00016,000
Bob Martinez95,00089,0006,000
Irene Nguyen92,00089,0003,000

Example 2 โ€” Multiple CTEs (Multiple Sticky Notes!)

You can chain multiple CTEs together separated by commas:

WITH dept_stats AS (
    -- Sticky note 1: department salary stats
    SELECT department_id,
           COUNT(*) AS emp_count,
           ROUND(AVG(salary), 2) AS avg_salary,
           MAX(salary) AS max_salary
    FROM employees
    GROUP BY department_id
),
order_stats AS (
    -- Sticky note 2: total orders per department
    SELECT e.department_id,
           COUNT(o.id) AS total_orders,
           COALESCE(SUM(o.amount), 0) AS total_revenue
    FROM employees e
    LEFT JOIN orders o ON o.employee_id = e.id
    GROUP BY e.department_id
)
-- Main query: combine both sticky notes
SELECT d.name AS department,
       ds.emp_count,
       ds.avg_salary,
       ds.max_salary,
       os.total_orders,
       os.total_revenue
FROM dept_stats ds
JOIN departments d ON d.id = ds.department_id
JOIN order_stats os ON os.department_id = ds.department_id
ORDER BY os.total_revenue DESC;

Result:

departmentemp_countavg_salarymax_salarytotal_orderstotal_revenue
Sales384,333.33110,000750,500
Engineering3106,666.67120,00019,500
Marketing280,000.0088,00000
HR278,500.0092,00000

๐Ÿ’ก Pro Tip โ€” CTEs Can Reference Earlier CTEs!

In a chain of CTEs, each one can reference any CTE defined before it. So order_stats could use dept_stats if needed. This lets you build up complex logic step by step, like a recipe where each step builds on the previous one.

7๏ธโƒฃ Recursive CTEs โ€” Climbing Trees!

๐Ÿง’ ELI5 โ€” The Family Tree

Imagine you want to draw your family tree. You start with yourself. Then you ask: "Who are my parents?" For each parent, you ask: "Who are THEIR parents?" And you keep asking until you run out of ancestors. A recursive CTE does the same thing โ€” it starts with a base case (you), then keeps expanding by finding related rows, until there's nothing more to find. It's perfect for any tree-like or hierarchical data: org charts, category trees, file systems, etc.

Recursive CTE Syntax

WITH RECURSIVE cte_name AS (
    -- BASE CASE: the starting point (the "root")
    SELECT ...
    FROM table
    WHERE ... -- condition to find the root(s)

    UNION ALL

    -- RECURSIVE CASE: find children of the current level
    SELECT ...
    FROM table
    JOIN cte_name ON ... -- join back to itself!
)
SELECT * FROM cte_name;

Example โ€” Employee Org Chart (Who Reports to Whom?)

WITH RECURSIVE org_chart AS (
    -- BASE CASE: start with top-level managers (no manager)
    SELECT id,
           name,
           manager_id,
           1 AS level,
           name::TEXT AS path
    FROM employees
    WHERE manager_id IS NULL

    UNION ALL

    -- RECURSIVE CASE: find employees managed by the current level
    SELECT e.id,
           e.name,
           e.manager_id,
           oc.level + 1,
           oc.path || ' โ†’ ' || e.name
    FROM employees e
    JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT REPEAT('  ', level - 1) || name AS org_tree,
       level,
       path
FROM org_chart
ORDER BY path;

Result:

org_treelevelpath
Alice Chen1Alice Chen
  Bob Martinez2Alice Chen โ†’ Bob Martinez
  Carol White2Alice Chen โ†’ Carol White
Diana Lopez1Diana Lopez
  Eve Johnson2Diana Lopez โ†’ Eve Johnson
Frank Kim1Frank Kim
  Grace Patel2Frank Kim โ†’ Grace Patel
  Henry Davis2Frank Kim โ†’ Henry Davis
Irene Nguyen1Irene Nguyen
  Jack Robinson2Irene Nguyen โ†’ Jack Robinson

How Recursion Works โ€” Step by Step

Iteration 0 โ€” Base Case

Finds 4 rows: Alice, Diana, Frank, Irene (manager_id IS NULL). These are level 1.

Iteration 1 โ€” First Recursion

Finds employees whose manager_id matches the ids from iteration 0. Bob & Carol (under Alice), Eve (under Diana), Grace & Henry (under Frank), Jack (under Irene). These are level 2.

Iteration 2 โ€” Second Recursion

Looks for employees managed by level 2 employees. Finds NOTHING โ€” no one reports to Bob, Carol, Eve, etc. Recursion stops!

Bonus โ€” Number Sequence Generator

Recursive CTEs can generate data from nothing โ€” no table needed!

-- Generate numbers 1 to 10
WITH RECURSIVE nums AS (
    SELECT 1 AS n          -- Base case: start at 1
    UNION ALL
    SELECT n + 1            -- Recursive case: add 1
    FROM nums
    WHERE n < 10            -- Stop condition!
)
SELECT n FROM nums;

โš ๏ธ Always Include a Stop Condition!

Without a WHERE clause to stop the recursion, your query will run forever (until PostgreSQL's max_recursion limit kicks in, defaulting to about 100 iterations, and throws an error). Always make sure the recursive case has a condition that will eventually return zero rows.

8๏ธโƒฃ CTE vs. Subquery โ€” When to Use Which?

Both CTEs and subqueries can solve the same problems. So when should you use each?

CriteriaSubqueryCTE (WITH)
ReadabilityGets messy with nestingClean, top-down, named blocks
ReuseMust duplicate the subqueryDefine once, reference multiple times
DebuggingHard to test inner queries aloneEasy โ€” comment out the main query and run the CTE
RecursionNot possibleOnly CTEs support recursion
Performance (PG 12+)Always inlinedInlined by default (same as subquery)
Force materializationNot possibleWITH ... AS MATERIALIZED

๐Ÿง’ ELI5 โ€” When to Use Which

Subqueries: Great for simple, one-off "questions inside questions." If it's a single line or small enough to read at a glance, a subquery is fine.

CTEs: Use when the query is complex, when you need to reference the same result more than once, or when you need recursion. Think of it as: "If I need a sticky note to keep track of things, I should use a CTE."

The Same Query โ€” Both Ways

-- SUBQUERY VERSION: nested and harder to read
SELECT e.name, e.salary
FROM employees e
WHERE e.salary > (
    SELECT AVG(salary) FROM employees
)
AND e.department_id IN (
    SELECT department_id FROM employees
    GROUP BY department_id HAVING COUNT(*) >= 3
);

-- CTE VERSION: clean and readable
WITH company_avg AS (
    SELECT AVG(salary) AS avg_sal FROM employees
),
big_departments AS (
    SELECT department_id
    FROM employees
    GROUP BY department_id
    HAVING COUNT(*) >= 3
)
SELECT e.name, e.salary
FROM employees e, company_avg c
WHERE e.salary > c.avg_sal
AND e.department_id IN (SELECT department_id FROM big_departments);

๐Ÿ’ก Pro Tip โ€” The Professional Default

In professional codebases, CTEs are the preferred choice for anything beyond trivial subqueries. They make SQL code self-documenting because each CTE has a meaningful name. When you come back to a query 6 months later, WITH monthly_revenue AS (...) is infinitely clearer than a mysterious nested subquery buried three levels deep.

๐ŸŒ Real-Life Analogy

Subqueries are like whispering an instruction mid-sentence: "Give me employees who earn more than (quick โ€” what's the average salary?) the average." CTEs are like writing notes on a whiteboard first: "Step 1: Average salary = $89K. Step 2: Find employees above $89K." Both get you the same answer, but the whiteboard version is what you'd use in a presentation.

๐Ÿ‹๏ธ Practice Exercises

Time to put it all together! Try these yourself before revealing the solutions.

Exercise 1: Above-Average Earners

Using a subquery, find all employees who earn more than the average salary of the Sales department (department_id = 3). Show their name, salary, and department_id.

Reveal Solution
SELECT name, salary, department_id
FROM employees
WHERE salary > (
    SELECT AVG(salary)
    FROM employees
    WHERE department_id = 3
);

Exercise 2: Departments With Orders

Using EXISTS, find departments that have at least one employee who has placed an order. Show the department name.

Reveal Solution
SELECT d.name
FROM departments d
WHERE EXISTS (
    SELECT 1
    FROM employees e
    JOIN orders o ON o.employee_id = e.id
    WHERE e.department_id = d.id
);

Returns: Engineering, Sales

Exercise 3: CTE Dashboard

Write a query using two CTEs: one for department headcount and one for department order totals. Join them to show department name, headcount, and total order amount.

Reveal Solution
WITH headcount AS (
    SELECT department_id, COUNT(*) AS emp_count
    FROM employees
    GROUP BY department_id
),
dept_orders AS (
    SELECT e.department_id,
           COALESCE(SUM(o.amount), 0) AS total_amount
    FROM employees e
    LEFT JOIN orders o ON o.employee_id = e.id
    GROUP BY e.department_id
)
SELECT d.name,
       h.emp_count,
       do.total_amount
FROM departments d
JOIN headcount h ON h.department_id = d.id
JOIN dept_orders do ON do.department_id = d.id
ORDER BY do.total_amount DESC;

Exercise 4: Recursive Challenge โ€” Management Chain

Using a recursive CTE, find the full management chain for Grace Patel (id = 7). Start from Grace and walk UP to her manager, then her manager's manager, etc.

Reveal Solution
WITH RECURSIVE chain AS (
    -- Start with Grace
    SELECT id, name, manager_id, 1 AS level
    FROM employees
    WHERE id = 7

    UNION ALL

    -- Walk UP to the manager
    SELECT e.id, e.name, e.manager_id, c.level + 1
    FROM employees e
    JOIN chain c ON e.id = c.manager_id
)
SELECT name, level FROM chain;

Returns: Grace Patel (1), Frank Kim (2)

๐Ÿ“ Quiz โ€” Test Your Knowledge!

Question 1: What is a scalar subquery?

Question 2: What makes a correlated subquery different from a regular subquery?

Question 3: Why is NOT EXISTS safer than NOT IN?

Question 4: What keyword introduces a CTE?

Question 5: What are the two parts of a recursive CTE?

Question 6: Subqueries in the FROM clause are also calledโ€ฆ

Question 7: When should you prefer a CTE over a subquery?

๐ŸŽ‰ What's Next?

8
Techniques mastered
20+
SQL examples explored
7
Quiz questions conquered

You now know how to write queries inside queries, name them with CTEs for clarity, and even walk hierarchical data with recursive CTEs. Here's a summary:

ConceptWhat It DoesKey Syntax
Scalar SubqueryReturns one valueWHERE col = (SELECT ...)
IN SubqueryReturns a list of valuesWHERE col IN (SELECT ...)
Derived TableSubquery as a table in FROMFROM (SELECT ...) AS alias
Correlated SubqueryReferences outer query per rowWHERE col = (SELECT ... WHERE inner.x = outer.x)
EXISTS / NOT EXISTSChecks for existence of rowsWHERE EXISTS (SELECT 1 ...)
CTENamed temporary resultWITH name AS (SELECT ...)
Recursive CTEWalks hierarchiesWITH RECURSIVE name AS (...)

๐Ÿ”ฎ Coming Up Next: Indexes & Performance

You've learned to write powerful, complex queries. But how do you make them fast? In the next module, you'll learn about indexes โ€” the secret weapon that turns slow queries taking 30 seconds into lightning-fast lookups completing in milliseconds. You'll also learn to read execution plans with EXPLAIN ANALYZE, so you can see exactly what PostgreSQL does behind the scenes. It's like getting X-ray vision for your database!

Joins Indexes & Performance