Introduction to Indexing
Imagine a table of ten million customer records stored as a heap file. A query asks for the one customer whose ID equals 483,201. Without help, the database must scan every page. It checks every record until it finds a match or exhausts the file. This is the same problem a phone book solves. Nobody flips page by page looking for "Ramírez." Instead, they use the alphabetical structure of the book itself as a shortcut. An index is that shortcut, formalized as a data structure. Indexing matters because selective access is the dominant workload in real systems. Selective access means fetching a small fraction of rows that satisfy a condition. Every relational database lives or dies by how quickly it answers such queries. This applies from a single-node embedded engine to a distributed warehouse. Example queries include "find the rows where `id = 483201`" or "find all rows where `age > 30`." Without indexes, that query costs a full table scan. With a well-chosen index, it can cost a handful of page reads. The difference between a millisecond query and a twenty-second query usually comes down to one thing. That thing is the presence or absence of an appropriate index. The big picture: an index is an auxiliary structure built over one or more fields of a file. These fields are called the search key. The index maps search-key values to the locations of matching records. It trades extra storage and maintenance cost for dramatically faster lookups. This maintenance cost includes index upkeep on every insert, delete, and update. This trade is almost always worth it for read-heavy workloads. It is the central design decision behind every storage engine's performance profile. This article lays the conceptual groundwork every later indexing topic builds on. It defines search keys and data entries precisely. It distinguishes primary from secondary indexes. It walks through the mechanics of an index-assisted lookup with pseudocode and a diagram. It also analyzes exactly why indexing changes the complexity of selective queries. It deliberately stops short of B+-trees and hash indexes. Those are separate structures built on the vocabulary introduced here.
- An index maps search-key values to the locations of matching records, avoiding full-file scans.
- A search key is any field or set of fields used to look up records; it need not be a candidate key.
- A data entry is what the index actually stores per key — a full record, a record ID, or a list of record IDs.
- A primary index is built on the file's ordering field (usually the primary key); a secondary index is built on any other field.
- Indexing turns an O(n) linear scan into an O(log n) or better lookup, at the cost of extra storage and update overhead.
- These concepts set up Dense vs Sparse Indexes, Clustered vs Unclustered Indexes, B+-Tree Indexes, and Hash Indexes.
An index is a separate, smaller structure that maps search-key values to record locations, so a query pays for searching a shortcut instead of scanning the whole file.
The idea
An index is an auxiliary data structure, stored alongside a data file, that supports efficient lookup of records by the value of one or more fields. The field or combination of fields used for lookup is called the search key. Crucially, a search key is not necessarily a candidate key of the relation — you can index on `age` even though many customers share the same age, and you can index on `customer_id` because it happens to be unique. The index does not care about uniqueness; it only cares about being queried on that value. What the index physically stores for each distinct search-key value is called a data entry. There are three classical choices for what a data entry can be, all of which appear across real systems: 1. The actual record itself, keyed and physically stored in search-key order (this is really the data file organized as an index — no separate structure). 2. A pair `(search-key value, record ID)`, where the record ID (RID) is a pointer to the record's location in a separate data file (e.g., page number + slot number). 3. A pair `(search-key value, list of record IDs)`, used when many records share the same key value, avoiding repeating the key for every match. This distinction feeds directly into dense vs. sparse indexes, a topic covered separately, but the essential idea to internalize now is: an index entry is not the record — it is a lightweight proxy that tells you where the record lives. The second foundational distinction is between primary and secondary indexes, based on the relationship between the index's search key and the physical ordering of the underlying data file:
- A primary index (sometimes called a clustering index in some textbooks, though that term is used more precisely later) is built on a field that determines, or matches, the physical sort order of the data file. If the heap file's records are sorted by `customer_id`, an index on `customer_id` is primary. There is at most one primary index per file, because a file can only be physically sorted one way at a time.
- A secondary index is built on any field that does not match the file's physical ordering — for instance, an index on `last_name` when the file is physically sorted by `customer_id`. A file can have arbitrarily many secondary indexes, one for every field or field combination queries commonly filter on.
This primary/secondary distinction is the seed of the later, more refined distinction between clustered and unclustered indexes, and understanding it now — in terms of physical file order — makes that later material almost automatic.
How it works
The mechanism is simplest to understand by contrasting two paths for the same query: "find all records with search key = k."
Without an index (scanning a heap file): the system must read every page of the file and check every record's key, because records are stored in no particular order relevant to the query.
With an index: the system first searches the (much smaller) index structure for k, obtains the data entries — record IDs — for matching records, and then does a small number of targeted reads into the data file to fetch the actual records. The index search itself is fast because indexes are built as compact, ordered, or hashed structures (arrays, trees, or hash tables) specifically optimized for lookup, not for holding full records. The general algorithm for an index-assisted equality lookup: ``` function INDEX_LOOKUP(index, key):
# Step 1: locate the position of `key` inside the index structure.
# For a sorted array index this is a binary search;
# for a tree index this is a root-to-leaf traversal;
# for a hash index this is a hash-bucket computation. position = index.locate(key) if position is NOT_FOUND: return [] # no matching records entries = index.dataEntriesAt(position) results = [] for entry in entries: if entry.isFullRecord(): results.append(entry) # Alternative 1 index else: record = dataFile.fetch(entry.rid) # Alternative 2 index results.append(record)
# entry may itself be a list of RIDs (Alternative 3);
# in that case, fetch every RID in the list return results
function RANGE_LOOKUP(index, lowKey, highKey):
# Only efficient on ORDERED indexes (sorted arrays, B+-trees), not hash indexes position = index.locateFirstGreaterOrEqual(lowKey) results = [] while position is valid and index.keyAt(position) <= highKey: for entry in index.dataEntriesAt(position): record = resolve(entry) # same resolution as above results.append(record) position = index.next(position) return results ``` Two implementation details matter for correctness:
- `index.locate` must be a search procedure whose cost is much smaller than scanning the whole data file — otherwise the index provides no benefit. This is why indexes are themselves built as sorted arrays (binary search), balanced trees, or hash tables, never as unsorted lists.
- Resolving a data entry to a record (the `dataFile.fetch(entry.rid)` step) costs one additional I/O per matching record when the index stores RIDs rather than full records. This extra hop is the price paid for keeping the index small; it is central to the cost analysis below and to why clustering (covered in a later article) matters so much.
The following diagram shows the architecture: a sorted index file of `(key, RID)` data entries sitting above an unordered (heap) data file. A lookup for key `483201` binary-searches the index, finds the RID, and follows the pointer to the exact page and slot in the data file.
Complexity & correctness
Cost without an index. A linear scan of a heap file with `n` pages costs `O(n)` I/Os in the worst case (the matching record is on the last page, or there is no match at all), because every page must be examined. This holds regardless of how many records match — even a query that returns zero rows still costs a full scan, since the absence of a match can only be confirmed by checking everything.
Cost with a sorted-array index using binary search. If the index itself occupies `m` pages (typically `m ≪ n`, since the index stores only keys and RIDs, not full records), binary search over the index costs ``` O(log2 m) ``` I/Os to locate the first matching data entry, since each comparison halves the candidate region — this is the standard binary-search argument: after `i` probes, the search space size is `m / 2^i`, and the loop terminates when this reaches 1, giving `i = log2 m`. Then, for each of the `k` matching records, one additional I/O is needed to fetch the record from the data file (in the worst case, when data entries store RIDs rather than full records and matches are scattered across different pages). Total: ``` O(log2 m + k) ``` Since `m ≤ n` (the index is never larger than the data, and is usually much smaller because entries are smaller than full records), this is an exponential improvement over `O(n)` whenever `k` is small — exactly the case of a selective query, which is the query pattern indexes are built for.
Why this is correct, not just fast. Correctness of `INDEX_LOOKUP` rests on one invariant: the index's mapping from search-key values to data entries must always reflect the current contents of the data file. Formally, at every point in time, for every record `r` in the data file with search-key value `v`, the index must contain a data entry for `v` that resolves (directly or via RID) to `r`, and conversely, every data entry in the index must correspond to a record that actually exists. This invariant must be maintained transactionally: every `INSERT`, `DELETE`, or `UPDATE` on the data file must be paired with a corresponding update to every index defined over the modified fields. If this invariant is violated — say, a record is deleted from the data file but its stale RID is left in the index — a subsequent lookup silently returns garbage or a dangling pointer. Maintaining this invariant is precisely the index maintenance cost traded for lookup speed.
Honest tradeoffs.
- Storage overhead. Each index consumes additional disk space proportional to the number of records times the size of a `(key, RID)` pair (or key list). Multiple secondary indexes multiply this cost.
- Write amplification. Every insert/update/delete must update every affected index, not just the data file. A table with five secondary indexes turns one logical write into up to six physical writes (one to the heap, five to the indexes).
- Range queries need order. The `O(log m + k)` bound for range lookups (`RANGE_LOOKUP` above) only holds for indexes built on an ordered structure (sorted array, or later, a B+-tree). A hash-based index — foreshadowed later — gives excellent `O(1)` expected-time equality lookup but cannot efficiently answer range queries at all, because hashing destroys order.
- Selectivity matters. If a query matches a large fraction of the file (`k` close to `n`), the `O(log m + k)` index path can actually be slower than a raw sequential scan, because random-access I/O to fetch scattered records is more expensive per-record than sequential I/O. Query optimizers use estimated selectivity to decide whether to use an index at all — this is why "add an index" is not a universal performance fix.
Worked example
Consider a `Students` table stored as a heap file across 4 data pages (as drawn above), and a secondary index built on `student_id`, stored as a sorted array of 5 `(key, RID)` entries across 1 index page (in reality index files span many pages; we keep this small for tracing).
Data file (heap, unordered by student_id):
| Page | Slot | student_id | name |
|---|---|---|---|
| 2 | 0 | 601122 | Diaz |
| 2 | 4 | 102344 | Osei |
| 2 | 2 | 217005 | Kwan |
| 5 | 0 | 771309 | Silva |
| 5 | 1 | 483201 | Wren |
| 7 | 0 | 217005 | Patel |
| 7 | 2 | 601122 | Ito |
| 9 | 0 | 483201 | Alam |
| 9 | 1 | 102344 | Reyes |
| 9 | 2 | 483201 | Torres |
Index (sorted array, one entry per record with that key — dense, Alternative 3 style, list of RIDs):
| search key | data entry (list of RIDs) |
|---|---|
| 102344 | [(p2,s4), (p9,s1)] |
| 217005 | [(p2,s2), (p7,s0)] |
| 483201 | [(p5,s1), (p9,s0), (p9,s2)] |
| 601122 | [(p2,s0), (p7,s2)] |
| 771309 | [(p5,s0)] |
Query: `SELECT * FROM Students WHERE student_id = 483201;`
Trace of `INDEX_LOOKUP(index, 483201)`: 1. Binary search over 5 sorted keys `[102344, 217005, 483201, 601122, 771309]`.
- Probe 1: middle index = 2, key = `483201`. Match found immediately.
- Comparisons used: 1 (best case here, but worst case for 5 entries is `⌈log2 5⌉ = 3`).
2. `entries = [(p5,s1), (p9,s0), (p9,s2)]` — three RIDs. 3. Resolve each RID against the data file:
- `(p5,s1)` → fetch Page 5 → slot 1 → Wren
- `(p9,s0)` → fetch Page 9 → slot 0 → Alam
- `(p9,s2)` → fetch Page 9 → slot 2 → Torres
- (Page 9 is fetched once and reused for both slots in a real buffer-managed system — one I/O, not two — illustrating that even the "+k" term can be optimized when matches share a page.)
4. Return `{Wren, Alam, Torres}`.
Cost comparison for this query:
| Approach | I/O cost |
|---|---|
| Full heap scan | 4 page reads (pages 2, 5, 7, 9), every slot checked |
| Index-assisted lookup | 1 index page read + binary search (≤3 comparisons, no extra I/O since index fits in 1 page) + 2 data page reads (page 5, page 9 — deduplicated) |
Here `n = 4` data pages and the index costs 1 page read plus 2 targeted data page reads — already a meaningful reduction, and the gap widens dramatically as the table grows to millions of pages while the index (holding only keys and RIDs) grows far more slowly, and its internal search stays logarithmic.
Contrast — why `student_id` was the right choice but `name` might not be, if instead the query were `WHERE name = 'Patel'` with no index on `name`: no index exists, so the system falls back to a full heap scan of all 4 pages, checking all 10 slots individually — exactly the cost indexing was introduced to avoid. This motivates building a secondary index on `name` as well, at the cost of maintaining a second structure on every insert/update/delete.
Common pitfalls & traps
| Pitfall | Why it happens | Consequence |
|---|---|---|
| Assuming every index speeds up every query | Conflating "has an index" with "query is selective" | Optimizer (or naive reasoning) uses an index for a low-selectivity query, causing more random I/O than a sequential scan would |
| Confusing search key with candidate key | Textbook examples often index on primary keys, creating a habit | Missing the fact that non-unique fields (e.g., `age`, `city`) are equally valid, common search keys |
| Forgetting index maintenance cost | Focusing only on read speed | Underestimating write overhead in insert/update/delete-heavy workloads; every index roughly doubles or triples write cost |
| Believing a file can have many primary indexes | Misreading "primary" as "on the primary key" rather than "matches physical order" | Modeling errors: a file has at most one primary/clustering index, but unlimited secondary indexes |
| Treating data entries as always full records | Not distinguishing Alternative 1/2/3 storage | Underestimating the extra I/O hop needed to resolve a RID into an actual record |
| Ignoring stale indexes after bulk loads | Assuming indexes rebuild themselves automatically | Loading data directly into a heap file without updating indexes leaves them inconsistent, silently corrupting future lookups |
Pioneers & history
The concept of indexing predates computers entirely — library card catalogs and book indexes are the direct ancestors — but its formalization inside database systems is tied to the earliest work on file organization and access methods in the 1960s and 70s, as researchers sought principled alternatives to sequential scanning for the newly emerging large-scale data-processing systems.
Origins · 1 minEdgar F. Codd and how this was discovered
Where this matters
Every production relational database — PostgreSQL, MySQL, Oracle, SQL Server, SQLite — implements the primary/secondary index distinction directly: a `PRIMARY KEY` in InnoDB determines the clustering order of the table itself, while every `CREATE INDEX` statement on another column builds a secondary structure of exactly the `(key, RID)` shape described here. Query optimizers rely on estimated selectivity — the fraction of rows a predicate is expected to match — to decide, at query-planning time, whether to use an available index or fall back to a sequential scan, exactly mirroring the tradeoff analyzed above. Beyond relational databases, the same idea underlies search engines (an inverted index maps search terms to document IDs, the textual analogue of mapping search keys to RIDs), key-value stores (LSM-tree indexes in RocksDB and Cassandra), and file systems (directory structures are indexes mapping filenames to inode locations). Understanding the primitive — search key, data entry, primary vs. secondary — is what makes the subsequent, more specialized structures (B+-Tree Indexes, Hash Indexes) legible as refinements of a single idea rather than unrelated tricks. It also underlies the distinction between Dense vs Sparse Indexes (how many data entries exist per distinct key) and Clustered vs Unclustered Indexes (whether the index order matches physical storage order), both of which sharpen the vocabulary introduced here.
Ask the tutor: How does the choice between a dense and a sparse index change the space and lookup-time tradeoffs discussed here?
Is a search key the same thing as a primary key?
No. A primary key is a schema-level constraint guaranteeing uniqueness and non-nullity for a relation. A search key is simply whatever field or combination of fields an index is built on, chosen because queries filter on it. You can build an index — and therefore define a search key — on `age`, `city`, or `email`, none of which need be unique. Many indexes in practice are built on non-key fields precisely because those are the fields `WHERE` clauses commonly reference. The primary key is often also used as a search key (for the primary index), but the two concepts are independent: uniqueness is a schema property, search-key-hood is an access-pattern property.
Can a table have more than one primary index?
No, and this follows directly from the definition. A primary index's search key must match the physical ordering of the data file's records. A file's records physically reside in exactly one order at any given time — you cannot sort a heap file simultaneously by `customer_id` and by `last_name` in the same physical layout. So there is at most one primary index per file. Every other useful lookup field instead gets a secondary index, whose order is independent of physical storage order, and a file can have as many secondary indexes as its workload justifies (subject to storage and write-cost tradeoffs).
What exactly is a data entry, and why does the distinction between the three alternatives matter?
A data entry is what the index stores for a given search-key value: either the full record (Alternative 1), a `(key, RID)` pair pointing to the record (Alternative 2), or a `(key, list-of-RIDs)` pair when many records share a key (Alternative 3). The distinction matters because it trades index size against lookup speed: Alternative 1 needs no extra fetch but makes the index as large as the data itself; Alternatives 2 and 3 keep the index small and fast to search but require one more I/O to resolve each RID into an actual record. This tradeoff becomes central once you study dense vs. sparse indexes and clustering.
Does adding an index always make queries faster?
Not universally — it makes selective equality or range queries faster, but for queries that match a large fraction of the table, a full sequential scan can outperform an index. This happens because fetching many scattered records via RIDs means many random-access page reads, while a sequential scan reads pages in physical order, which is far cheaper per page on spinning disks and often still cheaper on SSDs due to prefetching and I/O batching. Real query optimizers estimate the selectivity of a predicate — the expected fraction of matching rows — before deciding whether to use an available index or fall back to a scan.
Why does every insert or delete need to touch the index too, not just the data file?
Because the index's entire value depends on an invariant: at all times, it must accurately reflect which records exist and where they live. If a record is deleted from the data file but its entry lingers in the index, a later lookup returns a dangling RID pointing to a slot that no longer holds that record — a silent correctness bug, not a crash. Symmetrically, a newly inserted record that isn't reflected in the index is simply invisible to any query that relies on that index. This is why every index defined on a modified field must be updated atomically alongside the base table write, and why more indexes mean more write overhead.
How does indexing relate to what I already know about heap files and sorted files?
A heap file stores records with no useful order, making every lookup a full scan; a sorted file enables binary search directly on the data but pays a heavy cost to keep records in order under inserts and deletes. An index resolves this tension: it keeps a separate, small, ordered (or hashed) structure of just keys and pointers, letting the bulky data file remain a heap (cheap to insert into) while still enabling binary-search-like or hash-like lookup speed through the index. This is precisely why the "auxiliary structure" framing matters — the index absorbs the ordering cost so the data file doesn't have to.
What comes after understanding basic indexing?
The next layer of precision splits into two independent design axes. First, density — whether the index has one entry per search-key value (sparse) or one entry per record (dense) — covered in Dense vs Sparse Indexes. Second, clustering — whether the index's order matches the data file's physical order, allowing the index to be built directly into the file rather than as a separate structure — covered in Clustered vs Unclustered Indexes. Together these set up the two dominant concrete implementations used in practice: B+-Tree Indexes for ordered, range-friendly access, and Hash Indexes for constant-time equality lookups.