DBMS 📂 Transactions and Concurrency: · 6 of 6 44 min read

Database Indexing, Hashing & B+-Trees — A Visual DBMS Tutorial

A comprehensive DBMS tutorial on database indexing structures. It explains the anatomy of an index, dense vs sparse indexes, and the three index families — primary, clustered/clustering, and secondary — including how many of each a table can have and why. It then covers hashing (hash functions, buckets, collisions via chaining, static vs dynamic hashing) and B-tree/B+-tree fundamentals (balanced multi-level structure, internal vs linked leaf nodes, exact and range search).

Section 01

The Story That Explains Indexes

The Index at the Back of a Textbook
To find every mention of "deadlock" in an 800-page book, you would never read all 800 pages. You flip to the index at the back, find the word, and it tells you: pages 312, 488, 601. You jump straight there.

A database index is exactly this: a small, sorted side-structure of (search key → pointer) entries that lets the engine jump to the rows it needs instead of scanning the whole table. This tutorial builds the idea up from first principles — why indexes are fast, how they are organised, and the two structures that power them: hashing and the B-tree / B+-tree.
🧮
The One Idea to Hold Onto

An index trades a little extra storage and write cost for dramatically faster reads. The whole game is reducing how many disk blocks the engine must read to answer a query. Hold that sentence in mind — the next section makes it precise.


Section 02

Anatomy of an Index Entry

Every index entry has two parts: a search key (the value you look up) and a pointer (the block or row address where the matching record lives).

🔗 Search Key → Pointer → Record
42 search key pointer 42 · Asha · CSE data record the index is sorted by search key, so it can be searched quickly

Because the index is far smaller than the table and kept in sorted order, the engine finds the pointer fast, then makes one jump to the record.


Section 03

Why Indexes Are Fast — The Disk-Block Cost Model

To understand why indexes help, you must understand what is actually expensive. A database does not read one row at a time; it reads fixed-size blocks (also called pages, typically 4–16 KB) from disk, each holding many rows. Reading a block from disk is thousands of times slower than any work the CPU does in memory. So the cost of a query is measured in block accesses (disk I/Os) — not in CPU cycles.

⏱️
The Only Cost That Matters

A single random disk read can take roughly 10,000–100,000× longer than reading the same data from RAM. That is why we count how many blocks a query touches and treat everything else as free. Indexes win by slashing that count.

🔢 A Worked Example — One Million Records
Setup
1,000,000 records, 100 records per block → 10,000 data blocks.
No index
A full table scan must read every block → 10,000 block reads in the worst case.
Sorted index
Binary-search a small sorted index, then read one data block → only about 8 block reads.
Multilevel / B+-tree
A tree-shaped index reaches any record in 3–4 block reads — thousands of times fewer I/Os.
🏃 Full Table Scan vs Index Lookup — counting block reads
Without index — scan every block 50 checks block after block — up to 10,000 reads (O(n)) With index — jump straight there 50 index points to the block directly — a handful of reads (O(log n))

Same data, same query. The index converts a linear sweep into a near-instant jump — the entire reason indexes exist.


Section 04

Dense vs Sparse Indexes

This single distinction underlies every index type that follows, and it is a direct consequence of the cost model above.

📚 Dense index
Property
One entry for every record
Larger, but finds any key directly
Required when the file is not sorted by the key
Used by secondary indexes
🧰 Sparse index
Property
One entry per data block
Smaller, less storage, fewer index blocks to search
Needs the file sorted by the key
Used by primary & clustering indexes
💡
Why Sparse Works Only on Sorted Files

A sparse index stores just the first key of each block. To find key 50, you locate the largest index key that is ≤ 50, jump to that block, and scan within it. This only works because the records are physically in sorted order — otherwise 50 could be in any block, forcing a dense entry for every single record.


Section 05

Multilevel Indexes — An Index on the Index

Here is the key theoretical leap. Even an index can grow large. A dense index on a million records has a million entries — far too big to search in a couple of block reads. So we apply the same trick to the index itself: treat the (sorted) index as a file and build a smaller sparse index over it. That outer level is smaller still, so we can index it too — repeating until the topmost level fits in a single block.

🏯 A Two-Level Index Funnelling Down to the Data
outer index 1 block inner (sparse) index — a few blocks data blocks — many

Each level is the blocking factor smaller than the one below it, so the number of levels grows only logarithmically with the data.

🧮
This Is Already (Almost) a B-tree

A multilevel index reaches any record in about logfan-out(N) block reads. The catch: a plain multilevel index becomes lopsided and expensive to maintain as rows are inserted and deleted. The B+-tree is precisely a multilevel index that keeps itself balanced automatically — which is why Section 12 builds on this.


Section 06

Primary Index — Sparse, on the Ordering Key

A primary index is built on the ordering key field of a file that is physically sorted by that field (typically the primary key). It is sparse: one entry per block, pointing to the block's first record. The animation shows a lookup for key 50.

🔍 Sparse Primary-Index Lookup — find 50
Sparse index Sorted data blocks 10 → 40 → 70 → 10 | 20 | 30 40 | 50 | 60 70 | 80 | 90 find 50: largest index key ≤ 50 is 40 → go to block B2 scan block B2 → found 50 ✓

The index has only 3 entries for 9 records: locate the right block via the index, then scan inside that one block.

-- In most engines the PRIMARY KEY automatically creates the primary/clustered index
CREATE TABLE students (
  roll_no INT PRIMARY KEY,   -- file ordered by roll_no; sparse index built
  name    VARCHAR(50),
  branch  VARCHAR(20)
);

Section 07

Clustered Index — Data Physically Sorted by the Key

A clustered index determines the physical order of the rows on disk: the table's data is the index's leaf level. Because rows can be laid out in only one order, a table can have at most one clustered index. A primary index is one kind of clustered index; a clustering index is the variant built on an ordering non-key field, where many rows share the same value (e.g. all students of one branch stored together).

📦
Clustered
one per table
Rows stored in key order. Range scans are very fast because neighbouring keys sit together on disk — one seek, then sequential reads.
🔗
Non-clustered
many per table
A separate sorted structure whose leaves hold pointers back to the actual rows, which stay where they are. Fetching a row needs an extra hop.
The trade-off
Clustered: fastest reads & ranges, but inserts may reshuffle rows to keep order. Non-clustered: cheaper writes, but a level of indirection on every read.
📊
Clustering Factor — Why Order Helps Ranges

When the rows you want are physically adjacent (high clustering), a range query like roll_no BETWEEN 100 AND 200 reads a handful of consecutive blocks. With a non-clustered index the same matching rows may be scattered across hundreds of blocks — one random read each — which is why the optimiser sometimes prefers a full scan instead.


Section 08

Secondary Index — Dense, on a Non-ordering Field

A secondary index is built on a field the file is not sorted by — say, looking up students by name when the file is ordered by roll_no. Since the target values are scattered across the file, a secondary index must be dense: one entry per record (there is no "first key of the block" to anchor a sparse entry). You can create many secondary indexes on one table, each supporting a different access path into the same data.

-- Secondary (non-clustered) index to speed lookups by name
CREATE INDEX idx_students_name ON students(name);

-- A unique secondary index also enforces uniqueness
CREATE UNIQUE INDEX idx_students_email ON students(email);
⚠️
Indexes Are Not Free

Every secondary index must be updated on each INSERT, UPDATE, and DELETE, and it consumes disk space. Index the columns you actually filter, join, or sort on — not every column "just in case." More on choosing indexes in Section 15.


Section 09

Primary vs Clustering vs Secondary — At a Glance

AspectPrimary indexClustering indexSecondary index
Built onordering key fieldordering non-key fieldnon-ordering field
File sorted on this field?YesYesNo
Dense or sparseSparseSparse (one per value)Dense
How many per tableOneOneMany
Index sizeSmallestSmallLargest

Section 10

Hashing — Computing the Location Directly

Indexes search for a location; hashing computes it. A hash function h(key) maps the key to a bucket number, and the record is stored in that bucket. A lookup applies the same function and goes straight to the bucket — no tree to descend, no list to scan. On average this is O(1): constant time, independent of table size.

🧮 What Makes a Good Hash Function
Deterministic
The same key must always map to the same bucket — otherwise you could never find the record again.
Uniform
It should spread keys evenly across all buckets, so no bucket becomes a hotspot. Uneven spread means long overflow chains and slow lookups.
Fast
Cheap to compute, since it runs on every insert and every lookup.
Uses the whole key
Depends on all parts of the key, so similar keys don't all collide into the same bucket.
⚡ Hashing a Key into a Bucket — h(k) = k mod 5
B010 · 5 B116 · 21 B212 · 7 B38 · 23 B414 · 9 overflow: 27 h(k) = k mod 5 hash function 27 mod 5 = 2 27 insert key 27 B2 is full → 27 chains to an overflow bucket (collision)

Key 27 hashes to bucket B2. Because B2 is already full, the collision is resolved by chaining to an overflow bucket.


Section 11

Collisions, Load Factor & Dynamic Hashing

Two different keys can hash to the same bucket — a collision. Collisions are unavoidable, so every hashing scheme needs a strategy to handle them, and a way to stay fast as the data grows.

🔗 Chaining (open hashing)
How it works
Each bucket links to overflow buckets
Collisions extend a chain
Simple; buckets never "fill up"
Long chains slow lookups
🔄 Open addressing (closed hashing)
How it works
No overflow buckets; probe nearby slots
Linear / quadratic / double hashing
Cache-friendly, compact
Degrades sharply when nearly full
🔢
Load Factor — The Health Metric

The load factor = (number of stored records) ÷ (total bucket slots). The closer it gets to 1, the more collisions occur and the slower lookups become. Good hash tables keep the load factor moderate — and grow when it climbs too high.

📋
Static hashing
fixed buckets
A constant number of buckets fixed up front. Simple, but as the file grows the load factor rises, overflow chains lengthen, and O(1) drifts toward O(n).
📐
Extendible hashing
directory + split
A directory indexed by the top d bits of the hash points to buckets. When a bucket overflows it splits, and the directory doubles only when needed.
➡️
Linear hashing
gradual growth
Buckets are split one at a time in a fixed round-robin order as the table grows — no directory needed, smooth expansion.
Why Dynamic Hashing Exists

Both extendible and linear hashing are forms of dynamic hashing: the number of buckets adapts to the amount of data, so performance stays near O(1) even as the table grows or shrinks — fixing the central weakness of static hashing.


Section 12

B-tree / B+-tree Basics

The B+-tree is the workhorse index of nearly every relational database. It is the self-balancing realisation of the multilevel index from Section 5: a shallow, wide, always-balanced tree that reaches any key in just a few disk reads and supports both exact lookups and range scans.

🧮 Structure of a B+-tree
Root & internal
Hold only keys + child pointers that route the search downward — they store no data.
Leaf nodes
Hold the actual keys + record pointers, and are linked left-to-right so a range scan just walks the leaves.
Order / fan-out
A node of order n holds up to n−1 keys and n child pointers. One node = one disk block packed with keys, so fan-out is large (often hundreds).
Balanced & half-full
Every leaf sits at the same depth, and every node stays at least half-full — guaranteeing the tree never degenerates.
🔢
Why So Shallow? The Height Formula

Height ≈ logfan-out(N). With a fan-out of 100 and one million keys, the height is just log100(1,000,000) = 3. So any record is reachable in about 3–4 block reads, versus 10,000 for a full scan. High fan-out is the whole trick — it keeps the tree flat.

🌳 B+-tree Search — descend from root to leaf to find 40
30 | 60 10 · 20 30 · 40 · 50 60 · 70 40? at root: 30 ≤ 40 < 60 → take the middle child leaf node → found 40 ✓ (then follow links for ranges)

Each level narrows the search. For a range query like 40–70, find 40 in the leaf, then walk the dashed leaf links rightward.

-- B-tree is the DEFAULT index type in almost every relational database
CREATE INDEX idx_emp_salary ON employees(salary);          -- B-tree implied

-- Some engines let you request a hash index explicitly (great for = only)
CREATE INDEX idx_emp_email ON employees(email) USING HASH;

Section 13

Staying Balanced — Insertion, Splitting & B-tree vs B+-tree

What makes a B+-tree better than a hand-built multilevel index is that it repairs its own balance on every insert and delete, so all leaves always stay at the same depth.

➕ Insertion & Node Splitting
Step 1
Descend to the correct leaf and insert the key in sorted position.
Step 2
If the leaf still has room (≤ n−1 keys), you are done.
Step 3
If it overflows, split it into two half-full leaves and copy the separating key up to the parent.
Step 4
If the parent overflows too, it splits and pushes a key up — this can cascade. Splitting the root creates a new root and is the only way the tree grows taller.
Deletion Is the Mirror Image

Remove the key from its leaf. If the leaf falls below half-full, it borrows a key from a sibling, or merges with one — possibly propagating up and, when the root loses its last separator, shrinking the tree's height. Borrowing and merging are what keep every node at least half-full.

AspectB-treeB+-tree
Where data pointers livein all nodesonly in leaf nodes
Internal nodes holdkeys + datakeys only (routing)
Leaves linked together?NoYes — sequential chain
Range / ordered scansAwkward (tree traversal)Excellent (walk the leaves)
Fan-out (keys per node)Lower (data takes space)Higher → shallower tree
Used by most databases?RarelyYes — the default

Section 14

Hashing vs B-tree — When to Use Which

Query needHash indexB+-tree index
Exact match (=)Excellent — O(1) avgGood — O(log n)
Range (>, BETWEEN)Useless — keys scatteredExcellent — ordered leaves
Sorting / ORDER BYNo helpAlready sorted
Prefix / LIKE 'abc%'NoYes
Worst-case lookupBad with many collisionsAlways O(log n)
Rule of Thumb

Reach for hashing when every query is an exact-match equality lookup. Reach for a B+-tree when you also need ranges, sorting, or prefix searches — which is why it is the default almost everywhere.


Section 15

Choosing & Using Indexes Well

Knowing the structures is half the battle; the other half is deciding which indexes to create. These ideas come straight out of the cost model.

🎯
Selectivity
An index pays off on columns with many distinct values, where each value matches few rows (e.g. email). On low-selectivity columns (e.g. a yes/no flag) the optimiser often just scans — an index that returns half the table saves nothing.
📦
Covering index
If an index contains every column a query needs, the engine answers from the index alone — an index-only scan — never touching the table at all.
🔗
Composite order
A multi-column index on (a, b, c) is sorted by a, then b, then c. Column order is a design decision — put the most-filtered column first.
🧮
The Left-Prefix Rule

A composite index on (a, b, c) can serve queries filtering on a, on a AND b, or on a AND b AND c — but not on b alone or c alone. The leftmost column(s) must be used, because the index is sorted left-to-right.

Don't index tiny tables
A few blocks are faster to scan outright than to bounce through an index.
Don't over-index writes
Each index adds work to every INSERT/UPDATE/DELETE. Write-heavy tables should carry only the indexes they truly need.
⚠️
Low selectivity
Indexing a column with only a few distinct values rarely helps and just wastes space.
Index for the query
Index the columns that appear in WHERE, JOIN, and ORDER BY — the access paths your real queries use.

Section 16

Practice Problems

🧠 Work These Out
Q1
A file of 10,000 records sorted by key, 100 records per block. How many entries does a sparse primary index have? → one per block = 10,000 / 100 = 100.
Q2
For the same file, how many entries would a dense secondary index need? → one per record = 10,000.
Q3
A B+-tree has fan-out 100. Roughly how many levels are needed for 1,000,000 keys? → log100(106) = 3.
Q4
Using h(k) = k mod 7, which bucket holds key 100? → 100 mod 7 = 2.
Q5
Which index type best serves WHERE salary BETWEEN 50000 AND 70000? → a B+-tree — hashing can't do ranges.
Q6
You have an index on (dept_id, salary). Will it speed up WHERE salary = 60000 alone? → No — the left-prefix (dept_id) is missing.
Q7
How many clustered indexes can one table have, and why? → one — rows can be physically ordered only one way.

Section 17

Golden Rules

🔍 Indexes, Hashing & B-trees — The Essentials
1
Query cost is counted in disk-block reads. An index wins by cutting that count from O(n) (full scan) to O(log n) or O(1).
2
An index is a sorted set of (search key → pointer) entries. Sparse = one per block (sorted file required); dense = one per record (needed when unsorted).
3
Primary and clustering indexes need the file physically ordered, so a table has at most one; secondary indexes are dense and you may have many.
4
A multilevel index (an index over an index) reaches data in logfan-out(N) reads — the idea the B+-tree automates and keeps balanced.
5
Hashing gives O(1) average exact-match lookups but no ranges or sorting. Handle collisions by chaining or open addressing, and use dynamic hashing so performance survives growth.
6
The B+-tree is balanced and shallow (high fan-out), stores data only in linked leaves, and self-repairs via splits and merges — the default index everywhere.
7
Choose hash for equality-only workloads and B+-tree when you need ranges, sorting, or prefixes.
8
Index for selectivity and your real query patterns; respect the left-prefix rule on composite indexes; and remember indexes cost storage and slow writes.
You have completed Transactions and Concurrency:. View all sections →