Loading content...
Every index you create imposes a tax on your database system—a tax that is paid not just in storage space, but in CPU cycles, I/O operations, memory pressure, and lock contention. Understanding these costs is essential because they are often invisible until they accumulate to a crisis point.
The Four Categories of Index Cost:
Index maintenance costs fall into four distinct categories, each with different characteristics and implications:
In this page, we will dissect each category with the rigor required to make informed indexing decisions.
By the end of this page, you will understand exactly what happens when you modify data in an indexed table, how to calculate storage and memory requirements, the impact of index fragmentation, and the maintenance operations required to keep indexes performing optimally.
Every write operation must maintain every index on the affected table. This fundamental fact has profound implications for system design.
The Anatomy of an INSERT with Indexes:
When you execute INSERT INTO orders VALUES (...) on a table with 6 indexes, here is the complete sequence of operations:
Quantifying the Cost:
The time complexity of a single INSERT scales linearly with the number of indexes:
$$T_{INSERT} = T_{base} + \sum_{i=1}^{n} T_{index_i}$$
Where:
Each $T_{index_i}$ includes:
| Indexes | INSERT Latency | UPDATE Latency | DELETE Latency | Bulk INSERT (10K rows/sec) |
|---|---|---|---|---|
| 0 (heap only) | 0.08 ms | 0.10 ms | 0.09 ms | 125,000 rows/sec |
| 2 | 0.15 ms | 0.22 ms | 0.18 ms | 67,000 rows/sec |
| 5 | 0.31 ms | 0.45 ms | 0.38 ms | 32,000 rows/sec |
| 10 | 0.58 ms | 0.92 ms | 0.71 ms | 17,000 rows/sec |
| 15 | 0.89 ms | 1.48 ms | 1.12 ms | 11,200 rows/sec |
| 20 | 1.24 ms | 2.15 ms | 1.58 ms | 8,000 rows/sec |
Notice that UPDATE latency increases faster than INSERT or DELETE. This is because updating an indexed column requires both removing the old key and inserting the new key—effectively two index operations per affected index. An UPDATE that modifies all indexed columns on a 15-index table performs 30 index key operations.
While INSERT is straightforward (add keys to all indexes), UPDATE and DELETE have additional complexity that affects their cost profile.
UPDATE Operation Analysis:
UPDATE only modifies indexes for columns that are actually changed:
-- Only updates indexes containing 'status' or 'updated_at'
UPDATE orders SET status = 'shipped', updated_at = NOW() WHERE id = 12345;
-- Updates ALL indexes containing any of these columns
UPDATE orders
SET customer_id = 999, status = 'cancelled', total = 0, notes = 'Refunded'
WHERE id = 12345;
This creates an important optimization opportunity: design UPDATEs to modify as few indexed columns as possible.
The Deferred Delete Optimization:
Many modern databases use ghost records or delete markers instead of immediately removing index entries:
This optimizes DELETE latency but creates storage bloat and cleanup overhead. The trade-off is worthwhile for most workloads because:
If your application uses soft deletes (SET is_deleted = 1 instead of DELETE), remember that these are UPDATEs, not DELETEs. The row remains in all indexes forever. Consider partial indexes that exclude deleted records, or periodic archival to separate tables.
Index storage costs are often underestimated. A naive assumption is that an index on a small column uses minimal space. In reality, index storage can exceed the base table size.
Anatomy of Index Storage:
Each index consists of:
| Component | Calculation | Notes |
|---|---|---|
| Key Size | Sum of indexed column sizes + overhead | VARCHAR uses actual length + 2 bytes. NULLs may add bitmap overhead. |
| Row Locator | 4-8 bytes (heap RID) or clustered key size | Non-clustered indexes in clustered tables store the clustering key. |
| Entry Size | Key Size + Row Locator + per-entry overhead | Overhead: 4-8 bytes for slot directory, status bits. |
| Entries per Page | (Page Size × Fill Factor) / Entry Size | Typical page: 8KB. Fill factor: 80-90%. |
| Leaf Pages | Row Count / Entries per Page | The dominant storage component. |
| Non-leaf Pages | Leaf Pages / (Page Size / (Key + Pointer)) | Much smaller; typically adds 1-5% to total. |
| Total Size | Leaf Pages × Page Size × (1 + Overhead%) | Add 10-20% for fragmentation and free space. |
123456789101112131415161718192021222324
-- Table: orders (50 million rows)-- Columns: order_id BIGINT (8 bytes), customer_id INT (4 bytes),-- order_date DATE (4 bytes), status VARCHAR(20) (avg 10 bytes) -- Index: (customer_id, order_date)-- Key size: 4 + 4 = 8 bytes-- Row locator (assuming clustered on order_id): 8 bytes-- Entry size: 8 + 8 + 6 (overhead) = 22 bytes -- With 8KB pages and 85% fill factor:-- Usable space per page: 8192 × 0.85 = 6,963 bytes-- Entries per page: 6,963 / 22 ≈ 316 entries -- Leaf pages needed: 50,000,000 / 316 ≈ 158,228 pages-- Leaf storage: 158,228 × 8 KB ≈ 1.24 GB -- Non-leaf pages: 158,228 / 400 ≈ 396 + 1 root ≈ 400 pages-- Non-leaf storage: 400 × 8 KB ≈ 3 MB -- Total index size: ~1.25 GB -- Verification in PostgreSQL:SELECT pg_size_pretty(pg_relation_size('idx_orders_customer_date'));-- Result: 1.29 GB (close to estimate, extra from fragmentation)Storage Cost Multipliers:
Several factors can significantly increase actual storage beyond theoretical calculations:
A production system had a 10 GB table with 12 indexes totaling 45 GB. The indexes consumed 4.5× the base table storage. Each index addition seemed small, but the cumulative effect was dramatic. Storage costs, backup times, and restore durations all increased proportionally.
For indexes to deliver their promised performance benefits, they must be cached in the buffer pool. This creates competition with other data structures for limited memory resources.
The Buffer Pool Reality:
Modern database systems cache frequently accessed pages in a buffer pool (also called buffer cache or shared buffers). When an index page is needed:
An index that doesn't fit in the buffer pool loses most of its performance advantage.
| Index Size | Buffer Pool Fit | Effective Performance |
|---|---|---|
| < 10% of buffer pool | Fully cached, high locality | Excellent: 3-4 I/Os for any lookup, usually from cache |
| 10-50% of buffer pool | Partially cached, root/upper levels reliable | Good: Upper levels cached, leaf pages may require I/O |
| 50-100% of buffer pool | Competitive caching, frequent eviction | Degraded: High cache miss rate during peak load |
100% of buffer pool | Cannot fully cache, trashes other data | Poor: Index competes with table for cache, net negative |
buffer pool | Minimal caching possible | Very Poor: Almost every access requires I/O |
Working Set Analysis:
Not all pages in an index are accessed equally. The working set is the subset of pages accessed during normal operation:
A well-designed index has a small working set relative to total size. An index on a monotonically increasing key (like auto-increment ID or timestamp) has excellent locality—new inserts always go to the same few pages, keeping the working set small.
Conversely, an index on randomly distributed keys (like UUIDs) has poor locality—inserts scatter across the entire index, requiring the whole index to be cached for good performance.
Memory pressure from over-indexing doesn't just hurt index performance—it evicts table data pages from the buffer pool, degrading ALL queries including those that don't use indexes. The system becomes I/O bound on operations that should be memory-speed.
Index maintenance requires locks, and locks create contention. In high-concurrency systems, this contention can become the limiting factor on throughput.
Index Page Locking:
When modifying an index, the database must acquire locks to prevent concurrent operations from creating inconsistencies:
Most databases use fine-grained locking at the page or key level, but contention still occurs when multiple transactions target the same page.
Hot Spots in Indexes:
Certain index patterns create severe lock contention:
1. Sequential Key Insertion (The Right Edge Problem)
When inserting into an index on an auto-increment or timestamp column:
2. Popular Key Updates
If many transactions update the same indexed value:
3. Index Splits During High Load
When a page split occurs:
123456789101112131415161718192021222324252627282930313233343536373839
-- Identify lock contention on indexesSELECT relname AS index_name, pg_stat_user_indexes.idx_scan AS index_scans, pg_stat_user_indexes.idx_tup_read AS tuples_read, pg_statio_user_indexes.idx_blks_read AS blocks_read, pg_statio_user_indexes.idx_blks_hit AS blocks_hit, ROUND( pg_statio_user_indexes.idx_blks_hit * 100.0 / NULLIF(pg_statio_user_indexes.idx_blks_read + pg_statio_user_indexes.idx_blks_hit, 0), 2 ) AS cache_hit_ratioFROM pg_stat_user_indexesJOIN pg_statio_user_indexes USING (schemaname, relname, indexrelname)WHERE schemaname = 'public'ORDER BY idx_scan DESC; -- Check for lock waits (real-time)SELECT blocked_locks.relation::regclass AS blocked_table, blocked_activity.query AS blocked_query, blocking_locks.relation::regclass AS blocking_table, blocking_activity.query AS blocking_query, blocked_activity.wait_event_type, blocked_activity.wait_eventFROM pg_catalog.pg_locks blocked_locksJOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pidJOIN pg_catalog.pg_locks blocking_locks ON blocking_locks.locktype = blocked_locks.locktype AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid AND blocking_locks.pid != blocked_locks.pidJOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pidWHERE NOT blocked_locks.granted;To reduce index lock contention: (1) Use higher fill factors to delay page splits, (2) Consider GUID/UUID alternatives that distribute inserts across pages, (3) Partition indexes on hot columns, (4) Use database-specific features like SQL Server's OPTIMIZE_FOR_SEQUENTIAL_KEY option.
Fragmentation is the gradual degradation of index structure over time due to insert, update, and delete operations. There are two types of fragmentation, each with different symptoms and remedies.
Internal fragmentation occurs when pages are not fully utilized—they contain free space that could hold more entries.
Causes:
Impact:
Measurement:
Internal fragmentation is typically expressed as average page fullness:
1234567891011121314151617181920
-- Install pgstattuple extensionCREATE EXTENSION IF NOT EXISTS pgstattuple; -- Analyze index fragmentationSELECT schemaname || '.' || indexname AS index, pg_size_pretty(pg_relation_size(indexname::regclass)) AS size, avg_leaf_density AS page_fullness_pct, leaf_fragmentation AS fragmentation_pctFROM pgstattuple_all('idx_orders_customer_id'); -- For all indexes on a tableSELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) AS size, idx.avg_leaf_density, idx.leaf_fragmentationFROM pg_stat_user_indexes psuCROSS JOIN LATERAL pgstattuple(indexrelid) idxWHERE relname = 'orders';Beyond per-operation costs, indexes require periodic maintenance to remain healthy. These maintenance operations consume resources and must be scheduled carefully to avoid impacting production workloads.
| Operation | Resource Usage | Blocking? | Recommended Frequency | Best Scheduled |
|---|---|---|---|---|
| Statistics Update | Low-Medium | No | Daily / On threshold | Any time |
| Reorganize | Medium | Minimal | Weekly / As-needed | Low-activity period |
| Rebuild (Online) | High | Minimal | Monthly / As-needed | Maintenance window |
| Rebuild (Offline) | Very High | Yes | When necessary | Extended downtime |
| Vacuum/Cleanup | Medium | Minimal | Continuous / Daily | Any time (auto-vacuum) |
| Integrity Check | Very High | Read-only | Weekly / Monthly | Weekends / Off-hours |
12345678910111213141516171819202122
-- Reindex with minimal locking (PostgreSQL 12+)REINDEX TABLE CONCURRENTLY orders; -- Update statisticsANALYZE orders; -- Check and vacuumVACUUM (VERBOSE, ANALYZE) orders; -- Automated maintenance querySELECT schemaname, tablename, n_live_tup, n_dead_tup, ROUND(n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio, last_vacuum, last_autovacuum, last_analyzeFROM pg_stat_user_tablesWHERE n_dead_tup > 1000ORDER BY n_dead_tup DESC;Skipping or delaying maintenance doesn't eliminate the work—it defers and compounds it. A week of missed vacuums might take minutes to catch up. A month might take hours. A year might require extended downtime. Build maintenance into your operational rhythm from day one.
We have dissected the comprehensive costs of index maintenance. This knowledge enables informed decision-making about when the read benefits of an index justify its maintenance costs.
You now understand the complete picture of index maintenance costs. This knowledge is essential for making ROI-based indexing decisions. Next, we'll examine how to analyze query patterns to identify which queries actually need—and would benefit from—index support.