Storage And Indexing
Hash and Specialised Indexes
The DSA Notes built hash maps for O(1) lookup. On disk they exist, and are used far less than you would expect.
JrCodex·8 min read
Jr Codex DBMS Notes
Level: Intermediate Prerequisites: Chapter 2: B+ Tree Indexes; DSA Notes, Module 8 Time to complete: ~20 minutes
Table of Contents
- Hash Indexes
- Composite Indexes
- Covering Indexes
- Partial and Expression Indexes
- Full-Text and Specialised Types
- Choosing an Index Type
- Summary & Next Steps
1. Hash Indexes
The DSA Notes built hash maps for O(1) lookup. On disk they exist, and are used far less than you would expect.
CREATE INDEX idx_students_email_hash ON students USING HASH (email); -- PostgreSQLHash vs B+ Tree
─────────────────────────────────────────
HASH B+ TREE
─────────────────────────────────────────
Equality (=) O(1) O(log n)
Range (<, BETWEEN) ✗ NO ✓ yes
ORDER BY ✗ NO ✓ free
MIN / MAX ✗ NO ✓ O(log n)
Prefix LIKE 'a%' ✗ NO ✓ yes
Size smaller larger
─────────────────────────────────────────
Why B+ Trees Win Anyway
─────────────────────────────────────────
A hash index is faster at exactly ONE thing, by a
small constant factor — O(1) versus a 3-level
descent that is usually cached anyway.
It cannot do ANY of the other five.
So a B+ tree serves every query pattern including
equality, at negligible extra cost. Adding a hash
index means maintaining a second index that
handles a strict subset of queries.
Use a hash index only when: the column is large
(long URLs), you ONLY ever do equality, and you
have measured that it matters.
─────────────────────────────────────────
2. Composite Indexes
An index on several columns, in a specified order. The order is everything.
CREATE INDEX idx_students_city_marks ON students (city, marks);The Sort Order It Creates
─────────────────────────────────────────
Entries are sorted by city FIRST, then by marks
within each city:
('Delhi', 74) ('Delhi', 82)
('Mumbai', 65)
('Pune', 88) ('Pune', 91)
It behaves exactly like sorting a spreadsheet by
column A, then column B.
─────────────────────────────────────────
THE LEFTMOST PREFIX RULE
─────────────────────────────────────────
An index on (a, b, c) can serve queries filtering
on:
✓ a
✓ a, b
✓ a, b, c
✗ b — cannot skip a
✗ c — cannot skip a and b
✗ b, c — cannot skip a
WHY: entries are sorted by `a` first. Without a
value for `a`, the b-values are scattered across
the whole index — there is nothing to seek to.
Same reason a phone book sorted by (surname,
forename) cannot find everyone called "James".
─────────────────────────────────────────
-- Which of these use idx_students_city_marks (city, marks)?
SELECT * FROM students WHERE city = 'Pune'; -- ✓ prefix
SELECT * FROM students WHERE city = 'Pune' AND marks > 80; -- ✓ full use
SELECT * FROM students WHERE marks > 80; -- ✗ skips `city`
SELECT * FROM students WHERE city = 'Pune' ORDER BY marks; -- ✓ AND no sort neededOrdering the Columns
─────────────────────────────────────────
1. EQUALITY columns first, RANGE columns last.
An index stops being useful for further
columns after the first range condition.
WHERE city = ? AND marks > ?
──► index (city, marks). Not (marks, city).
2. Most SELECTIVE first among equality columns —
it narrows the search fastest.
3. Match your ORDER BY, to get sorting free.
─────────────────────────────────────────
3. Covering Indexes
An index containing every column a query needs, so the table is never touched.
CREATE INDEX idx_cover ON students (city, marks, name);
SELECT name, marks FROM students WHERE city = 'Pune';
-- All three columns are IN the index ──► INDEX-ONLY SCAN. The heap is never read.Why This Is Such a Large Win
─────────────────────────────────────────
A normal index scan is TWO steps:
1. find matching entries in the index
2. for EACH, fetch the row from the heap
── a RANDOM page read per row
Step 2 usually dominates. 1,000 matching rows can
mean 1,000 random reads.
A covering index eliminates step 2 entirely. This
is frequently a 10x improvement, and the single
most effective index tuning technique.
─────────────────────────────────────────
-- INCLUDE: carry extra columns in the LEAVES only, not in the sort key.
CREATE INDEX idx_cover2 ON students (city, marks) INCLUDE (name);
-- Smaller internal nodes (higher fanout) while still covering the query.The Trade
─────────────────────────────────────────
Covering indexes are WIDER, so:
- the index is bigger, and evicts more from the
buffer pool (Chapter 5)
- every UPDATE to any included column must
update the index
Cover the columns your hot query needs. Do not
cover everything — at that point you have
duplicated the table.
─────────────────────────────────────────
4. Partial and Expression Indexes
-- PARTIAL: index only the rows you actually query.
CREATE INDEX idx_active_orders ON orders (created_at)
WHERE status = 'pending';Why Partial Indexes Are Underused
─────────────────────────────────────────
A table holds 10,000,000 orders; 5,000 are
pending.
Full index ──► 10,000,000 entries
Partial index ──► 5,000 entries
Two thousand times smaller, so it stays entirely
in memory, and every write to a non-pending row
does not touch it at all.
Any column with a skewed, frequently-filtered
value — status, is_deleted, is_active — is a
candidate.
─────────────────────────────────────────
-- EXPRESSION: index the result of a function.
CREATE INDEX idx_lower_email ON students (LOWER(email));
SELECT * FROM students WHERE LOWER(email) = 'asha@example.com'; -- ✓ uses itThe Rule This Illustrates
─────────────────────────────────────────
A plain index on `email` CANNOT serve
WHERE LOWER(email) = ?
The index stores the original values; the
database has no way to know which of them lower-
case to the target without checking every one.
Wrapping an indexed column in ANY function
disables its index. Chapter 4 calls this
"non-sargable" and lists the common cases.
─────────────────────────────────────────
5. Full-Text and Specialised Types
Beyond B+ Trees
─────────────────────────────────────────
FULL-TEXT (GIN)
Inverted index: word ──► list of rows.
Real text search with stemming and ranking.
Solves the LIKE '%word%' problem from Module 3,
Chapter 2.
GIN
For "contains" queries on composite values:
arrays, JSONB, tsvector.
GiST
Extensible; geometric and range types.
Nearest-neighbour and overlap queries.
BRIN
Block Range INdex. Stores min/max per block
range. TINY, and only works when the data is
physically ordered by that column — perfect for
append-only time-series.
SPATIAL (R-tree)
Two-dimensional "within this bounding box".
─────────────────────────────────────────
-- Full-text search, properly.
ALTER TABLE articles ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX idx_articles_fts ON articles USING GIN (search_vector);
SELECT title, ts_rank(search_vector, query) AS rank
FROM articles, to_tsquery('english', 'database & index') AS query
WHERE search_vector @@ query
ORDER BY rank DESC;BRIN, Because the Numbers Are Striking
─────────────────────────────────────────
A 100GB append-only events table, indexed on
timestamp:
B+ tree index ~20 GB
BRIN index ~few MB
BRIN records only the min and max timestamp for
each range of blocks. To find a time window it
skips every block range that cannot contain it.
Requires physical ordering to match the column —
which append-only time-series data has for free.
─────────────────────────────────────────
6. Choosing an Index Type
Decision Guide
─────────────────────────────────────────
Default, anything ordered, ranges, sorting
──► B+ TREE. Start here, essentially always.
Several columns filtered together
──► COMPOSITE, equality columns first
A hot query reading few columns
──► COVERING, or B+ tree with INCLUDE
Filtering on a skewed flag
──► PARTIAL. Often a dramatic win.
Filtering on a function's result
──► EXPRESSION index on that expression
Searching words inside text
──► FULL-TEXT / GIN. Never LIKE '%x%'.
Arrays or JSONB containment
──► GIN
Huge append-only table ordered by time
──► BRIN
Equality only, on a large column, measured
──► HASH
─────────────────────────────────────────
7. Summary & Next Steps
Key Takeaways
- Hash indexes beat B+ trees only at equality and by a small constant, while losing ranges, ordering, MIN/MAX and prefix matching — which is why B+ trees are the default.
- A composite index is sorted left to right, so it can only serve queries that use a leftmost prefix; put equality columns before range columns.
- A covering index removes the random heap fetch per matching row, and is usually the single largest index-tuning win available.
- Partial indexes on skewed flags can be thousands of times smaller than a full index, and wrapping a column in a function disables its index entirely.
Concept Check
- An index exists on
(city, marks). Explain whyWHERE marks > 80cannot use it. - What two steps does a normal index scan perform, and which one does a covering index eliminate?
- Why can a plain index on
emailnot serveWHERE LOWER(email) = ?, and what fixes it?
Next Chapter
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to DBMS Index