Subqueries — Learn Scalar, IN, EXISTS, FROM-Clause, Correlated, and CTE Comparison from the Basics
BasicSubqueryScalar subqueryIN / NOT IN / EXISTSFROM clause / correlatedCTE comparisonPostgreSQL-compatible5 questions
QUESTION 6
NOT EXISTS Subquery — NULL-Safe Retrieval of Rows That Do Not Exist
NOT EXISTSCorrelated SQSet differenceDetect incomplete
Background

WHERE NOT EXISTS (subquery) keeps a row when the subquery "returns no rows at all." It has none of the NULL pitfalls of NOT IN and is the recommended pattern for taking a set difference in practice.

SELECT * FROM users u
WHERE NOT EXISTS (
  SELECT 1
  FROM   orders o
  WHERE  o.user_id = u.user_id   -- passes if zero matching rows
);
Why NOT EXISTS is safer than NOT IN:if a NULL slips into the list, NOT IN excludes every row, whereas NOT EXISTS only performs a two-valued "does a row exist?" evaluation, so it is unaffected by NULL. Because production data can contain unexpected NULLs, NOT EXISTS is recommended.
Problem

From the users table, retrieve users who have no order with status 'completed' (incomplete users = dormant candidates). Return user_id, name, plan in ascending order of user_id.

Tables used
▸ users
user_idnameplan
1Tanaka Taropremium
2Sato Hanakofree
3Suzuki Ichiropremium
4Yamada Jirostandard
5Ito Saburofree
▸ orders
order_iduser_idstatus
1011completed
1021completed
1032pending
1043completed
1053completed
1064cancelled
Expected Output

Expected output (no completed, user_id ascending):

user_idnameplan
2Sato Hanakofree
4Yamada Jirostandard
5Ito Saburofree

※ user_id 1 and 3 have completed orders → excluded. 2 (pending), 4 (cancel), and 5 (no orders) pass.

Model Answer
SELECT
  user_id, name, plan
FROM   users u
WHERE  NOT EXISTS (            -- passes when the SQ returns 0 rows (no row exists)
  SELECT 1
  FROM   orders o
  WHERE  o.user_id = u.user_id -- correlated SQ referencing an outer column
    AND  o.status  = 'completed'
)
ORDER BY user_id;

/*
  Execution order (logical evaluation order in SQL):
  1. FROM users u                → process one row at a time
  2. NOT EXISTS(...)             → run the subquery per user
  3. SELECT user_id, name, plan  → select the passing rows
  4. ORDER BY user_id            → user_id ascending
  */
Explanation (table transitions & key points)
SELECT user_id, name, plan FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id AND o.status = 'completed' ) ORDER BY user_id;
LEGEND
Rows read / loaded
① FROM users
FROM users uProcess all 5 rows of the users table one at a time. For each row, the NOT EXISTS subquery is evaluated.
1 / 3
user_idnameplan
1Tanaka Taropremium
2Sato Hanakofree
3Suzuki Ichiropremium
4Yamada Jirostandard
5Ito Saburofree
All 5 rows read
LEARNING POINTS
NOT EXISTS = "pass if no row exists":it is the inverse of EXISTS. It is TRUE only when the subquery returns 0 rows. Contrasting it with the EXISTS in Q5 deepens your understanding.
Why NOT EXISTS is safer than NOT IN:if a NULL slips into the NOT IN list, the evaluation becomes UNKNOWN and every row is excluded. NOT EXISTS only performs a two-valued "does a row exist?" evaluation, so it is unaffected by NULL. Make NOT EXISTS your first choice with production data.
Equivalent rewrite with LEFT JOIN + IS NULL:LEFT JOIN orders o ON u.user_id=o.user_id AND o.status='completed' WHERE o.user_id IS NULL produces the same result. This is a pattern you often see in queries generated by an ORM.
ANTI-PATTERNS
A real case where NULL crept into NOT IN:if user data imported from an external API contains a row with user_id=NULL, then WHERE user_id NOT IN (SELECT user_id FROM orders) returns 0 rows for everything. Simply switching to NOT EXISTS makes it work correctly.
Forgetting the correlation condition:if you do not write the join condition with the outer query WHERE o.user_id = u.user_id inside EXISTS / NOT EXISTS, then every user passes/is excluded as long as orders has even one row. The correlation condition is mandatory.
Field note: scheduled batch detection of dormant users
A batch job such as "send a reminder email to users who have no completed order in the last 30 days" can be implemented simply by putting a time-range condition inside the subquery, as in NOT EXISTS (SELECT 1 FROM orders WHERE user_id = u.user_id AND status='completed' AND ordered_at >= NOW() - INTERVAL '30 days'). NOT EXISTS is highly flexible with conditions and is a staple pattern for real-world batch queries.
QUESTION 7
Correlated Subquery — Reference Outer-Query Values Inside to Compare Against Each User's Average
Correlated SQScalar SQPer-row evalPersonalized analysis
Background

A correlated subquery is a form in which the inner subquery references the value of the row currently being processed by the outer query. Each time the outer query processes a row, the inner query runs. Use it for patterns that contrast a per-group aggregate with each row, such as "compare each order against that user's average."

SELECT o.order_id, o.amount
FROM   orders o
WHERE  o.amount > (
  SELECT AVG(o2.amount)
  FROM   orders o2
  WHERE  o2.user_id = o.user_id  -- references the outer o.user_id (correlation condition)
);
Difference from an ordinary subquery:an ordinary scalar SQ is evaluated once and returns the same value to every row. A correlated SQ is re-evaluated for each row of the outer query, so it returns a value that differs per row (here, "that user's average").
Problem

From the orders table, retrieve only orders that exceed each user's average order amount. The columns to retrieve are order_id, user_id, amount, user_avg (attaching that user's average), ordered by user_id ascending and, within the same user, by amount descending.

Tables used
▸ orders
order_iduser_idamountstatus
10118000completed
102112000completed
10323500pending
10439500completed
10536000completed
10642000cancelled
Per-user averages: user1=(8000+12000)÷2=10000 / user2=3500÷1=3500 / user3=(9500+6000)÷2=7750 / user4=2000÷1=2000
Expected Output

Expected output (only orders exceeding each user's average):

order_iduser_idamountuser_avg
10211200010000
104395007750

※ user2 and user4, who have only one order each, have average = their own amount, so the "exceeds" condition is always FALSE. Only 102 (12000>10000) passes for user1, and only 104 (9500>7750) passes for user3.

Model Answer
SELECT
  o.order_id,
  o.user_id,
  o.amount,
  (SELECT ROUND(AVG(o2.amount))    -- correlated SQ: returns that user's average
   FROM   orders o2
   WHERE  o2.user_id = o.user_id    -- references the outer o.user_id (correlation condition)
  ) AS user_avg
FROM   orders o
WHERE  o.amount > (               -- compare against each user's average to filter
  SELECT AVG(o2.amount)
  FROM   orders o2
  WHERE  o2.user_id = o.user_id   -- the same correlated SQ used in WHERE
)
ORDER BY o.user_id, o.amount DESC;

/*
  Execution order (logical evaluation order in SQL):
  1. FROM orders o                  → process every row one at a time
  2. WHERE o.amount > (correlated SQ) → run the subquery for each row
  3. SELECT ... user_avg            → attach each user's average via the correlated SQ
  4. ORDER BY user_id, amount DESC  → sort
  */
Explanation (table transitions & key points)
SELECT o.order_id, o.user_id, o.amount, ( SELECT ROUND(AVG(o2.amount)) FROM orders o2 WHERE o2.user_id = o.user_id ) AS user_avg FROM orders o WHERE o.amount > ( SELECT AVG(o2.amount) FROM orders o2 WHERE o2.user_id = o.user_id ) ORDER BY o.user_id, o.amount DESC;
LEGEND
Rows read / loaded
① FROM orders
FROM orders oProcess all 6 rows of the orders table one at a time. For each row, the correlated subquery runs.
1 / 3
order_iduser_idamountstatus
10118000completed
102112000completed
10323500pending
10439500completed
10536000completed
10642000cancelled
All 6 rows read
LEARNING POINTS
The essence of a correlated subquery:each time the outer query processes a row, the inner query runs. That is why processing that contrasts a per-group aggregate with each row—such as "each user's average" or "each category's maximum"—can be done in a single query.
Real-world patterns:use it for comparison against an aggregate within a group, such as "show only orders that exceed my own average," "extract the highest-priced product within a category," or "detect departments that exceeded their monthly budget."
Use aliases to distinguish outer and inner:by using the same table under different aliases, as in FROM orders o (outer) and FROM orders o2 (inner), the correlation condition o2.user_id = o.user_id becomes meaningful. Forget the alias and you get an unintended self-reference.
Comparison with window functions:on PostgreSQL / MySQL 8.0+ you can get the same result with AVG(amount) OVER (PARTITION BY user_id), which is also better for performance. A correlated SQ runs the inner query per row, so it can be slow when there is a lot of data.
ANTI-PATTERNS
Forgetting to write the correlation condition:if you omit WHERE o2.user_id = o.user_id, the comparison is against the average of all orders (6833), and it behaves like an ordinary scalar SQ rather than a correlated subquery. Always double-check the correlation condition.
Performance degradation with large data:a correlated SQ runs the inner query for every row of the outer query. If orders has 100,000 rows, up to 100,000 inner queries run. Leverage an index, or use a window function or a CTE.
Field note: choosing between a correlated SQ and a window function
A correlated SQ has the strengths of "works even on old databases" and "reads intuitively," but its weakness is that it becomes slow on large data. On modern RDBs such as PostgreSQL, MySQL 8.0+, and SQL Server, using the window function AVG(amount) OVER (PARTITION BY user_id) needs only a single table scan and improves performance dramatically. However, because you cannot use a window function directly in WHERE, you need to wrap it in a derived table or CTE after SELECT.
QUESTION 8
FROM-Clause Subquery + JOIN — Retrieve the Highest-Priced Product per Category in One Query
FROM-clause SQINNER JOINGROUP BY MAXCatalog management
Background

To "retrieve the highest-priced product in each category," a useful pattern is to first aggregate the MAX price per category and then JOIN that result table with the original table. You can also write it with a correlated subquery, but a derived table + JOIN is more efficient on large data.

SELECT p.*
FROM   products p
INNER JOIN (
  SELECT category, MAX(price) AS max_price  -- aggregate MAX in a derived table
  FROM   products
  GROUP BY category
) AS cat_max
  ON  p.category = cat_max.category
  AND p.price    = cat_max.max_price;   -- only rows matching the MAX value
Why this pattern is effective:by aggregating the category MAX first and then joining, it works more efficiently than a correlated SQ (which computes the MAX per row). Also remember that when several products share the same MAX price, all of them are returned.
Problem

From the products table, retrieve the highest-priced product in each category. The columns to retrieve are product_id, name, category, price, ordered by price descending.

Tables used
▸ products
product_idnamecategoryprice
1Plan Aservice9800
2Plan Bservice4900
3Template Xcontent5500
4Template Ycontent3800
5API Add-onoption3500
6Support Extensionoption2200
Expected Output

Expected output (only the highest-priced product per category, price descending):

product_idnamecategoryprice
1Plan Aservice9800
3Template Xcontent5500
5API Add-onoption3500

※ service max=9800 (Plan A) / content max=5500 (Template X) / option max=3500 (API Add-on)

Model Answer
SELECT
  p.product_id,
  p.name,
  p.category,
  p.price
FROM   products p
INNER JOIN (                       -- place the derived table on the right side of the JOIN
  SELECT
    category,
    MAX(price) AS max_price       -- aggregate the highest price per category
  FROM   products
  GROUP BY category
) AS cat_max                      -- always give the derived table an alias
  ON  p.category = cat_max.category  -- category matches
  AND p.price    = cat_max.max_price  -- and price matches the MAX value
ORDER BY p.price DESC;

/*
  Execution order (logical evaluation order in SQL):
  1. Evaluate the derived table
  2. FROM products p         → read all of products
  3. INNER JOIN ... ON ...   → join only rows where category matches and price = MAX value
  4. SELECT p.product_id...  → select the needed columns
  5. ORDER BY p.price DESC   → sort by price descending
  */
Explanation (table transitions & key points)
SELECT p.product_id, p.name, p.category, p.price FROM products p INNER JOIN ( SELECT category, MAX(price) AS max_price FROM products GROUP BY category ) AS cat_max ON p.category = cat_max.category AND p.price = cat_max.max_price ORDER BY p.price DESC;
LEGEND
Grouping keys / aggregation targets
Group classification
① Derived table (GROUP BY)
SELECT category, MAX(price) FROM products GROUP BY categoryFirst the inner query of the derived table runs. It aggregates the highest price for each of the 3 categories.
1 / 3
product_idnamecategorypricegroup
1Plan Aservice9800service group
2Plan Bservice4900service group
3Template Xcontent5500content group
4Template Ycontent3800content group
5API Add-onoption3500option group
6Support Extensionoption2200option group
→ Derived table: 3 rows (service:9800 / content:5500 / option:3500)
LEARNING POINTS
The "aggregate then JOIN" pattern:first perform a group aggregation (MAX) in a derived table, then JOIN that result with the original table. It is more efficient than a correlated SQ (which computes the MAX per row) and applies to cases like "Top 1 per category" or "the latest row within a group."
Multiple conditions in the ON clause:you can add conditions with AND in the JOIN's ON clause. Only rows that satisfy both the category match and the price match remain in the join result. Writing it in WHERE gives the same result, but placing it in ON as a join condition makes the intent clearer.
When multiple rows share the MAX price:if a category has several products with the same MAX price, this query returns all of them (confirm at design time whether that is intended). If you want only one, you need a window function such as ROW_NUMBER().
ANTI-PATTERNS
Trying to use MAX in WHERE (error):writing WHERE price = MAX(price) produces a syntax error. Aggregate functions cannot be used directly in the WHERE clause. Go through a derived table or use a window function.
Performance when substituting a correlated SQ:WHERE price = (SELECT MAX(price) FROM products p2 WHERE p2.category = p.category) gives the same result, but it runs the inner query per row and becomes slow on large data. A derived table + JOIN is more efficient.
Field note: design patterns for retrieving Top-N within a group
"Top 1 per category" is handled by today's pattern, but "Top 3 per category" gets complicated. On modern RDBs, the standard approach is to use the window function ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) and filter with WHERE rank <= 3. Keep the subquery + JOIN pattern in mind as a fallback for environments where window functions are unavailable.
QUESTION 9
Subquery vs CTE (WITH Clause) — Understand Two Ways to Write the Same Processing
CTE/WITHFROM-clause SQReadabilityEquivalent rewrite
Background

A CTE (Common Table Expression) is defined with WITH name AS (subquery) and is a "temporary named table" that subsequent queries can reference by name. It is equivalent to a FROM-clause subquery (derived table) in many cases, but is superior in readability and reusability.

-- ■ Subquery version (deeply nested)
SELECT * FROM (
  SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status
) AS sub WHERE cnt >= 2;

-- ■ CTE version (flat and easy to read)
WITH sub AS (
  SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status
)
SELECT * FROM sub WHERE cnt >= 2;
Where a CTE excels:① when you reference the same subquery multiple times (define once, reuse); ② when nesting reaches 3 levels or more; ③ when you want to organize logic by naming each step.
Problem

Using the orders table, aggregate the order count (order_cnt) and total amount (total_amount) per status, and return only the statuses whose order_cnt is 2 or more, by total_amount descending. Answer with both the subquery version and the CTE version.

Tables used
▸ orders
order_iduser_idamountstatus
10118000completed
102112000completed
10323500pending
10439500completed
10536000completed
10642000cancelled
Expected Output

Expected output (order_cnt ≥ 2, total_amount descending):

statusorder_cnttotal_amount
completed435500

※ pending (1) and cancelled (1) are excluded because order_cnt < 2. Only completed (4 orders: 8000+12000+9500+6000=35500) remains.

Model Answer
-- ■ Subquery version (derived table)
SELECT
  status,
  order_cnt,
  total_amount
FROM (
  SELECT
    status,
    COUNT(*)    AS order_cnt,   -- aggregate the count
    SUM(amount) AS total_amount -- aggregate the total amount
  FROM   orders
  GROUP BY status
) AS summary                    -- always give the derived table an alias
WHERE  order_cnt >= 2           -- filter by the post-aggregation count
ORDER BY total_amount DESC;

-- ■ CTE version (WITH clause) — returns exactly the same result as above
WITH summary AS (               -- CTE definition (name: summary)
  SELECT
    status,
    COUNT(*)    AS order_cnt,
    SUM(amount) AS total_amount
  FROM   orders
  GROUP BY status
)
SELECT                           -- reference by CTE name (just like an ordinary table)
  status,
  order_cnt,
  total_amount
FROM   summary
WHERE  order_cnt >= 2
ORDER BY total_amount DESC;

/*
  Execution order (the same logical evaluation order for both):
  1. Evaluate the aggregation query (SQ or CTE)  → aggregate per status
  2. FROM summary                → reference the aggregated result
  3. WHERE order_cnt threshold   → filter by the condition
  4. SELECT ...                  → select the 3 columns
  5. ORDER BY total_amount DESC  → total descending
  */
Explanation (table transitions & key points)
SELECT status, order_cnt, total_amount FROM ( SELECT status, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY status ) AS summary WHERE order_cnt >= 2 ORDER BY total_amount DESC;
LEGEND
Grouping keys / aggregation targets
Group classification
① Inner query (GROUP BY)
SELECT status, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY statusFirst the inner query of the derived table runs. It groups orders by status and aggregates the count and total.
1 / 4
order_idstatusamountgroup
101completed8000completed group
102completed12000completed group
103pending3500pending group
104completed9500completed group
105completed6000completed group
106cancelled2000cancelled group
→ Derived table: 3 rows
WITH summary AS ( SELECT status, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY status ) SELECT status, order_cnt, total_amount FROM summary WHERE order_cnt >= 2 ORDER BY total_amount DESC;
LEGEND
Grouping keys / aggregation targets
Group classification
① CTE definition (WITH clause)
WITH summary AS (SELECT status, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY status)Define the query as a CTE named "summary." What it does is exactly the same as the subquery version. Its distinguishing feature is that it can be written flat without nesting.
1 / 4
order_idstatusamountgroup
101completed8000completed group
102completed12000completed group
103pending3500pending group
104completed9500completed group
105completed6000completed group
106cancelled2000cancelled group
→ CTE "summary": 3 rows
LEARNING POINTS
A CTE and a FROM-clause SQ are equivalent in many cases:on PostgreSQL, MySQL 8.0+, SQL Server, and others, a CTE and a FROM-clause subquery are often converted to the same execution plan, so there is no performance difference. Choose based on stylistic preference and readability.
Where a CTE excels:① when you reference the same aggregation in multiple places (you can define multiple CTEs, as in WITH a AS (...), b AS (...) SELECT ...); ② when nesting reaches 3 levels or more; ③ when you want to check intermediate results step by step while debugging.
Where a FROM-clause SQ excels:old MySQL (5.x) does not support CTEs, so only a FROM-clause SQ can be used. Also, for a throwaway, simple aggregation where "it's not worth naming," a FROM-clause SQ can be written more compactly.
ANTI-PATTERNS
Deeply nested subqueries:when a FROM-clause SQ is nested 3 levels or more, readability drops sharply. A query like FROM (FROM (FROM ...)) should be decomposed with CTEs—this is the practical standard.
Over-trusting a CTE (confusing it with a recursive CTE):an ordinary CTE is nothing more than a "throwaway named query." A "recursive CTE (WITH RECURSIVE)" is a separate feature used for things like traversing tree structures. Do not confuse the two.
Field note: team SQL readability standards
In team development, writing "SQL that others can read" is important. Always write complex logic step by step with CTEs, and give each CTE a meaningful name. For example, WITH active_users AS (...), recent_orders AS (...), summary AS (...) SELECT ... lets the SQL function as documentation as well. Use "if a subquery exceeds 3 levels, move to a CTE" as a rule of thumb.
QUESTION 10
Compound Subquery — Combine IN/HAVING, Scalar SQ, and Correlated SQ to Build a Real-World Query
Compound SQHAVINGPractical levelVIP analysis
Background

Real-world SQL often combines several kinds of subqueries. In this problem you combine ① WHERE IN + HAVING (identify high-value users), ② a scalar SQ (correlated) (get the latest order date), and ③ a scalar SQ (correlated) (attach the order count) into a single query.

How to read a complex query:the trick to a compound query is to read it "from the inside out." First confirm what the innermost subquery returns, then follow how it is used in the outer query.
Problem

Write a query that satisfies the following conditions:
① From the orders table, retrieve only the single latest order of each user whose total completed amount is 10,000 or more
② The columns to retrieve are name (from users), order_id, amount, ordered_at, total_orders (that user's total number of orders)
③ Sort by total_orders descending, and by amount descending for the same count.

Tables used
▸ users
user_idnameplan
1Tanaka Taropremium
2Sato Hanakofree
3Suzuki Ichiropremium
4Yamada Jirostandard
5Ito Saburofree
▸ orders
order_iduser_idamountstatusordered_at
10118000completed2024-05-01
102112000completed2024-05-20
10323500pending2024-05-10
10439500completed2024-05-15
10536000completed2024-05-25
10642000cancelled2024-05-08
Expected Output

Expected output (latest order of users with total ≥ 10000, total_orders descending):

nameorder_idamountordered_attotal_orders
Tanaka Taro102120002024-05-202
Suzuki Ichiro10560002024-05-252

※ user1 (completed total 20000≥10000) / user3 (completed total 15500≥10000) → latest order of each user: user1→102 (2024-05-20) / user3→105 (2024-05-25)

Model Answer
SELECT
  u.name,
  o.order_id,
  o.amount,
  o.ordered_at,
  (SELECT COUNT(*)                    -- correlated scalar SQ: attach the total order count
   FROM   orders o3
   WHERE  o3.user_id = u.user_id
  ) AS total_orders
FROM   users u
INNER JOIN orders o
  ON  u.user_id = o.user_id
WHERE  u.user_id IN (              -- ① WHERE IN + HAVING: identify high-value users
  SELECT user_id
  FROM   orders
  WHERE  status = 'completed'
  GROUP BY user_id
  HAVING SUM(amount) >= 10000     -- list of user_ids whose completed total is 10000 or more
)
  AND o.ordered_at = (            -- ② correlated scalar SQ: only the row matching the latest order date
    SELECT MAX(o2.ordered_at)
    FROM   orders o2
    WHERE  o2.user_id = u.user_id
  )
ORDER BY total_orders DESC, o.amount DESC;

/*
  Execution order (logical evaluation order in SQL):
  1. Evaluate the IN subquery                       → generate the list via a HAVING aggregation
  2. FROM users INNER JOIN orders               → join on user_id
  3. WHERE user_id IN (...)                     → narrow to the matching users
  4. AND o.ordered_at = (correlated scalar SQ)  → narrow to each user's latest order
  5. SELECT (correlated scalar SQ)              → attach the total order count
  6. ORDER BY total_orders DESC, o.amount DESC  → count → amount descending
  */
Explanation (table transitions & key points)
SELECT u.name, o.order_id, o.amount, o.ordered_at, ( SELECT COUNT(*) FROM orders o3 WHERE o3.user_id = u.user_id ) AS total_orders FROM users u INNER JOIN orders o ON u.user_id = o.user_id WHERE u.user_id IN ( SELECT user_id FROM orders WHERE status = 'completed' GROUP BY user_id HAVING SUM(amount) >= 10000 ) AND o.ordered_at = ( SELECT MAX(o2.ordered_at) FROM orders o2 WHERE o2.user_id = u.user_id ) ORDER BY total_orders DESC, o.amount DESC;
LEGEND
Columns / keys under evaluation
Excluded / hidden data
✓ pass
✗ excluded
① IN + HAVING subquery
SELECT user_id FROM orders WHERE status='completed' GROUP BY user_id HAVING SUM(amount)>=10000First group completed orders by user_id and list only the user_ids whose total is 10000 or more. This is used to narrow users in the outer query.
1 / 4
user_idSUM(completed)HAVING >= 10000?
18000+12000=20000✓ add to [1,3]
20 (no completed)✗ excluded
39500+6000=15500✓ add to [1,3]
40 (no completed)✗ excluded
5(no orders)✗ excluded
→ IN list: [1, 3]
LEARNING POINTS
How to combine subqueries:design a compound query by being conscious of "which kind of SQ to use in which position." A practical rule of thumb is: ① generate a filter list → IN SQ; ② filter after aggregation → IN SQ with HAVING; ③ a single value that varies per row → correlated scalar SQ; ④ existence check → EXISTS.
The pattern for retrieving "the latest N":ordered_at = (SELECT MAX(ordered_at) FROM orders WHERE user_id = u.user_id) is the standard pattern for retrieving "each user's latest order." Placing a correlated SQ with MAX in an ON or AND condition lets you keep only the latest row while joining.
Filter after grouping with HAVING:by using GROUP BY + HAVING inside the IN subquery, you can dynamically generate a list of "user IDs whose total amount is above a threshold" in a single query. HAVING is a filter condition that can be applied only to aggregate values after GROUP BY.
A complex query is easier to read when decomposed with CTEs:a query like this one becomes far easier to handle during debugging or spec changes when you decompose each step with named CTEs, as in WITH high_value_users AS (...), latest_orders AS (...), summary AS (...) SELECT ....
ANTI-PATTERNS
Care with datetime matching when retrieving the latest order:if ordered_at is a timestamp type, it includes seconds and milliseconds, so all of a user's orders end up with different timestamps. With date only (DATE type), two orders on the same day may both be returned. Depending on your intent, also consider using the ROW_NUMBER() window function.
Performance issues with complex correlated SQs:there is a correlated scalar SQ in the SELECT clause (total_orders) and a correlated SQ in the WHERE clause (the latest ordered_at) as well. As the number of rows grows, evaluation equivalent to O(N²) can occur. In production, check the execution plan with EXPLAIN and replace with window functions or CTEs as needed.
Field note: a stepwise approach to designing complex queries
When writing a complex query in practice, a stepwise design that "starts by verifying the simplest part first" is effective. ① First run just the IN + HAVING to confirm the user list is correct → ② JOIN and confirm the join result → ③ add the correlated SQ's latest-order condition and confirm the narrowing → ④ attach total_orders with a scalar SQ. Building it up in this order makes it easy to pinpoint at which step the result diverges from your intent. "Don't write it all at once; split and verify" is the iron rule of real-world query development.