Indexes Composite Keys and Query Plans

Relational Databases for Designers · 40 min

System Design · Module 3 · Lesson 2 of 4 · 40 min

Indexes Composite Keys and Query Plans

Choose an index from a hot query, understand the cost of maintaining it, and read query plans without assuming an index is always faster.

Choose an index for the filter and order of an important query, then verify the plan on representative data.

01 The idea

The problem this lesson solves

An owner asks for the newest 20 links.

Prerequisite: earlier lessons introduce the vocabulary and shared examples used here. Follow the module order.

B-treeAn ordered index structure suitable for many equality and range lookups.
SelectivityHow narrowly a condition reduces the rows that must be considered.
Query planThe database’s selected sequence of scans, joins and other operations.

02 Worked example

Follow one worked example

Indexes Composite Keys and Query Plans worked diagram01QueryOwner + recent order02Index rangeOne owner’s keys03Ordered scanFirst 20 rows04EvidencePlan + write costIndexes Composite Keys and Query Plans compact diagram01QueryOwner + recent order02Index rangeOne owner’s keys03Ordered scanFirst 20 rows04EvidencePlan + write cost
Query: Owner + recent order • Index range: One owner’s keys • Ordered scan: First 20 rows • Evidence: Plan + write cost.

Trace the example in order. Follow the same identities, units and state transitions through the diagram and the events below.

1
Name the readAn owner asks for the newest 20 links.
2
Match the indexGroup by owner_id, then store creation time and id in the listing order.
3
Inspect the planCheck whether the plan narrows work and avoids an unnecessary large sort.
4
Review the costMeasure insertion and storage overhead before retaining extra indexes.

Choose an index for the filter and order of an important query, then verify the plan on representative data.

03 The method

Mechanisms decisions and a guided tutorial

Work through the explanation in order, then apply it in the tutorial. Each comparison names a cost as well as a benefit.

Choice or conceptPurposeBoundary or cost
Table scanRead and filter rowsCan be efficient for small/broad reads
Composite indexFilter plus ordered rangeColumn order and data distribution matter
Additional indexAnother access pathWrite, storage and cache cost
EXPLAIN ANALYZEActual execution evidenceExecutes the statement

An index is a second structure to maintain

A B-tree index organizes key values so a query can find a relevant range without scanning every table row. It consumes disk and memory, and inserts, deletes or changes to indexed values must maintain it. Index every column and writes become more expensive without necessarily helping useful queries. On small tables, or when most rows match, a sequential scan can be the right plan. Use EXPLAIN to inspect the planned access path; EXPLAIN ANALYZE executes the statement, so practice it on disposable data.

Order a composite index for the hot query

For ‘this user's most recent bookings’, an index on (user_id, created_at DESC, id DESC) matches equality on user_id followed by ordered traversal. Leading columns generally determine how effectively a multicolumn B-tree narrows its scan. Avoid saying non-leading columns can never use an index: planners may use broader index scans and newer PostgreSQL versions can use skip scans in suitable cases. The useful question is how much work the actual plan performs for this data distribution.

Start with a concrete query

The owner’s recent-link page asks WHERE owner_id = ? ORDER BY created_at DESC, id DESC LIMIT 20. An index beginning with owner_id then the ordered tuple can narrow to the owner’s range and traverse recent rows. An unrelated index on destination does not help merely because the table now has an index.

A table scan reads many rows and filters them. An index can locate candidate keys quickly, but fetching many table rows through it may cost more than a sequential scan. Table size, distribution, cached pages and the fraction returned all matter. An index is an option for the planner, not a command to use it.

The leading columns shape the useful range

An index on (owner_id,created_at,id) groups each owner’s entries. Restricting owner_id makes the remaining order useful within that range. A query only by created_at may require broader work because the first grouping is unconstrained.

Avoid the absolute claim that skipping a leading column makes index use impossible. PostgreSQL can use broad scans and newer versions can apply skip scans in suitable distributions. The design lesson is to put the important access pattern in a strong position and inspect the actual plan rather than memorize an unconditional slogan.

Test reads and writes together

An insert must update the table and each relevant index. Indexed-column updates and deletes also require maintenance. More indexes use storage, cache and maintenance time; unused indexes are not free insurance.

Use EXPLAIN for the planned operations. EXPLAIN ANALYZE runs the query and reports actual timing and row observations, so use disposable data for write experiments. Compare estimates with actual rows and keep statistics representative. A plan on ten toy rows cannot prove behavior on fifty million rows.

Guided tutorial

1. Create practice rows

Use a disposable table with owner_id, created_at and id.

2. Inspect without an index

Run EXPLAIN on the listing query.

3. Add the candidate

Create the composite index and inspect again.

4. Vary the distribution

Compare one owner with many rows and many owners with few rows; explain why results may differ.

-- Run setup once in a disposable PostgreSQL database.
CREATE TABLE sd_lab_links (
  id integer PRIMARY KEY, owner_id integer NOT NULL,
  created_at timestamp NOT NULL
);
INSERT INTO sd_lab_links
SELECT i, i % 100, timestamp '2026-01-01' + i * interval '1 second'
FROM generate_series(1,10000) AS i;
ANALYZE sd_lab_links;
EXPLAIN SELECT id, created_at FROM sd_lab_links
WHERE owner_id = 7 ORDER BY created_at DESC, id DESC LIMIT 20;
CREATE INDEX sd_links_owner_recent
ON sd_lab_links (owner_id, created_at DESC, id DESC);
EXPLAIN SELECT id, created_at FROM sd_lab_links
WHERE owner_id = 7 ORDER BY created_at DESC, id DESC LIMIT 20;

04 Cheat sheet

Review the decisions

Use this table to explain the mechanism and its limit without memorizing a product name.

Choice or conceptRememberCheck
Table scanRead and filter rowsCan be efficient for small/broad reads
Composite indexFilter plus ordered rangeColumn order and data distribution matter
Additional indexAnother access pathWrite, storage and cache cost
EXPLAIN ANALYZEActual execution evidenceExecutes the statement
B-treeAn ordered index structure suitable for many equality and range lookups.
SelectivityHow narrowly a condition reduces the rows that must be considered.
Query planThe database’s selected sequence of scans, joins and other operations.

05 Where & why

Check the primary references

Use official references for protocol and implementation details. The questions here are teaching prompts.

Primary reference
PostgreSQL 16 transaction isolation

Read PostgreSQL 16 transaction isolation

Primary reference
PostgreSQL explicit locking

Read PostgreSQL explicit locking

Primary reference
Multicolumn index behavior

Read Multicolumn index behavior

State the implementation version and the workload assumptions when defending your own design.

06 Interview questions

Explain the reasoning out loud

Answer before opening the explanation. The questions progress from vocabulary to decisions and failure behavior.

What does B-tree mean?
An ordered index structure suitable for many equality and range lookups.
What does Selectivity mean?
How narrowly a condition reduces the rows that must be considered.
What does Query plan mean?
The database’s selected sequence of scans, joins and other operations.
Why might a planner choose a sequential scan?
The table may be small or the query may return much of it. Many scattered row fetches through an index can cost more than a sequential pass.
Why include a unique id after time?
It breaks timestamp ties for stable ordering and cursor continuation. The index order should match the query contract.
Can an index hurt writes?
Yes. Relevant index entries must be maintained when rows change. Measure read benefits together with write and storage costs.
Why is a ten-row benchmark weak evidence?
Its distribution, cache behavior and scan cost differ from a large production workload. It can validate syntax, but not a capacity claim.
What is the danger of EXPLAIN ANALYZE on an UPDATE?
It executes the write. Use an isolated dataset or a deliberate transaction and understand side effects before running it.

07 Practice problems

Practice a new case

Solve the new cases before revealing hints. Complete worked solutions follow the set.

Match a query

Easy
Choose an index prefix for WHERE tenant_id = ? ORDER BY id DESC.
Follow-up
State the assumption or edge case that makes the answer correct.
Show the hint
Equality filtering defines the first range.

Name a cost

Easy
List two resources an unused index consumes.
Follow-up
State the assumption or edge case that makes the answer correct.
Show the hint
It remains part of the write path.

Choose column order

Medium
Compare (user_id,time,id) with (time,user_id,id) for one user’s recent events.
Follow-up
State the assumption or edge case that makes the answer correct.
Show the hint
The hot query filters one user first.

Broad read

Medium
Explain why WHERE active=true might not benefit from an index when 98% of rows are active.
Follow-up
State the assumption or edge case that makes the answer correct.
Show the hint
The condition eliminates little work.

Inspect safely

Medium
Choose EXPLAIN or EXPLAIN ANALYZE to inspect a destructive query without executing it.
Follow-up
State the assumption or edge case that makes the answer correct.
Show the hint
The second form runs the statement.

Review an index proposal

Hard
An engineer adds an index for every column to fix one slow endpoint; write a review.
Follow-up
State the assumption or edge case that makes the answer correct.
Show the hint
Begin with the actual filter, order and row count.

Worked solutions

1. Match a query

A candidate is (tenant_id,id DESC), subject to workload measurement.

2. Name a cost

It uses storage/cache and adds write maintenance; it also increases operational work such as backups and rebuilds.

3. Choose column order

The user-leading index directly groups that user’s ordered range. The time-leading index prioritizes a different access pattern; inspect actual plans rather than promising equivalence.

4. Broad read

Fetching nearly the whole table through the index may add random access without avoiding much reading. A sequential scan can be cheaper.

5. Inspect safely

Use EXPLAIN alone for the estimated plan. Test actual destructive behavior only in a controlled disposable environment.

6. Review an index proposal

Request the query and representative plan, propose a targeted candidate, measure before/after read and write behavior, and remove unsupported indexes. Avoid promising speed from index count alone.