A normal Index Scan has two stages: “identify the location of a row in the index → jump to the heap (the table itself) and read the values.” If every column to return is contained in the index, the heap visit itself can be skipped and PostgreSQL can choose Index Only Scan. In PostgreSQL 11+, the INCLUDE clause adds columns that are not search keys but should be stored alongside the index leaf entries.
-- ✓ Covering index: amount is included, not used as a search key CREATE INDEX idx_orders_cover ON orders (customer_id, created_at) INCLUDE (amount); -- ✗ Only (customer_id, created_at) → a heap visit is needed for amount on every row
Heap Fetches: 0 in EXPLAIN (ANALYZE) is the operational pass line.For orders (10 million rows), a dashboard runs the list of order dates and amounts for customer 101 from 2024-06-01 onward several hundred times per second. Define an index that can return the result with zero heap visits (Index Only Scan), and write the SELECT statement that uses it.
| order_id | customer_id | created_at | amount | note (large column) |
|---|---|---|---|---|
| 1 | 100 | 2024-05-01 | 1200 | … |
| 2 | 101 | 2024-06-10 | 800 | … |
| 3 | 101 | 2024-07-22 | 2000 | … |
| 4 | 101 | 2024-08-30 | 3000 | … |
| 5 | 102 | 2024-04-15 | 500 | … |
| 6 | 101 | 2024-09-05 | 1500 | … |
| 7 | 103 | 2024-07-01 | 700 | … |
| 8 | 101 | 2024-05-20 | 900 | … |
※ customer_id=101 and created_at >= '2024-06-01' produce 4 rows: id=2,3,4,6. The only selected columns are created_at and amount.
Expected output (created_at ascending):
| created_at | amount |
|---|---|
| 2024-06-10 | 800 |
| 2024-07-22 | 2000 |
| 2024-08-30 | 3000 |
| 2024-09-05 | 1500 |
- 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
LATERAL lets a subquery placed in a JOIN refer to rows on its left. It directly expresses “the latest N rows for each customer” as a Nested Loop that looks up the index once per customer. Unlike the window-function approach, which assigns ROW_NUMBER to all rows and then discards most of them, its strength is that scanning stops at LIMIT N for each group.
-- For each c, loop over the index to get only c’s latest 2 rows FROM customers c CROSS JOIN LATERAL ( SELECT ... FROM orders o WHERE o.customer_id = c.customer_id -- The left-side c is visible here! ORDER BY o.created_at DESC LIMIT 2 ) recent
(customer_id, created_at DESC).From customers (3 rows) and orders (10 million actual rows / 8 examples, indexed on (customer_id, created_at DESC)), retrieve the latest 2 orders for each customer. Return name, created_at, amount, ordered by name ascending and then created_at descending. Customers with no orders do not need to appear.
| customer_id | name |
|---|---|
| 101 | Alice |
| 102 | Bob |
| 103 | Carol |
| order_id | customer_id | created_at | amount |
|---|---|---|---|
| 1 | 101 | 2024-05-01 | 1200 |
| 2 | 101 | 2024-06-10 | 800 |
| 3 | 101 | 2024-07-22 | 2000 |
| 4 | 102 | 2024-08-30 | 3000 |
| 5 | 102 | 2024-04-15 | 500 |
| 6 | 103 | 2024-09-05 | 1500 |
| 7 | 103 | 2024-07-01 | 700 |
| 8 | 103 | 2024-05-20 | 900 |
Expected output (name ascending → created_at descending):
| name | created_at | amount |
|---|---|---|
| Alice | 2024-07-22 | 2000 |
| Alice | 2024-06-10 | 800 |
| Bob | 2024-08-30 | 3000 |
| Bob | 2024-04-15 | 500 |
| Carol | 2024-09-05 | 1500 |
| Carol | 2024-07-01 | 700 |
- 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
A window-function frame clause determines which range of rows is included in the calculation as seen from each row. ROWS BETWEEN 2 PRECEDING AND CURRENT ROW means “the current row and the previous 2 rows,” or a 3-row moving average. Once the table is sorted, the moving aggregation is completed by a single pass, without a self-join.
AVG(sales) OVER ( ORDER BY sales_date -- Frame ordering ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- current + previous 2 rows = 3-row window )
ROWS frames by physical row count, while RANGE frames by a value range (all equal values are included together). An even more important trap: the default when the frame is omitted is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. With duplicate dates, that becomes a cumulative average including all peers with the same value and may diverge from the intent. Always specify ROWS for moving aggregates.From daily_sales, retrieve a list ordered by date with a 3-day moving average. Return sales_date, sales, ma3, rounding the moving average to 1 decimal place. For the first 2 days, average only the rows that exist.
| sales_date | sales |
|---|---|
| 2024-09-01 | 100 |
| 2024-09-02 | 200 |
| 2024-09-03 | 300 |
| 2024-09-04 | 600 |
| 2024-09-05 | 300 |
| 2024-09-06 | 900 |
Expected output (sales_date ascending):
| sales_date | sales | ma3 |
|---|---|---|
| 2024-09-01 | 100 | 100.0 |
| 2024-09-02 | 200 | 150.0 |
| 2024-09-03 | 300 | 200.0 |
| 2024-09-04 | 600 | 366.7 |
| 2024-09-05 | 300 | 400.0 |
| 2024-09-06 | 900 | 600.0 |
- 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
Running the same heavy aggregation (such as monthly sales) every time is wasteful. A materialized view (MV) saves query results as a physical table, so the read side only reads a small precomputed table. Freshness is controlled by the timing of REFRESH MATERIALIZED VIEW.
-- The aggregation runs once at definition time and the result is materialized CREATE MATERIALIZED VIEW mv_monthly_sales AS SELECT DATE_TRUNC('month', created_at) AS month, SUM(amount) AS total FROM orders GROUP BY 1; -- Update: CONCURRENTLY does not block reads (a unique index is required) REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_sales;
A monthly sales aggregation over orders (10 million rows) runs every minute in a dashboard and is the main cause of database load. Write: (1) a monthly-sales MV definition, (2) a unique index so it can be refreshed without stopping reads, and (3) the dashboard SELECT ordered by month ascending.
| order_id | created_at | amount |
|---|---|---|
| 1 | 2024-07-03 | 1200 |
| 2 | 2024-07-18 | 800 |
| 3 | 2024-08-02 | 2000 |
| 4 | 2024-08-21 | 3000 |
| 5 | 2024-09-05 | 1500 |
| 6 | 2024-09-28 | 700 |
Expected output (month ascending):
| month | total | order_count |
|---|---|---|
| 2024-07-01 | 2000 | 2 |
| 2024-08-01 | 5000 | 2 |
| 2024-09-01 | 2200 | 2 |
- 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
Table partitioning physically divides a huge table into multiple child tables using a partition key, often a date. When a WHERE clause identifies a key range, the planner excludes irrelevant partitions in the plan itself (pruning). An index “narrows the rows,” while pruning “narrows the tables to read,” making it a higher-level first cut.
CREATE TABLE orders ( order_id bigint, created_at date, amount int, ... ) PARTITION BY RANGE (created_at); -- Partition key CREATE TABLE orders_2024_08 PARTITION OF orders FOR VALUES FROM ('2024-08-01') TO ('2024-09-01'); -- Monthly partition
DATE_TRUNC(created_at), prevents the range from being identified and scans every partition (the Sargable principle applies directly).From monthly RANGE-partitioned orders (12 partitions × approximately 1 million rows each), retrieve the August 2024 sales total and count. Use a WHERE clause that enables pruning, with a half-open range using >= / <. Return total, order_count.
| Child table | Range (FROM → TO) | Rows |
|---|---|---|
| orders_2024_07 | 07-01 → 08-01 | about 1 million |
| orders_2024_08 | 08-01 → 09-01 | about 1 million |
| orders_2024_09 | 09-01 → 10-01 | about 1 million |
| … (other 9 partitions) | … | … |
| order_id | created_at | amount |
|---|---|---|
| 3 | 2024-08-02 | 2000 |
| 4 | 2024-08-21 | 3000 |
| 9 | 2024-08-30 | 1000 |
Expected output (1 row):
| total | order_count |
|---|---|
| 6000 | 3 |
- 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