Indexing and Optimization
Why Indexes Matter
Section titled “Why Indexes Matter”Without an index, finding a specific row in a table of rows requires a full sequential scan, Which is . A B-tree index reduces this to — for a table of one billion rows, that Is the difference between examining one billion rows and approximately 30.
Indexes are the single most impactful performance tool available to a database user. The query Planner cannot use an index that does not exist, and adding the wrong index wastes storage and slows Down writes. Understanding how indexes work internally is the difference between a query that runs In milliseconds and one that takes minutes.
B-Tree Structure
Section titled “B-Tree Structure”The B-tree (Bayer and McCreight, 1972) is the default index structure in PostgreSQL, MySQL (InnoDB), And most relational databases. Despite the name, modern implementations use B+ trees.
Node Anatomy
Section titled “Node Anatomy”A B-tree is a balanced, self-sorting tree with the following properties:
- Every node stores an array of keys and pointers
- Internal nodes store keys and pointers to child nodes
- Leaf nodes store keys and pointers to the actual table rows (or to the row”s physical location)
- The tree is always balanced: all leaf nodes are at the same depth
- Every node (except the root) is at least half full (this is the minimum fill factor)
B-Tree vs B+ Tree
Section titled “B-Tree vs B+ Tree”B-Tree (internal nodes store data): [10 | 20 | 30] / | | \ [3,5,7] [12,15,18] [22,25,28] [32,35,40]
B+ Tree (all data in leaves, leaves linked): [10 | 20 | 30] / | | \ [3,5,7] [12,15,18] [22,25,28] [32,35,40] | | | | v v v v [leaf] -> [leaf] -> [leaf] -> [leaf] (linked list for range scans)| Property | B-Tree | B+ Tree |
|---|---|---|
| Data storage | In all nodes | Only in leaf nodes |
| Leaf linkage | None | Doubly-linked list |
| Range scans | Requires tree traversal | Sequential scan of linked leaves |
| Node fill | Variable | 2/3 to 4/5 (higher fan-out) |
| Height | Taller for the same data | Shorter (higher fan-out) |
Depth and Fan-Out
Section titled “Depth and Fan-Out”For a B+ tree with fan-out (number of children per internal node) and height :
\mathrm{Max entries = f^h
\mathrm{Height = \lceil \log_f N \rceil
In practice:
- A PostgreSQL page is 8KB
- A typical B-tree index entry is about 20-50 bytes (depending on key size)
- Fan-out per internal node: approximately 160-400 entries
- For 1 billion rows: height =
This means every index lookup traverses at most 4 pages — approximately 4 disk seeks or, more Likely with the buffer pool, 4 cache lookups.
Insertion
Section titled “Insertion”- Find the correct leaf node by traversing from the root
- Insert the new key in sorted order in the leaf
- If the leaf overflows (exceeds page capacity):
- Split the leaf into two halves
- Promote the median key to the parent
- If the parent overflows, split it recursively
- If the root splits, create a new root (tree height increases by 1)
Deletion
Section titled “Deletion”- Find the key in the leaf
- Remove the key
- If the leaf underflows (below minimum fill factor):
- Attempt to redistribute entries from a sibling (borrow)
- If siblings are also at minimum, merge with a sibling
- If the parent underflows, recurse upward
- If the root has one child and is an internal node, the child becomes the new root
PostgreSQL does not immediately reclaim space from page splits. Empty space on B-tree pages is Reused by future inserts, but the pages themselves are not returned to the OS until VACUUM FULL or pg_repack. This is why B-tree indexes can become bloated after heavy UPDATE/DELETE workloads.
Hash Indexes
Section titled “Hash Indexes”Hash indexes use a hash function to map keys directly to bucket locations, providing average Lookup for exact equality checks.
When to Use
Section titled “When to Use”- Workload is exclusively point lookups (
WHERE key = value) - No range queries needed (hash indexes cannot do
WHERE key > value) - No sorting needed (hash indexes cannot do
ORDER BY key) - No prefix matching needed
Limitations
Section titled “Limitations”- No range scans
- No ORDER BY
- No partial matches or LIKE
- No partial indexes
- In PostgreSQL prior to 10, hash indexes were not WAL-logged (not crash-safe)
- Generally not recommended in PostgreSQL; use B-tree instead unless you have a specific reason
CREATE INDEX idx_users_email_hash ON users USING hash (email);Covering Indexes
Section titled “Covering Indexes”A covering index (also called an index-only scan) contains all the columns needed by a query, so the Database never needs to access the table itself (the “heap” in PostgreSQL terminology).
-- Query:SELECT first_name, last_name, email FROM employees WHERE department_id = 5;
-- Covering index (includes the selected columns):CREATE INDEX idx_emp_dept_covering ON employees (department_id, first_name, last_name, email);The benefits are significant:
- No heap access: the query is satisfied entirely from the index
- Less I/O: index pages are much smaller than table pages
- Better cache utilisation: more index entries fit in the buffer pool
In PostgreSQL, an index-only scan requires that the visibility map indicates all pages are All-visible. If any referenced page has dirty visibility information, the database falls back to a Regular index scan that checks the heap. This is why VACUUM matters for index-only scan Performance.
Composite Indexes
Section titled “Composite Indexes”A composite (multi-column) index is an index on two or more columns. The order of columns in a Composite index matters critically.
The Leftmost Prefix Rule
Section titled “The Leftmost Prefix Rule”A composite index (A, B, C) can satisfy queries that filter on:
AA, BA, B, C
But not queries that filter on:
BaloneCaloneB, C
CREATE INDEX idx_emp_dept_hire ON employees (department_id, hire_date, salary);
-- Uses the index:SELECT * FROM employees WHERE department_id = 3;SELECT * FROM employees WHERE department_id = 3 AND hire_date > '2023-01-01';SELECT * FROM employees WHERE department_id = 3 AND hire_date > '2023-01-01' AND salary > 100000;
-- Does NOT use the index effectively (falls back to seq scan or partial index scan):SELECT * FROM employees WHERE hire_date > '2023-01-01';SELECT * FROM employees WHERE salary > 100000;Column Order Strategy
Section titled “Column Order Strategy”The general rule for ordering columns in a composite index:
- Equality columns first — columns used with
=go before range columns - Range columns after equality — columns used with
>``<``BETWEEN``LIKEcome after - Most selective columns first among equality columns (reduces the search space faster)
-- Good: equality (department_id) before range (hire_date)CREATE INDEX idx_emp_dept_hire ON employees (department_id, hire_date);
-- Bad: range column first makes the equality column unusableCREATE INDEX idx_emp_hire_dept ON employees (hire_date, department_id);-- Query: WHERE department_id = 3 AND hire_date > '2023-01-01'-- The department_id filter cannot use this index effectivelyPartial Indexes
Section titled “Partial Indexes”A partial index is an index built on a subset of rows defined by a WHERE clause. This reduces index Size and maintenance overhead.
-- Index only active users (99% of queries filter on this anyway)CREATE INDEX idx_users_active_email ON users (email) WHERE is_active = TRUE;
-- Index only unshipped ordersCREATE INDEX idx_orders_unshipped ON orders (customer_id, created_at) WHERE status = 'pending';
-- Index only high-value transactions for fraud detectionCREATE INDEX idx_transactions_large ON transactions (account_id, created_at) WHERE amount >= 10000;REFRESH MATERIALIZED VIEW CONCURRENTLY requires a UNIQUE index on the materialized view. It Refreshes by scanning the new data and updating existing rows, which is slower than a full refresh But does not block concurrent reads.
Subquery Flattening
Section titled “Subquery Flattening”The query planner often converts subqueries into joins automatically. However, some subquery Patterns prevent this optimisation:
-- This may not be flattened (correlated subquery with LIMIT):SELECT * FROM customersWHERE customer_id IN ( SELECT customer_id FROM orders WHERE total > 1000 LIMIT 100);
-- Rewrite as a JOIN:SELECT DISTINCT c.*FROM customers cJOIN orders o ON c.customer_id = o.customer_id AND o.total > 1000LIMIT 100;Batch Inserts and COPY
Section titled “Batch Inserts and COPY”For bulk data loading, individual INSERT statements are extremely slow due to per-statement Overhead (parsing, planning, WAL flush, index maintenance). Use batch inserts or COPY:
-- Multi-row INSERT (batch):INSERT INTO sensor_readings (sensor_id, timestamp, value)VALUES (1, '2024-03-15T10:00:00Z', 23.5), (1, '2024-03-15T10:01:00Z', 23.7), (1, '2024-03-15T10:02:00Z', 23.6);
-- COPY (fastest for bulk loads):COPY sensor_readings (sensor_id, timestamp, value)FROM '/data/sensor_data.csv'WITH (FORMAT csv, HEADER true);# For large imports, tune these settings during the load:psql -c "SET maintenance_work_mem = '2GB';"psql -c "SET max_wal_size = '4GB';"psql -c "ALTER TABLE sensor_readings SET (autovacuum_enabled = false);"psql -c "COPY sensor_readings FROM '/data/sensor_data.csv' WITH (FORMAT csv);"psql -c "ALTER TABLE sensor_readings SET (autovacuum_enabled = true);"psql -c "ANALYZE sensor_readings;"Prepared Statements
Section titled “Prepared Statements”Prepared statements parse and plan a query once, then execute it multiple times with different Parameters. This avoids repeated parsing overhead and allows the planner to cache the execution Plan.
-- Prepare:PREPARE get_orders_by_customer ASSELECT * FROM orders WHERE customer_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT 50;
-- Execute (multiple times with different parameters):EXECUTE get_orders_by_customer(42, 'pending');EXECUTE get_orders_by_customer(42, 'completed');
-- Deallocate when done:DEALLOCATE get_orders_by_customer;