In Basic 2 Q6, we used NOT EXISTS to find “people who have never participated.” In practice, a more advanced pattern appears constantly: period-conditioned exclusion, such as “no participation in the last 90 days (= dormant).” The key is to put the additional condition inside the subquery—you can directly express “there is no participation within the last 90 days,” allowing the planner to choose a Hash Anti Join with filtering through a composite index.
-- ✗ NOT IN + period condition: a NULL makes the result empty (the Basic Q6 trap returns) WHERE member_id NOT IN (SELECT member_id FROM event_entries WHERE entered_at >= DATE '2026-03-14') -- ✓ Put the condition inside the subquery: NULL-safe + Anti Join WHERE NOT EXISTS (SELECT 1 FROM event_entries e WHERE e.member_id = m.member_id AND e.entered_at >= DATE '2026-03-14')
matching member_id AND within the period inside the subquery to mean “a person with no participation during the period.” Trying to put it outside breaks the meaning, even though this is an exclusion. The principle is to complete the granularity of every exclusion condition inside the subquery.There is a members table (100,000 actual rows) and an event_entries table (3 million actual rows; member_id allows NULL to represent guest participation). Retrieve “dormant members” whose status is active and who have never participated on or after 2026-03-14, in a form that is not broken by NULL and can be optimized as an Anti Join. Return member_id, name (member_id ascending). Also define a composite index to speed up matching on the subquery side.
| member_id | name | status |
|---|---|---|
| 1 | Aoki | active |
| 2 | Baba | active |
| 3 | Chiba | inactive |
| 4 | Doi | active |
| 5 | Endo | active |
| entry_id | member_id | entered_at |
|---|---|---|
| 1 | 2 | 2026-05-10 |
| 2 | 4 | 2026-01-20 |
| 3 | NULL | 2026-05-01 |
| 4 | 5 | 2026-06-01 |
| 5 | 2 | 2026-02-11 |
※ Imagine this as a mailing list for dormant-member reminders. With NOT IN, the NULL on entry_id=3 (within the period) makes the mailing list empty.
Expected output (member_id ascending):
| member_id | name |
|---|---|
| 1 | Aoki |
| 4 | Doi |
Doi (4) is dormant because there is participation history but none during the period. Baba (2) and Endo (5) participated during the period, while Chiba (3) is excluded because inactive.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
In Basic 2 Q7, FILTER consolidated condition-based aggregates for the whole table into one scan. In the advanced set, we combine it with GROUP BY to build a cross-tabulation (pivot). Practical reports also inevitably need ratios—completion rate, churn rate, and so on—and these can be calculated as expressions of aggregate values within the same scan. There are two traps: integer division truncates to 0, and SUM becomes NULL in a group with zero matching rows.
-- ✗ Integer division: 2 / 3 = 0 (the fraction disappears) / denominator 0 causes division by zero COUNT(*) FILTER (WHERE …) / COUNT(*) -- ✓ Multiply by 100.0 to make it numeric + use NULLIF to turn denominator 0 into NULL ROUND(100.0 * COUNT(*) FILTER (WHERE …) / NULLIF(COUNT(*), 0), 1)
NULLIF(a, b) returns NULL when a = b and returns a otherwise. Wrap a denominator in NULLIF(denominator, 0) so the whole expression becomes NULL when the denominator is 0 and the query does not fail with an error. Likewise, a SUM(…) FILTER with zero matching rows becomes NULL, so reporting convention is to turn it back into 0 with COALESCE(…, 0).From the orders table (5 million actual rows), aggregate the following four values for each category in one scan only: total_cnt (all rows), completed_cnt (completed rows), completed_amt (completed sales total; 0 when there are no matches), and completed_rate (completion rate as a percentage to one decimal place, using a formula that does not fail with denominator 0). Output in ascending category order.
| order_id | category | status | amount |
|---|---|---|---|
| 1 | book | completed | 1200 |
| 2 | book | cancelled | 800 |
| 3 | food | completed | 500 |
| 4 | food | completed | 700 |
| 5 | food | completed | 300 |
| 6 | toy | pending | 900 |
| 7 | book | completed | 2000 |
※ If you write the “completion-rate report by category” as subqueries for each category × aggregate type, it becomes 5 million rows × (number of categories × 4) scans.
Expected output (ascending category, 3 rows):
| category | total_cnt | completed_cnt | completed_amt | completed_rate |
|---|---|---|---|---|
| book | 3 | 2 | 3200 | 66.7 |
| food | 3 | 3 | 1500 | 100.0 |
| toy | 1 | 0 | 0 | 0.0 |
toy has zero completed rows—SUM becomes NULL and the rate numerator becomes 0. The question tests COALESCE and numeric conversion.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
In Basic 2 Q8, a single-column index eliminated the sort for ORDER BY. Real-world list screens almost always combine “filtering + sorting + pagination.” A form such as WHERE category = 'tech' ORDER BY published_at DESC LIMIT 3 becomes a single operation with a composite index (category, published_at DESC): jump to the equality interval → read the interval in order → stop after N rows. For pagination, keyset pagination (using the previous page’s last-row value as the boundary) avoids OFFSET and keeps deep pages fast.
-- ✗ OFFSET: rows to discard are still read (page 100 = read and discard 297 rows, then return 3) ORDER BY published_at DESC LIMIT 3 OFFSET 297 -- ✓ keyset: jump directly from the previous page’s last value through the index WHERE category = 'tech' AND published_at < '2026-06-02' ORDER BY published_at DESC LIMIT 3
category (equality) first so tech entries form a contiguous interval, already ordered by published_at DESC within that interval. With the reverse order, (published_at DESC, category), tech rows are scattered across the full index; the sort may disappear, but skipping irrelevant categories remains.From the articles table (2 million actual rows), retrieve the three latest articles in category = 'tech' (page 1) in descending published_at order. Also write (1) the composite index that eliminates the sort stage for this query and (2) the page-2 query that does not use OFFSET (assume the last row on page 1 had published_at = '2026-06-02'). Return article_id, title, published_at.
| article_id | category | title | published_at |
|---|---|---|---|
| 1 | life | Morning Habits | 2026-05-01 |
| 2 | tech | Index Design | 2026-06-09 |
| 3 | life | Organizing Your Days Off | 2026-04-12 |
| 4 | tech | JOIN Basics | 2026-06-11 |
| 5 | life | The Science of Sleep | 2026-03-30 |
| 6 | tech | Sorting and Memory | 2026-06-02 |
| 7 | tech | Using CTEs | 2026-05-20 |
| 8 | tech | Introduction to Statistics | 2026-04-25 |
※ Imagine a category-based latest-articles list with a “Load more” button. Each access either sorts 2 million rows or walks three entries in one contiguous index interval—that is the difference.
Expected output (page 1: tech, published_at descending, 3 rows):
| article_id | title | published_at |
|---|---|---|
| 4 | JOIN Basics | 2026-06-11 |
| 2 | Index Design | 2026-06-09 |
| 6 | Sorting and Memory | 2026-06-02 |
Page 2 (keyset method):
| article_id | title | published_at |
|---|---|---|
| 7 | Using CTEs | 2026-05-20 |
| 8 | Introduction to Statistics | 2026-04-25 |
Both pages use only Limit → Index Scan in the execution plan. There is no Sort node and no discarded OFFSET scan.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
In Basic 2 Q9, we learned that UNION ALL is for combining disjoint sources. The advanced step is what comes next: retrieving only the latest N rows from the combined result. The naive form “connect everything, then sort the whole result” is expensive. If each table has an index on created_at, take Top-N in each branch first (at most N × number of branches), then reorder the outer result and keep N rows. With the right conditions PostgreSQL can even use Merge Append to merge already ordered streams.
-- ✗ Append everything → Sort 6 million rows → 3 rows Limit → Sort (6 million rows) → Append → Seq Scan ×2 -- ✓ 3 rows from each branch index → reorder at most 6 rows and keep 3 Limit → Sort/Merge (≤6 rows) → Append ├─ Limit → Index Scan Backward (logs_202605) └─ Limit → Index Scan Backward (logs_202606)
Logs are split into monthly tables logs_202605 / logs_202606 (3 million actual rows each; log ID ranges are disjoint; each table has a descending index on created_at). Retrieve the three latest logs across both tables in a form that does not cause a full-table sort. Return log_id, message, created_at (created_at descending).
| log_id | message | created_at |
|---|---|---|
| 101 | deploy ok | 2026-05-28 |
| 102 | cache warm | 2026-05-30 |
| 103 | batch done | 2026-05-15 |
| log_id | message | created_at |
|---|---|---|
| 201 | deploy ok | 2026-06-10 |
| 202 | index rebuilt | 2026-06-03 |
| 203 | backup done | 2026-06-11 |
※ Imagine an “Latest logs” widget for an operations dashboard. The requirement is to handle month boundaries correctly without sorting 6 million rows.
Expected output (created_at descending, 3 rows):
| log_id | message | created_at |
|---|---|---|
| 203 | backup done | 2026-06-11 |
| 201 | deploy ok | 2026-06-10 |
| 202 | index rebuilt | 2026-06-03 |
The message is the same (deploy ok) in both months, but log_id and created_at differ, so UNION would not remove these two rows. With disjoint sources, use UNION ALL to avoid unnecessary duplicate-elimination cost.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
This is the final synthesis of Advanced 2. Build a monthly sales report by combining all the components learned so far: ① combine monthly tables with UNION ALL (Q9), ② exclude blocked accounts with NOT EXISTS (Q6: NULL-safe + Anti Join), ③ pivot region × status and calculate amounts with FILTER + COALESCE (Q7), and the new element, ④ select groups with HAVING—aggregate values (including FILTER aggregates!) can be used as conditions.
-- Pattern assembly (data flow = execution order) FROM ( ... UNION ALL ... ) s -- ① combine (Append only) WHERE NOT EXISTS ( ... ) -- ② exclude rows (upstream, before aggregation) GROUP BY ... -- ③ group + FILTER aggregates HAVING aggregate FILTER (...) >= n -- ④ select groups (after aggregation) ORDER BY aggregate_column DESC
Combine monthly sales sales_202605 / sales_202606 (ID ranges are disjoint; each has several million actual rows), exclude sales from blocked accounts, and aggregate paid count, paid sales total, and refund count by region. Output only regions with at least two paid rows, ordered by paid sales total descending. blocked_accounts.customer_id allows NULL (rows under review may be present). Return region, paid_cnt, paid_amt, refund_cnt.
| sale_id | region | status | amount | customer_id |
|---|---|---|---|---|
| 1 | east | paid | 1000 | 11 |
| 2 | west | paid | 1500 | 12 |
| 3 | east | refund | 400 | 13 |
| 4 | north | paid | 300 | 15 |
| sale_id | region | status | amount | customer_id |
|---|---|---|---|---|
| 11 | east | paid | 2000 | 11 |
| 12 | west | refund | 700 | 999 |
| 13 | east | paid | 800 | 14 |
| 14 | west | paid | 1200 | 12 |
| block_id | customer_id |
|---|---|
| 1 | 999 |
| 2 | NULL |
※ Also consider the number of scans and what NULL does if you write this as “UNION + NOT IN + a subquery per region × status + an outer count filter.”
Expected output (paid_amt descending):
| region | paid_cnt | paid_amt | refund_cnt |
|---|---|---|---|
| east | 3 | 3800 | 1 |
| west | 2 | 2700 | 0 |
sale_id=12 (the refund for blocked customer 999) is excluded. north drops out in HAVING because it has only one paid row. With NOT IN, the NULL in blocked_accounts makes the entire report return zero rows.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free