Chapter 14 · Indexes and Query Execution

B-Tree, Hash, Composite, Covering, and Specialized Indexes

Index design is not “one column, one index.” The access method, key order, included data, predicate, and expression determine which searches and orderings the database can satisfy efficiently.

Intermediate135–165 minutesIndex families + composite designLast reviewed: August 2026

Learning outcomes

Choose an index shape for a query shape

01

Compare B-tree, hash, GiST, SP-GiST, GIN, and BRIN at a conceptual level.

02

Apply the leading-column rules of composite B-tree indexes.

03

Design covering indexes that reduce or eliminate table-row fetches.

04

Use partial and expression indexes for stable, selective workload patterns.

05

Avoid redundant indexes whose useful prefixes are already provided elsewhere.

Index families solve different search problems

Index familyTypical strengthsImportant limitation
B-treeEquality, ranges, prefix order, MIN/MAX, and ORDER BY.Key order matters; not ideal for every multidimensional or containment search.
HashEquality lookups.No natural range ordering; implementation and durability characteristics vary by product.
GiST / SP-GiSTGeometric, nearest-neighbor, ranges, trees, and extensible operator classes.Behavior depends on the operator class and data type.
GINInverted indexing for arrays, documents, full text, and containment.Can be larger and more expensive to update.
BRINVery large physically correlated tables, such as append-ordered time series.Summarizes page ranges; weak when physical order does not correlate with the indexed value.
SQLite ordinary indexes are B-trees

SQLite also offers specialized virtual-table modules such as FTS and R-Tree. PostgreSQL exposes multiple built-in access methods, including B-tree, Hash, GiST, SP-GiST, GIN, and BRIN.

Composite keys: equality first, then range and order

For a common B-tree workload, a useful starting heuristic is:

\[ (\text{equality keys},\;\text{range/order key},\;\text{covering payload}) \]
sqlite · composite index for customer history
CREATE INDEX idx_order_customer_status_dateON sales_order (customer_id, status, ordered_at DESC);SELECT order_id, ordered_at, total_centsFROM sales_orderWHERE customer_id = 417  AND status = 'paid'  AND ordered_at >= '2025-05-01'ORDER BY ordered_at DESCLIMIT 20;

The optimizer can seek to one customer and one status, then scan the requested date range in output order.

The left-prefix principle

PredicateUsefulness of (customer_id, status, ordered_at)
customer_id = ?Strong: constrains the first key.
customer_id = ? AND status = ?Strong: constrains the first two keys.
customer_id = ? AND ordered_at >= ?Useful for the customer prefix, but status breaks direct ordering into the date key.
status = ?Usually cannot perform a narrow leading-key seek in a conventional B-tree.
ordered_at >= ?Usually requires broad index scanning because earlier keys are unconstrained.

Some optimizers can use skip-scan or combine indexes, but those are planner choices—not a substitute for designing around the dominant workload.

Covering indexes

A query is covered when all required predicate, ordering, join, and output columns are available from the index. SQLite reports USING COVERING INDEX when it can avoid a separate table lookup.

sqlite · covering index
CREATE INDEX idx_order_status_date_totalON sales_order (status, ordered_at, total_cents);EXPLAIN QUERY PLANSELECT ordered_at, total_centsFROM sales_orderWHERE status = 'pending'  AND ordered_at >= '2025-06-01'ORDER BY ordered_at;
postgresql · INCLUDE payload columns
CREATE INDEX idx_order_customer_date_includeON sales_order (customer_id, ordered_at DESC)INCLUDE (status, total_cents, channel);
Coverage is workload-specific

Adding every output column makes an index wide, increases write amplification, and can reduce cache density. Cover only stable, high-value queries.

Partial indexes: index only the useful subset

sqlite · active-order partial index
CREATE INDEX idx_order_active_dateON sales_order (ordered_at, customer_id)WHERE status IN ('pending', 'processing');EXPLAIN QUERY PLANSELECT order_id, customer_id, ordered_atFROM sales_orderWHERE status IN ('pending', 'processing')  AND ordered_at >= '2025-06-01'ORDER BY ordered_at;

The index excludes paid and cancelled rows, reducing size and maintenance. The query predicate must logically imply the index predicate for the optimizer to use it.

Expression indexes: store a searchable transformation

sqlite · case-insensitive email lookup
CREATE INDEX idx_customer_email_lowerON customer (lower(email));EXPLAIN QUERY PLANSELECT customer_id, emailFROM customerWHERE lower(email) = lower('Customer0417@Example.com');

SQLite requires the indexed expression to appear in the query in essentially the same written form. Only deterministic expressions are valid index keys.

Specialized PostgreSQL examples

postgresql · access methods by workload
-- Equality-only workload.CREATE INDEX idx_session_token_hashON session USING hash (token);-- JSON containment.CREATE INDEX idx_document_payload_ginON document USING gin (payload jsonb_path_ops);-- Append-ordered event time with physical correlation.CREATE INDEX idx_event_created_brinON event_log USING brin (created_at);-- Geometric nearest-neighbor workload.CREATE INDEX idx_place_location_gistON place USING gist (location);

These statements are not portable to SQLite. They illustrate that “create an index” is incomplete without naming the operators and data distribution the index must support.

Detect redundancy

sqlite · inspect index definitions
PRAGMA index_list('sales_order');PRAGMA index_info('idx_order_customer_status_date');SELECT name, sqlFROM sqlite_schemaWHERE type = 'index'  AND tbl_name = 'sales_order'ORDER BY name;

An index on (customer_id) is often redundant when a maintained composite index begins with customer_id. It is not always redundant: width, uniqueness, collation, sort direction, predicates, and workload can still differ.

Checkpoint

Design the key

  1. Which common index family naturally supports equality, range, and ordered output?
  2. Why does (customer_id, status, ordered_at) poorly serve a query filtered only by status?
  3. What is the difference between a composite key column and a PostgreSQL INCLUDE column?
  4. Why can a partial index be both smaller and cheaper to maintain?
  5. What must be true for an expression index on lower(email) to help?
Review the answers

B-tree is the general ordered family. A status-only predicate does not constrain the leading customer key. Composite key columns participate in ordering and search; INCLUDE columns are payload for coverage. A partial index omits rows outside its predicate. The query must use a matching deterministic expression, and the optimizer must estimate that route as beneficial.

Summary and references

  • Choose the access method for the operators and data shape.
  • Composite B-tree order should reflect equality, range, ordering, and coverage needs.
  • Covering indexes save row fetches but increase width.
  • Partial and expression indexes target stable specialized patterns.
  • Review prefix redundancy before adding another index.

References

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.