Indexing Overview for Query Access
A table with ten million rows and no index causes a problem. The database must scan every single row. It does this just to find one customer's order. This is the core problem indexing solves. It turns a linear search into something dramatically faster. It does this by organizing auxiliary structures around the data. Real systems depend entirely on indexes for speed. A banking ledger might answer "show me account 44219's balance." An e-commerce catalog might filter by price range. Indexes make these queries return in milliseconds rather than minutes. Indexing sits at a boundary. On one side is the logical view of data — rows in a table, defined via SQL Data Definition and Schema Creation. On the other side is physical reality: disk blocks, memory pages, and pointer chasing. A well-chosen index converts an table scan into something faster. It becomes an tree lookup. In the best case, it becomes an hash lookup. But indexes are not free. They consume storage and slow down writes. They must be chosen deliberately based on the queries a workload actually runs. This article builds the conceptual vocabulary first. It covers primary versus secondary indexes. It covers dense versus sparse indexes. It covers ordered (B+-tree) versus hash access paths. Then it walks through exactly how a B+-tree lookup works, block by block. It does the same for a hash lookup. It closes by connecting indexing to the machinery that consumes it. This includes the Query Processing Overview pipeline that chooses access paths. It also includes the Cost Estimation for Query Plans models. These models decide whether an index is actually worth using. The big picture: an index trades space and write-time overhead for read-time speed. Nearly every performance-critical database decision is really a decision about indexes. This includes primary key design. It includes query tuning too.
- An index is an auxiliary data structure that maps search-key values to record locations, avoiding full table scans.
- Primary indexes are built on the file's sort order (usually the primary key); secondary indexes are built on other columns.
- Dense indexes have one entry per search-key value; sparse indexes have one entry per block, trading space for lookup cost.
- B+-trees give ordered, balanced access and support range queries; hash indexes give expected point lookups but no ordering.
- Every index speeds up reads but slows down inserts, updates, and deletes, and consumes extra disk space.
- The query optimizer chooses which index (if any) to use based on selectivity and cost, a decision formalized in cost-based query planning.
An index trades storage space and write overhead for a logarithmic or constant-time path to the rows a query actually needs.
The idea
An index is a data structure, separate from the table's raw storage, that maps values of a search key to the locations of the records containing those values. Formally, an index on attribute supports a function
in time far better than scanning every record. Without an index, answering `WHERE A = v` requires reading every block of the table — a linear scan costing block accesses, where is the number of blocks. A primary index (sometimes called a clustering index) is built on the attribute(s) that determine the physical sort order of the file — typically the primary key. Because the file is physically ordered, primary indexes can be sparse: one index entry per block is enough, since once you land in the right block you scan a handful of records to find the exact match. A secondary index is built on a non-ordering attribute. Since the file is not sorted by that attribute, a secondary index must be dense — one entry per distinct search-key value (or per record) — because there is no way to infer which block holds a given value without an explicit pointer for each value. Two access-path technologies dominate: ordered indexes, almost universally implemented as B+-trees, and hash indexes. A B+-tree keeps keys sorted in a balanced multiway tree, supporting equality search, range search, and ordered traversal, all in disk accesses where is the tree's fanout. A hash index applies a hash function to the search key to compute a bucket address directly, giving expected access for exact-match queries but no support for range queries or ordering — hashing scatters keys, so `WHERE age BETWEEN 20 AND 30` cannot be answered by jumping to one bucket. Choosing between them is a direct consequence of the query workload: point lookups favor hashing, range queries and sorting favor B+-trees.
How it works
B+-tree structure. A B+-tree of order is a balanced tree where every internal node holds between and pointers (and keys), and all actual data pointers live in the leaf nodes, which are linked together left-to-right to support fast range scans. Internal nodes exist purely to route search — they hold copies of keys to guide traversal, not data pointers. This design keeps the tree shallow (height for keys) while making sequential range scans cheap, since leaves form a linked list.
Search algorithm. Starting at the root, at each internal node the search compares the target key against the node's keys to pick the correct child pointer, descending until a leaf is reached; the leaf is then scanned for the matching key(s). ``` function BPlusTreeSearch(node, key): if node is a leaf: for each (k, pointer) in node.entries: if k == key: return pointer // found: pointer to record (or record itself) return NOT_FOUND // internal node: find the child to descend into i = 0 while i < node.numKeys and key >= node.keys[i]: i = i + 1 return BPlusTreeSearch(node.children[i], key) function RangeSearch(node, low, high): leaf = BPlusTreeFindLeaf(node, low) // descend to leftmost leaf >= low results = [] while leaf != null: for each (k, pointer) in leaf.entries: if k > high: return results if k >= low: results.append(pointer) leaf = leaf.nextLeaf // follow the leaf-level linked list return results ```
Insertion. Insert the new key into the correct leaf (found via search). If the leaf overflows (exceeds capacity), split it into two leaves and push a copy of the middle (or first key of the right half) up into the parent. If the parent overflows, split it too, and the process may propagate up to the root, possibly creating a new root and increasing the tree's height by one. ``` function Insert(tree, key, pointer): leaf = FindLeaf(tree.root, key) InsertIntoLeaf(leaf, key, pointer) if leaf.numEntries > MAX_LEAF_ENTRIES: SplitLeaf(leaf) // may recursively split ancestors function SplitLeaf(leaf): newLeaf = new LeafNode() move upper half of leaf.entries to newLeaf newLeaf.nextLeaf = leaf.nextLeaf leaf.nextLeaf = newLeaf promoteKey = newLeaf.entries[0].key InsertIntoParent(leaf.parent, promoteKey, newLeaf) // may cascade upward ```
Hash index structure and search. A hash index applies a hash function to the search key to compute a bucket number, then follows that bucket's pointer directly to the data (or to an overflow chain if the bucket is full). ``` function HashSearch(hashTable, key): bucketIndex = h(key) mod numBuckets bucket = hashTable.buckets[bucketIndex] for each (k, pointer) in bucket.entries: // includes overflow chain if k == key: return pointer return NOT_FOUND ``` Static hashing fixes `numBuckets` at creation time, so heavy growth causes long overflow chains and degraded performance; extendable hashing and linear hashing grow the bucket directory dynamically to keep chains short, at the cost of extra bookkeeping (a directory of pointers reinterpreted via a growing number of hash bits).
Complexity & correctness
B+-tree time complexity. Let be the number of keys and the tree's fanout (order). The tree height is
because each level partitions the key space among up to children, and a perfectly balanced tree of height can hold up to keys. Since is chosen so that one node fits in one disk block (typically hundreds of entries for 4KB–16KB blocks), is very small in practice — a tree with fanout 100 needs only levels, so even a billion-row table needs about 5 block accesses. Each descent step does work at a node (binary search within the node, or even linear scan if is small), so total search cost is disk I/Os, and range queries of size cost since matching leaves are linked sequentially. Insertion and deletion also cost , since they follow the same root-to-leaf path and any node split or merge propagates along at most that path.
Why it's balanced (invariant argument). The key invariant is: every leaf is at the same depth. Insertion never adds a new leaf below existing ones — it only splits a full leaf into two leaves at the same depth, pushing a separator key up. If the split propagates all the way to the root, a new root is created above the old one, uniformly increasing the depth of every leaf by exactly one. Because splits are the only way the tree grows, and they preserve uniform depth, the invariant holds by induction on the number of insertions. This guarantees worst-case height, unlike an unbalanced binary search tree which can degrade to .
Space complexity. A B+-tree occupies leaf blocks (data pointers) plus internal blocks (proportional across levels, geometric series bounded by a constant factor of leaf count), so total space is — linear in the number of indexed entries, with a constant-factor overhead controlled by fanout.
Hash index complexity. Assuming a good hash function distributes keys uniformly across buckets, and load factor is kept near 1 (via dynamic resizing), expected search cost is amortized. This follows directly from the expected chain length in each bucket under uniform hashing — a standard hashing argument, the same one underlying hash join-style algorithms. Worst case, if all keys collide into one bucket (an adversarial or pathological hash function), search degrades to ; this is why production hash indexes use strong, well-tested hash functions and dynamic bucket splitting (extendable/linear hashing) to bound chain length.
Honest tradeoffs. Hash indexes cannot answer range queries, ordering (`ORDER BY`), or prefix matches (`LIKE 'abc%'`) efficiently, because semantically-close keys are scattered to unrelated buckets by design. B+-trees pay a factor that hashing avoids for pure equality lookups, and every index — hash or tree — adds write-path cost: each insert/update/delete must also update every affected index, and each index consumes disk space proportional to the indexed column's size times the row count. A table with five secondary indexes may see inserts run several times slower than an unindexed table, which is why indexing decisions are workload-driven, not automatic.
Worked example
Consider a `Students` table with 10,000 rows, physically stored sorted by `student_id` (the primary key), block size holding 100 records, so the file spans 100 blocks.
Case 1 — primary sparse index lookup, `WHERE student_id = 8420`.
| Step | Action | Cost |
|---|---|---|
| 1 | Sparse primary index has one entry per block: 100 entries, fits in 1–2 index blocks | binary search: comparisons |
| 2 | Index entry for block containing IDs 8401–8500 points to block 85 | 1 index block read |
| 3 | Read block 85, scan up to 100 records for `student_id = 8420` | 1 data block read |
| Total | 2 block I/Os vs. 100 for a full scan |
Case 2 — secondary dense index lookup, `WHERE major = 'Computer Science'` (non-ordering column, 1,200 matching rows scattered across the file). Since the file is sorted by `student_id`, not `major`, a secondary index on `major` must be dense: it holds one entry per matching row (or a value plus a list of record pointers), because the file gives no clue where CS majors physically live.
| Step | Action | Cost |
|---|---|---|
| 1 | B+-tree secondary index on `major`, height (fanout ~50, 10,000 index entries) | 3 index block reads to leaf |
| 2 | Leaf holds pointer list for value `'Computer Science'`: 1,200 record pointers | negligible extra I/O within leaf(s) |
| 3 | Each of 1,200 pointers may reference a different data block (no clustering) | up to 1,200 random data block reads |
| Total | ~1,203 I/Os — index found the value fast, but retrieving scattered rows dominates cost |
This second case is the classic lesson: a secondary index accelerates finding qualifying keys but does not guarantee cheap retrieval of the rows themselves, because the underlying data is not physically clustered by that attribute. This is exactly the tension that a query optimizer must weigh — covered formally in Cost Estimation for Query Plans — when deciding whether using the index beats a full scan.
Case 3 — hash index, `WHERE student_id = 8420` on a hash index instead.
| Step | Action | Cost |
|---|---|---|
| 1 | Compute | O(1) computation |
| 2 | Jump directly to bucket, scan short chain (load factor ≈ 1) | 1 bucket block read (expected) |
| Total | ~1 I/O, better than the B+-tree's 2, but this index is useless for `WHERE student_id BETWEEN 8000 AND 9000` |
Common pitfalls & traps
| Pitfall | Why it happens | Consequence |
|---|---|---|
| Indexing every column "just in case" | Assuming indexes are free | Massive write-amplification; every insert/update touches N index structures |
| Using a hash index for range queries | Forgetting hashing destroys order | Optimizer falls back to full scan; query silently slow |
| Assuming secondary index lookup is always cheap | Ignoring lack of clustering | Many scattered random I/Os per matching row (see Case 2 above) |
| Confusing dense vs. sparse applicability | Sparse only works when file is sorted by that key | Building a sparse index on a non-ordering column gives wrong/missing results |
| Not rebuilding/reorganizing after heavy deletes | B+-tree leaves become sparse but rarely merge back aggressively | Wasted space, degraded fanout, slower scans over time |
| Forgetting composite index column order | `(a, b)` index doesn't help `WHERE b = ...` alone | Query planner cannot use the index, silent regression |
Pioneers & history
The B-tree, ancestor of the B+-tree used in essentially every production database and file system today, emerged from a need to organize large sequential files on slow disk hardware efficiently.
Origins · 1 minRudolf Bayer and how this was discovered
Bayer, working with Edward McCreight at Boeing Research Labs, designed the B-tree in 1970 to organize large indexes on disk-based storage, explicitly balancing tree height against block-transfer cost — the foundational insight that shaped every disk-oriented index structure since.
Where this matters
Every major relational database — PostgreSQL, MySQL/InnoDB, Oracle, SQL Server — uses B+-trees as the default index structure for `CREATE INDEX`, and most also expose hash indexes for pure equality workloads (PostgreSQL's `USING HASH`, and hash-based partitioning in distributed systems). Primary key constraints typically create a clustering B+-tree index automatically, physically organizing the table itself. Key-value stores and in-memory caches (Redis, memcached-style systems) rely on hash tables for their core lookup guarantee, while search engines and time-series databases lean on B+-tree-like structures (or LSM-trees, a write-optimized cousin) to support range and ordered scans efficiently. Beyond single-table lookups, indexes are the backbone of efficient joins: an indexed nested-loop join, previewed in Join Algorithms: Nested-Loop, uses an index on the inner relation's join key to avoid a full inner scan per outer row, turning an join into something closer to . The query optimizer's decision of which index to use for which operator — scan, join, sort avoidance via index order — is the central subject of the Query Processing Overview and is quantified precisely by the cost models in Cost Estimation for Query Plans, which weigh index selectivity, I/O cost, and clustering against the alternative of a full scan.
Ask the tutor: How does the query optimizer decide between using an index and doing a full table scan, in terms of concrete cost formulas?
What is the difference between a primary index and a clustering index?
In many textbooks these terms overlap: a primary index is built on the primary key and typically requires the file to be physically sorted by that key, making it inherently a clustering index (the physical row order matches the index order). Some systems distinguish them slightly — a clustering index can technically be built on a non-key ordering attribute if the file is sorted by it, even without uniqueness. In practice, most relational databases equate "primary index" with "the index that determines physical row placement," and it's usually sparse since one entry per block suffices when the file is already sorted.
Can a table have more than one primary/clustering index?
No — a table's rows can only be physically stored in one order at a time, so there can be at most one clustering index per table (often built automatically on the primary key). All other indexes are secondary and must be dense, since they cannot rely on matching the physical storage order. Some databases implement secondary indexes on top of a clustered structure by storing the clustering key as the "pointer" instead of a raw disk address, which adds an extra indirection but keeps the design consistent when the table is reorganized.
Why do B+-trees use high fanout instead of a simple balanced binary tree?
Disk I/O cost is dominated by the number of block transfers, not CPU comparisons within a block. A binary tree with keys has height , requiring one disk access per level — for a million keys that's about 20 accesses. A B+-tree with fanout 100 needs only levels, since each node holds far more keys per block. Fewer levels means fewer disk seeks, and disk seeks are orders of magnitude slower than in-memory comparisons, so maximizing fanout per block directly minimizes the dominant cost.
When would a query optimizer choose NOT to use an available index?
If the index's selectivity is poor — say a `WHERE status = 'active'` predicate matches 90% of rows — using the index means following that many scattered pointers to fetch matching rows, often costing more I/O than simply scanning the table sequentially block-by-block. The optimizer estimates this tradeoff using statistics like histograms and distinct-value counts, as covered in cost estimation for query plans, and chooses whichever access path minimizes estimated total I/O and CPU cost, not just whichever index technically applies.
How does a composite (multi-column) index interact with query predicates?
A composite index on `(a, b)` is physically sorted first by `a`, then by `b` within each `a` value — like a phone book sorted by last name then first name. It efficiently supports predicates on `a` alone, or on `a` and `b` together, because the sort order makes both usable as a prefix. It generally cannot efficiently support a predicate on `b` alone, since values of `b` are scattered throughout the index unless `a` is also constrained, analogous to being unable to search a phone book efficiently by first name alone.
Why do hash indexes need dynamic resizing schemes like extendible or linear hashing?
A static hash table fixes the number of buckets at creation. As rows are inserted, the load factor grows, and each bucket's overflow chain lengthens, degrading expected lookup time from toward . Extendible and linear hashing solve this by splitting buckets incrementally as the table grows, redistributing keys using additional hash bits, so the load factor stays near a target constant and expected lookup time remains close to without a disruptive full rehash of the entire table.
Does adding an index ever change the result of a query, not just its speed?
No — indexes are purely an access-path optimization; a correctly implemented index never changes which rows satisfy a query's `WHERE` clause or its final result set. If a query with and without an index returns different rows, that indicates a correctness bug (e.g., an index built incorrectly, or not properly maintained after concurrent updates), not an expected tradeoff. This separation of logical correctness from physical access strategy is a core design principle of the relational model and its query processing layer.