Performance Optimization — Learn Covering Indexes, LATERAL, MVs, and Partitioning in Practice
AdvancedCovering INDEXLATERALWindow Frames / Moving AverageMaterialized ViewsPartitioning / PruningPostgreSQL-compatible5 questions
QUESTION 6
Covering Indexes — Avoid the table with Index Only Scan
INCLUDEIndex Only ScanVisibility MapIndex Design
Background

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
Visibility Map: Index Only Scan can “truly avoid reading the heap” only when the target page is recorded as all-visible. If VACUUM has not run, Heap Fetches increase and the benefit shrinks, so checking Heap Fetches: 0 in EXPLAIN (ANALYZE) is the operational pass line.
Problem

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.

Source table (conceptual · 8 rows)
- orders (10 million actual rows / 8 example rows)
order_idcustomer_idcreated_atamountnote (large column)
11002024-05-011200
21012024-06-10800
31012024-07-222000
41012024-08-303000
51022024-04-15500
61012024-09-051500
71032024-07-01700
81012024-05-20900

※ 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

Expected output (created_at ascending):

created_atamount
2024-06-10800
2024-07-222000
2024-08-303000
2024-09-051500
QUESTION 7
LATERAL JOIN — Hit the index directly for “Top-N per row”
LATERALTop-N per groupIndex ScanCorrelated Subquery
Background

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
Rule of thumb: If there are few customers and orders is huge, use LATERAL (number of groups × Index Scan); for a batch that processes every customer × every order at once, use a window function (one-pass full scan). Both rely on the same prerequisite index: (customer_id, created_at DESC).
Problem

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.

Source tables
- customers (3 rows · driving table)
customer_idname
101Alice
102Bob
103Carol
- orders (8 examples · indexed on (customer_id, created_at DESC))
order_idcustomer_idcreated_atamount
11012024-05-011200
21012024-06-10800
31012024-07-222000
41022024-08-303000
51022024-04-15500
61032024-09-051500
71032024-07-01700
81032024-05-20900
Expected Output

Expected output (name ascending → created_at descending):

namecreated_atamount
Alice2024-07-222000
Alice2024-06-10800
Bob2024-08-303000
Bob2024-04-15500
Carol2024-09-051500
Carol2024-07-01700
QUESTION 8
Window Frames — Calculate a moving average in one pass with ROWS BETWEEN
Window FunctionsROWS BETWEENMoving AverageROWS vs RANGE
Background

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 and RANGE differ: 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.
Problem

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.

Source table
- daily_sales (6 rows · index on sales_date)
sales_datesales
2024-09-01100
2024-09-02200
2024-09-03300
2024-09-04600
2024-09-05300
2024-09-06900
Expected Output

Expected output (sales_date ascending):

sales_datesalesma3
2024-09-01100100.0
2024-09-02200150.0
2024-09-03300200.0
2024-09-04600366.7
2024-09-05300400.0
2024-09-06900600.0
QUESTION 9
Materialized Views — Precompute and save heavy aggregations
MATERIALIZED VIEWREFRESH CONCURRENTLYPrecomputationFreshness Design
Background

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;
Difference from a normal VIEW: A VIEW is an “alias for a query” and aggregates the source tables on every read. An MV is a “snapshot of the result”: reads are fast, but freshness becomes older. Start MV design by deciding the acceptable age (for example, 1 hour) and refreshing at that interval.
Problem

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.

Source table (conceptual · 6 rows)
- orders (10 million actual rows / 6 example rows)
order_idcreated_atamount
12024-07-031200
22024-07-18800
32024-08-022000
42024-08-213000
52024-09-051500
62024-09-28700
Expected Output

Expected output (month ascending):

monthtotalorder_count
2024-07-0120002
2024-08-0150002
2024-09-0122002
QUESTION 10
Partition Pruning — Use WHERE to reduce the partitions read
PARTITION BY RANGEPruningPartition KeyFunction-on-key trap
Background

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
When pruning works: WHERE must use a plain comparison (=, <, >, BETWEEN) against the partition key. Wrapping the key in a function, such as DATE_TRUNC(created_at), prevents the range from being identified and scans every partition (the Sargable principle applies directly).
Problem

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.

Partition structure (conceptual)
- orders (parent) = a set of 12 child partitions
Child tableRange (FROM → TO)Rows
orders_2024_0707-01 → 08-01about 1 million
orders_2024_0808-01 → 09-01about 1 million
orders_2024_0909-01 → 10-01about 1 million
… (other 9 partitions)
- inside orders_2024_08 (3 example rows)
order_idcreated_atamount
32024-08-022000
42024-08-213000
92024-08-301000
Expected Output

Expected output (1 row):

totalorder_count
60003