Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

SQL (PostgreSQL, MySQL, SQLite)  Module 6 – Subqueries and set operations

Correlated subqueries in SQL

Write subqueries that read the outer row, find the latest order of each customer with ties handled, and know when a join or window function is faster.

  • Intermediate
  • 25 minutes
  • Examples run with sql.js 1.14.2
  • By MySmartCoPilot
Free core, Pro depth: 8 of 25 minutes free
Free: A subquery that reads the outer row · The latest order of each customer · Three ways to ask a group-wise question · Your turn. With a pass: 3 in-depth parts and 3 interview answers, about 17 minutes.

What you will learn

  • Write subqueries that refer to the outer row
  • Solve group-wise maximum problems with a correlated subquery
  • Rewrite correlated subqueries as joins or window functions when that is faster
  • Predict how the cost of a correlated subquery grows, and which index it needs

Before you start

A correlated subquery is a subquery that refers to a column of the outer query, such as q.category = p.category, where p is the outer table. Because that value changes from row to row, the subquery is, logically, evaluated again for every outer row, with that row’s values filled in. That makes it the natural way to compare each row with its own group: products dearer than the average of their own category, each customer’s latest order, the students whose best offer beats last year’s. It reads almost like the question in plain words. The price is speed: run once per row, the subquery needs an index on the column it filters by, or a rewrite as a join or a window function, both of which this lesson shows.

All of the examples run in the page itself, with sql.js 1.14.2 running SQLite 3.49.1 inside the browser tab; statements about PostgreSQL 18 and MySQL 8.4 rest on their manuals.

A subquery that reads the outer row

Dearer than the shop's average, or than their own category's SQL · own_average.sql
-- Nine products of a grocery shop, prices in paise. Spices are cheap, so they rarely beat the shop's average.
CREATE TABLE products (
  id          INTEGER PRIMARY KEY,
  name        TEXT    NOT NULL,
  category    TEXT    NOT NULL,
  price_paise INTEGER NOT NULL
);
INSERT INTO products VALUES
  (1, 'Masala tea 250 g',    'drinks', 18000), (2, 'Filter coffee 500 g', 'drinks', 42000),
  (3, 'Green tea 100 g',     'drinks', 24000), (4, 'Basmati rice 5 kg',   'grains', 65000),
  (5, 'Wheat atta 10 kg',    'grains', 48000), (6, 'Red lentils 1 kg',    'grains', 14000),
  (7, 'Turmeric 100 g',      'spices',  4000), (8, 'Chilli powder 200 g', 'spices',  6000),
  (9, 'Cumin seeds 100 g',   'spices', 11000);

-- 1. Uncorrelated: one average for the whole shop, computed once.
SELECT name, category, price_paise / 100.0 AS rupees
FROM products
WHERE price_paise > (SELECT avg(price_paise) FROM products)
ORDER BY category, name;

-- 2. Correlated: p.category inside the subquery is the outer row's category, so each product meets the average of
--    its own category.
SELECT p.name, p.category, p.price_paise / 100.0 AS rupees,
       round((SELECT avg(q.price_paise) FROM products AS q WHERE q.category = p.category) / 100.0, 2) AS category_average
FROM products AS p
WHERE p.price_paise > (SELECT avg(q.price_paise) FROM products AS q WHERE q.category = p.category)
ORDER BY p.category, p.name;

Output

┌─────────────────────┬──────────┬────────┐
│        name         │ category │ rupees │
├─────────────────────┼──────────┼────────┤
│ Filter coffee 500 g │ drinks   │ 420.0  │
│ Basmati rice 5 kg   │ grains   │ 650.0  │
│ Wheat atta 10 kg    │ grains   │ 480.0  │
└─────────────────────┴──────────┴────────┘
┌─────────────────────┬──────────┬────────┬──────────────────┐
│        name         │ category │ rupees │ category_average │
├─────────────────────┼──────────┼────────┼──────────────────┤
│ Filter coffee 500 g │ drinks   │ 420.0  │ 280.0            │
│ Basmati rice 5 kg   │ grains   │ 650.0  │ 423.33           │
│ Wheat atta 10 kg    │ grains   │ 480.0  │ 423.33           │
│ Cumin seeds 100 g   │ spices   │ 110.0  │ 70.0             │
└─────────────────────┴──────────┴────────┴──────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < own_average.sql

For each outer row the subquery runs with that row's category: cumin beats the spices average and is kept, green tea is not.Outer row: Cumin seeds 100 gcategory spices, 110 rupeesSubquery with p.category = 'spices'average of the spices: 70 rupees110 > 70 is true: the row is keptNext outer row: Green tea 100 gcategory drinks, 240 rupeesSubquery with p.category = 'drinks'average of the drinks: 280 rupees240 > 280 is false: the row is left outcategoryaveragenext rowcategoryaverage

A correlated subquery runs again for each outer row

Text description of the diagram

The diagram follows the lesson's correlated query through two rows of the outer query, from top to bottom.

  1. The outer row is Cumin seeds 100 g, category spices, 110 rupees.
  2. Its category goes into the subquery, which computes the average price of the spices: 70 rupees.
  3. 110 is greater than 70, so the row is kept.
  4. The next outer row is Green tea 100 g, category drinks, 240 rupees.
  5. The subquery runs again, now with the category drinks, and computes their average: 280 rupees.
  6. 240 is not greater than 280, so the row is left out.

The first query compares every product with one number, the shop’s average of 257.78 rupees, and no spice comes close. The second gives each product its own yardstick. Its subquery mentions p.category, a column of the outer row, so for cumin seeds it computes the average of the spices, 70 rupees, and for green tea the average of the drinks, 280 rupees. Cumin seeds at 110 rupees now pass, while green tea at 240 does not. SQLite’s manual describes the rule: a correlated subquery is re-evaluated each time its result is required, while an uncorrelated one is evaluated once and reused. PostgreSQL’s manual says the outer values act as constants during any one evaluation.

Give the outer and inner tables different aliases, p and q here. When the same name could mean either, the engine resolves it to the nearest query, the subquery itself; MySQL’s manual calls this evaluating from inside to outside. A missing alias can silently turn the correlation into category = category inside the subquery, which compares each inner row with itself and is true for every row that has a category.

The latest order of each customer

“The latest row per group” is the classic job for a correlated subquery:

Each customer's latest order SQL · latest_order.sql
-- Orders of four customers. Bilal placed two orders on the same day.
CREATE TABLE orders (
  id           INTEGER PRIMARY KEY,
  customer     TEXT    NOT NULL,
  placed_on    TEXT    NOT NULL,
  amount_paise INTEGER NOT NULL
);
INSERT INTO orders VALUES
  (1, 'Asha',   '2026-03-02', 50000), (2, 'Asha',   '2026-03-09', 70000), (3, 'Bilal',  '2026-03-05', 30000),
  (4, 'Bilal',  '2026-03-05', 45000), (5, 'Chitra', '2026-03-01', 90000), (6, 'Chitra', '2026-02-20', 20000),
  (7, 'Dev',    '2026-03-11', 25000);

-- The latest order of each customer: keep an order whose date is the latest date of the same customer.
SELECT o.id, o.customer, o.placed_on, o.amount_paise / 100.0 AS rupees
FROM orders AS o
WHERE o.placed_on = (SELECT max(o2.placed_on) FROM orders AS o2 WHERE o2.customer = o.customer)
ORDER BY o.customer, o.id;

Output

┌────┬──────────┬────────────┬────────┐
│ id │ customer │ placed_on  │ rupees │
├────┼──────────┼────────────┼────────┤
│ 2  │ Asha     │ 2026-03-09 │ 700.0  │
│ 3  │ Bilal    │ 2026-03-05 │ 300.0  │
│ 4  │ Bilal    │ 2026-03-05 │ 450.0  │
│ 5  │ Chitra   │ 2026-03-01 │ 900.0  │
│ 7  │ Dev      │ 2026-03-11 │ 250.0  │
└────┴──────────┴────────────┴────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < latest_order.sql

For each order, the subquery finds the latest date of the same customer, and the order is kept when its date equals it. Asha, Chitra and Dev get one order each. Bilal gets two, because both of his orders were placed on the latest day. Whether that is right depends on the question: “every order placed on the customer’s last day” wants both, “one row per customer” needs a rule that breaks the tie.

Three ways to ask a group-wise question

Three ways to compare each row with its group
Criterion Correlated subqueryJoin to a grouped derived tableWindow function
Reads as The question, row by rowTwo steps: group, then joinA ranking inside each group
Ties on the latest date KeptKeptKept with rank(), one row with row_number()
Work A subquery per row; fast with an indexOne grouping passOne sorted pass
Engines All threeAll threePostgreSQL, MySQL 8.0 and later, SQLite 3.25 and later

All three return the same rows on clean data, and they differ in how they break ties, how missing values behave and how much work they cost:

  • A tie-breaker makes the correlated form return exactly one row per group.
  • A NULL in the date makes the forms disagree about the latest row.
  • Without an index a correlated subquery rereads the table once per outer row, which grows with the square of the table.
  • A window function ranks the rows of each group in one pass.

Check yourself

4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 4 Which part of this query makes the subquery correlated?

    Read the code, then choose one answer.

    SELECT p.name
    FROM products AS p
    WHERE p.price_paise > (SELECT avg(q.price_paise)
                           FROM products AS q
                           WHERE q.category = p.category);
    Show the answer to question 1

    Answer: p.category inside the subquery, a column of the outer query's row

    A subquery is correlated when it refers to a column of the outer query. Here p.category changes from row to row, so the subquery is evaluated again for each product, with that product's category.

  2. Question 2 of 4 Two orders of the same customer share the latest date. How many rows does the query with placed_on = (SELECT max(o2.placed_on) ... WHERE o2.customer = o.customer) return for that customer?

    Choose one answer.

    Show the answer to question 2

    Answer: Two, because both orders have the latest date

    The subquery returns one value, the latest date, and both orders equal it. To return exactly one, the query needs a rule that breaks the tie, such as the higher id.

  3. Question 3 of 4 Without an index, a correlated subquery scans a 3,000-row table for each of 3,000 outer rows. About how many row visits is that?

    Choose one answer.

    Show the answer to question 3

    Answer: About 9 million

    3,000 outer rows times 3,000 rows per run is 9 million row visits. An index on the column the subquery filters by turns each run into a short search instead of a full scan.

  4. Question 4 of 4 You rewrite (SELECT count() FROM orders o WHERE o.customer_id = c.id) as a LEFT JOIN with count(). What does a customer without orders get?

    Choose one answer.

    Show the answer to question 4

    Answer: 1, because the LEFT JOIN produces one row for that customer and count(*) counts it

    A LEFT JOIN keeps the customer with NULLs in the order columns, and count(*) counts that row. count(o.id) counts only non-NULL order ids and gives 0, like the correlated subquery.

Your turn

Exercise · Medium · SQL

Show each student's best job offer, ties included

A college records the job offers its final-year students receive in offers, with the columns id, student, company and salary_rupees (the yearly salary offered).

Write a query that returns, for every student, the offer or offers with that student's highest salary, with the columns student, company and salary_rupees. When two offers of the same student share the highest salary, return both. The order of the rows does not matter.

The starter query groups by student. Run the sample tests to see which case it gets wrong.

Starter code · best_offer.sql

-- For every student, the offer(s) with the highest salary: student, company, salary_rupees.
SELECT student, company, max(salary_rupees) AS salary_rupees
FROM offers
GROUP BY student;
The sample tests · tests.yaml
# Sample tests: each one builds a small offers table, runs your query on it and compares the rows it returns (in any
# order).
tests:
  - name: picks each student's highest offer
    setup: |
      CREATE TABLE offers (id INTEGER PRIMARY KEY, student TEXT NOT NULL, company TEXT NOT NULL, salary_rupees INTEGER NOT NULL);
      INSERT INTO offers VALUES
        (1, 'Ananya', 'Banyan Analytics', 900000), (2, 'Ananya', 'Bluefin Data', 1200000),
        (3, 'Rohan', 'Kite Systems', 650000), (4, 'Meher', 'Bluefin Data', 800000), (5, 'Meher', 'Orbit Retail', 700000);
    expected:
      columns: [student, company, salary_rupees]
      rows:
        - ['Ananya', 'Bluefin Data', 1200000]
        - ['Rohan', 'Kite Systems', 650000]
        - ['Meher', 'Bluefin Data', 800000]
  - name: keeps both offers when two share the highest salary
    setup: |
      CREATE TABLE offers (id INTEGER PRIMARY KEY, student TEXT NOT NULL, company TEXT NOT NULL, salary_rupees INTEGER NOT NULL);
      INSERT INTO offers VALUES
        (1, 'Ishaan', 'Kite Systems', 1000000), (2, 'Ishaan', 'Orbit Retail', 1000000), (3, 'Ishaan', 'Banyan Analytics', 750000),
        (4, 'Zara', 'Bluefin Data', 900000);
    expected:
      columns: [student, company, salary_rupees]
      rows:
        - ['Ishaan', 'Kite Systems', 1000000]
        - ['Ishaan', 'Orbit Retail', 1000000]
        - ['Zara', 'Bluefin Data', 900000]
  - name: compares each student only with their own offers
    setup: |
      CREATE TABLE offers (id INTEGER PRIMARY KEY, student TEXT NOT NULL, company TEXT NOT NULL, salary_rupees INTEGER NOT NULL);
      INSERT INTO offers VALUES
        (1, 'Kavya', 'Orbit Retail', 500000), (2, 'Kavya', 'Kite Systems', 450000), (3, 'Vikram', 'Banyan Analytics', 1500000);
    expected:
      columns: [student, company, salary_rupees]
      rows:
        - ['Kavya', 'Orbit Retail', 500000]
        - ['Vikram', 'Banyan Analytics', 1500000]
A hint

Keep an offer when its salary equals the highest salary of the same student. The highest salary is a subquery that refers to the outer row: (SELECT max(o2.salary_rupees) FROM offers AS o2 WHERE o2.student = o.student), with the outer table named o.

The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

The latest row per group four ways, checked against each other Pro: opens with a Pro, Premium or Ultimate pass

Four common ways to write “the latest order per customer” agree on clean data and disagree as soon as there is a tie or a missing date. This part runs all four on the same orders, shows which rows each returns and why, and turns the differences into a choice.

In this part:

  • Ties and a missing date, reproduced on eight orders
  • Breaking ties on purpose
  • Choosing among the four: the trade-offs

1 runnable example (SQL) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

How often a correlated subquery runs, measured on 3,000 orders Pro: opens with a Pro, Premium or Ultimate pass

A correlated subquery is cheap when each run is a short index search and expensive when each run is a full scan. This part times both on 3,000 orders, reads the plans, and scales the numbers up to a production table.

In this part:

  • 9 million row visits against one grouped pass, timed
  • What the plans say in PostgreSQL and MySQL
  • Worked numbers at production size

1 runnable example (SQL) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Rewriting correlated subqueries as joins, window functions and updates Pro: opens with a Pro, Premium or Ultimate pass

Most correlated subqueries can be written another way, and sometimes they should be. This part rewrites three common uses, shows the trap in the most common rewrite, and says when the correlated form is the better choice.

In this part:

  • Counting per row: the LEFT JOIN rewrite and its count(*) trap
  • Window functions: rank() for ties, row_number() for exactly one
  • A correlated subquery in UPDATE
  • Trade-offs: when the correlated form is the better choice

1 runnable example (SQL) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    What is a correlated subquery, and how often does it run?

    A correlated subquery is a subquery that refers to a column of the outer query, such as (SELECT max(o2.placed_on) FROM orders o2 WHERE o2.customer_id = o.customer_id), where o is the outer table. Because the outer value changes from row to row, the subquery is, logically, evaluated again for each outer row, with that row's values in place; an uncorrelated subquery is evaluated once and its result reused. That makes it the natural way to compare each row with its own group, for example the latest order of each customer. Engines may run it more cleverly, for instance MySQL once per distinct outer value, but the cost still grows with the number of outer rows, so it needs an index on the column it correlates on, or a rewrite as a join or a window function.

  2. Fresher to mid

    Return each customer's latest order from orders(id, customer_id, placed_at, amount). Two orders can share a timestamp. Write the query, then change it so that exactly one row per customer comes back, and say which row you return and why.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to senior

    A report with a correlated subquery in its WHERE clause finished in seconds while the orders table was small. At 2 million orders it runs for over an hour. What do you check first, and what are your options to make it fast?

    Model answer, Pro. First step and follow-ups

  4. Fresher to mid

    Two versions of a customer report count orders: one with a correlated subquery in the SELECT list, the other with LEFT JOIN orders and COUNT(*). They agree for most customers but not for all. Which customers differ, which version is right, and why?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A correlated subquery refers to the outer row, and logically runs again for every outer row with that row’s values.
  • It answers group-wise questions directly, such as each row against its own category, or the latest row of each group, where ties return several rows.
  • Its cost grows with the number of outer rows times the work of each run, so it needs an index on the column it correlates on, or a rewrite as a join or a window function.
  • Give the outer and inner tables different aliases, so that the correlation means what you intend.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.