Senior 8 min · March 06, 2026

SQL Indexes — Avoid 90-Minute Foreign Key Lock

An unindexed foreign key on an 8M-row child table caused a 90-minute hold during a 50,000-row deletion.

N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Lessons pulled from things that broke in production.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • An index is a sorted B-tree mapping column values to row locations — turns O(n) full table scans into O(log n) lookups
  • Clustered index = data physically stored in B-tree leaf nodes (one per table, usually the PK)
  • Non-clustered index = separate structure with pointers to heap rows (multiple per table)
  • Leftmost prefix rule: index on (A, B) helps queries on A or (A,B) — never on B alone
  • Covering index = INCLUDE clause adds columns to leaf nodes — zero heap access, fastest possible read
  • Biggest trap: function calls on indexed columns (WHERE YEAR(created_at) = 2024) silently disable the index
✦ Definition~90s read
What is SQL Indexes?

SQL indexes are data structures that enable the database engine to locate rows without scanning entire tables. Without them, every query triggers a sequential scan — O(n) complexity that becomes catastrophic at scale. A 10-million-row table with no index on the foreign key column will lock that table for minutes during cascading deletes or updates, because the database must check every row for referential integrity.

Think of a SQL database like a massive 1,000-page textbook.

Indexes solve this by maintaining a separate, sorted copy of the indexed columns, typically as a B-Tree, which reduces lookup time to O(log n). They are not optional for production systems; they are the difference between a 90-minute lock and a sub-millisecond operation.

The B-Tree index is the default in PostgreSQL, MySQL, and SQL Server. It stores key-value pairs in a balanced tree structure with root, branch, and leaf nodes. The leaf nodes contain the actual row pointers (TIDs in PostgreSQL, primary key references in InnoDB).

When you query WHERE foreign_key_id = 42, the engine traverses from root to leaf in roughly log₂(n) steps — for a billion rows, that's 30 comparisons versus scanning a billion rows. Composite indexes extend this by sorting on multiple columns, but the leftmost prefix rule means the index can only be used if you filter on the first column in the index definition.

A composite index on (a, b, c) will not help a query filtering only on b and c.

Specialized index types exist for non-b-tree workloads. GIN (Generalized Inverted Index) handles JSONB, arrays, and full-text search by mapping each element to its containing rows — essential for @> containment queries on JSON columns. GiST (Generalized Search Tree) supports geometric data, range types, and fuzzy text search with trigrams via pg_trgm.

These are not drop-in replacements; GIN has slower writes but fast reads, while GiST balances both. Use them when your query patterns involve ILIKE '%term%', @@ full-text operators, or ST_Contains spatial queries — B-Tree cannot handle these efficiently.

Indexes come with costs. Every INSERT, UPDATE, or DELETE must update every index on the table — this is write amplification. A table with 5 indexes pays a 5x write penalty. Over time, B-Tree pages fragment, causing bloat that wastes disk space and slows scans.

PostgreSQL's autovacuum mitigates this but cannot eliminate it. Indexes also consume memory for caching; a 50GB index on a 32GB server will cause constant page eviction. The rule: index only columns used in WHERE, JOIN, ORDER BY, or foreign key constraints.

Never index blindly — measure query plans with EXPLAIN ANALYZE and drop unused indexes. A 90-minute foreign key lock is a symptom of a missing index, but adding one without understanding your write workload can create a different class of performance problems.

Plain-English First

Think of a SQL database like a massive 1,000-page textbook. If you want to find the chapter on 'Photosynthesis,' you don't start at page one and read every sentence until you find it (that's a Full Table Scan). Instead, you flip to the Index at the back, find the word 'Photosynthesis,' see it's on page 412, and jump straight there. An SQL index is that exact 'map'—it's a smaller, sorted list that tells the database exactly where the data lives so it doesn't have to search the whole 'book.'

Indexes are the single most impactful optimization available in relational databases. A query that takes 30 seconds on a table with 10 million rows can return in 2 milliseconds after adding the right index. The difference is the gap between scanning every row on disk and jumping directly to the matching ones via a balanced tree structure.

However, indexes aren't free. The cost is write overhead and disk space. Every index you add slows down INSERT, UPDATE, and DELETE operations because the database must maintain the sorted order of the index in real-time. The 'Forge' philosophy is about precision: adding the exact indexes your critical queries need without bloating the table with redundant metadata.

Why SQL Indexes Are Not Optional

An SQL index is a separate data structure — typically a B-tree — that maps column values to row locations, enabling the database to find rows without scanning the entire table. Without an index, every query performs a sequential scan (O(n)), which becomes catastrophic beyond a few thousand rows. With a B-tree index, point lookups drop to O(log n), and range scans become efficient by traversing leaf nodes in order.

Indexes work by maintaining a sorted copy of the indexed column(s) along with pointers to the heap or clustered index. This imposes write overhead: each INSERT, UPDATE, or DELETE must update every relevant index, so adding indexes blindly can degrade write throughput by 2–5x. The database query planner chooses an index only when it estimates the index will reduce cost — typically when the query filters or sorts on leading columns of the index. Composite indexes follow a leftmost prefix rule: a query on (a, b, c) can use an index on (a, b, c) or (a, b) but not on (b, c) alone.

Use indexes on columns that appear in WHERE, JOIN, ORDER BY, or GROUP BY clauses, especially when those columns have high cardinality (many distinct values). In production systems, a missing index is the most common cause of sudden query degradation — a single unindexed foreign key can turn a 10ms JOIN into a 90-minute lock storm under load. Indexes are not free, but the cost of not having them is far higher.

Index ≠ Performance Guarantee
An index on a low-cardinality column (e.g., boolean) rarely helps — the planner will still scan because the index returns too many rows per key.
Production Insight
A missing foreign key index on a child table with 5M rows caused a cascading DELETE to hold an exclusive lock for 90 minutes, blocking all writes to the parent table.
Symptom: pg_stat_activity showed a single DELETE query with wait_event 'relation' and a steadily growing age, while application timeouts cascaded.
Rule: Always index foreign key columns — the database does not do this automatically, and unindexed FK operations escalate row locks to table-level locks under load.
Key Takeaway
Indexes turn O(n) scans into O(log n) lookups — without them, your database is a flat file.
Every index adds write cost: benchmark before adding indexes on hot write paths.
Foreign key columns must always be indexed — the database won't do it for you, and the lock escalation is brutal.
SQL Indexes: Structure, Types, and Pitfalls THECODEFORGE.IO SQL Indexes: Structure, Types, and Pitfalls From B-Tree internals to composite indexes and maintenance traps B-Tree Index Structure Root, branch, leaf nodes for fast lookup Composite Index & Leftmost Prefix Order matters; only leftmost columns usable Specialized Indexes (GIN/GiST) For JSON, full-text, and complex data types Write Amplification & Bloat Indexes slow writes; bloat degrades reads Index Maintenance Fill factor, reindex, and bloat control ⚠ Missing index on foreign key causes 90-min lock Always index FK columns to avoid cascading lock waits THECODEFORGE.IO
thecodeforge.io
SQL Indexes: Structure, Types, and Pitfalls
Sql Indexes

How a B-Tree Index Works

Most relational databases (PostgreSQL, MySQL, SQL Server) use a B-tree (Balanced Tree) structure for indexing. A B-tree keeps data sorted and allows searches, sequential access, insertions, and deletions in logarithmic time.

When you query a column, the database starts at the root node and follows pointers down to the leaf nodes. Each leaf node contains the indexed value plus a 'Record Identifier' (RID) or a Primary Key value that points to the actual row data in the 'Heap' or Clustered Index.

btree_index_demo.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
-- io.thecodeforge.optimization
-- Scenario: Searching for a customer without an index
SELECT * FROM io_thecodeforge.orders WHERE customer_id = 1234;
-- Result: The database performs a 'Sequential Scan' (reads 100% of the table).

-- Optimization: Create a standard B-tree index
CREATE INDEX idx_orders_customer 
ON io_thecodeforge.orders(customer_id);

-- Now the query engine performs an 'Index Scan'
EXPLAIN ANALYZE 
SELECT * FROM io_thecodeforge.orders WHERE customer_id = 1234;
Output
Index Scan using idx_orders_customer_id on orders
(cost=0.42..8.44 rows=1 width=120)
(actual time=0.082..0.085 rows=1 loops=1)
Why Low-Cardinality Columns Don't Benefit
An index on a boolean column (is_active with values true/false) is often useless. If 80% of rows have is_active = true, the planner calculates it is cheaper to read the whole table than to follow 8 million individual heap pointers. The query planner uses statistics — run ANALYZE after bulk inserts to keep them fresh.
Production Insight
A B-tree with 10M rows has depth ~23 — at most 23 page reads to find any row vs potentially millions without an index.
The planner ignores indexes when estimated selectivity is too low (>15-20% of rows matching).
Function calls on indexed columns (LOWER(email), YEAR(created_at)) bypass the index entirely — normalise data at write time instead.
Key Takeaway
B-tree depth scales as log(n) — 10M rows = ~23 hops vs millions of page reads for a sequential scan.
The planner decides whether to use your index — selectivity, statistics freshness, and function wrapping all affect that decision.
Always validate with EXPLAIN ANALYZE after creating an index — never assume it is being used.

B-Tree Index Structure: Root, Branch, and Leaf Nodes

A B-tree index is a multi-level tree consisting of three types of pages (nodes): root, internal (branch), and leaf. The root node is the single entry point – it contains a small set of keys that divide the data into ranges. When you search for a value, the engine reads the root, follows a pointer to the appropriate branch node, which further narrows the range, and finally reaches the leaf node containing the actual index entry. The number of levels (the height of the tree) depends on the number of entries and the page size. For a B-tree of order k (each node can hold up to k keys), the height grows as log base k of (number of rows), so even with millions of rows the tree is rarely more than 3–5 levels deep.

Leaf nodes are the most important: they store the indexed column value along with a pointer to the actual row (either a page identifier and offset, or the clustered index key). In a non-clustered index, leaf nodes store only the indexed columns and the row locator. In a clustered index, leaf nodes contain all table columns (the data itself). Branch nodes contain routing information only – keys and pointers to lower-level nodes.

A B-tree remains balanced by design – all leaf nodes are at the same depth. When a node overflows, it splits into two nodes, and a new key is promoted upward. This automatic rebalancing ensures consistent O(log n) performance, but the split operations also contribute to write overhead and potential bloat over time.

Visualising the hierarchy makes debugging easier. When EXPLAIN ANALYZE reports 'Index Scan using idx_orders_customer_id' but with a high number of 'Heap Fetches', the issue often lies in leaf node page density (fragmentation) rather than the tree height.

Production Insight
The B-tree's balanced property means index performance is predictable and does not degrade with more data – query time stays O(log n) even as tables grow. Use pageinspect in PostgreSQL to examine the actual number of internal levels and detect skewed node splits. If a specific range of keys causes more splits, consider using a fill factor lower than 90% to reserve space for frequent inserts.
Key Takeaway
A B-tree index has three node types (root, branch, leaf) all at the same depth – this guarantees log(N) lookup time independent of data growth.
B-Tree Structure Root Branch Leaf
Root: keys 25, 50Branch: keys 10, 18Branch: keys 30, 45Branch: keys 60, 80Leaf: 1,5,7,10Leaf: 12,14,18Leaf: 22,25,27Leaf: 30,35,40Leaf: 55,60,65Leaf: 70,75,80

Composite Indexes and the Leftmost Prefix Rule

A composite index (multi-column index) is sorted primarily by the first column, then the second, and so on. This leads to the Leftmost Prefix Rule: an index on $(A, B)$ can be used for queries filtering by $(A)$ or $(A, B)$, but it is completely useless for a query filtering only by $(B)$.

Choosing the right column order is vital. Generally, you put the column with the highest cardinality (most unique values) first, or the column most frequently used in WHERE clauses.

composite_and_covering_indexes.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- io.thecodeforge.optimization
-- Composite index on (last_name, first_name)
CREATE INDEX idx_user_full_name 
ON io_thecodeforge.users(last_name, first_name);

-- VALID: Uses index (leftmost prefix)
SELECT * FROM io_thecodeforge.users WHERE last_name = 'Jordan';

-- VALID: Uses index (exact match)
SELECT * FROM io_thecodeforge.users WHERE last_name = 'Jordan' AND first_name = 'Michael';

-- INVALID: Full Table Scan (skips the first column of the index)
SELECT * FROM io_thecodeforge.users WHERE first_name = 'Michael';

-- PRO TIP: Covering Index
-- This index contains ALL data needed, avoiding a 'Table Lookup' entirely
CREATE INDEX idx_covering_search 
ON io_thecodeforge.users(email) INCLUDE (user_id, status);
Output
-- Covering index result:
-- Index Only Scan using idx_covering_user_lookup
-- Heap Fetches: 0 <- zero heap access
The Phone Book Mental Model
  • First column = primary sort key — queries must include it to use the composite index
  • Second column = tie-breaker — only useful after the first column filters
  • Equality conditions first, range conditions last — maximises usability
  • INCLUDE columns add data to leaves without changing sort order — for covering indexes only
Production Insight
The most common composite index mistake: putting a range column first (created_at, status) — only the range portion is usable, the status column is ignored by the planner.
Covering indexes eliminate heap fetches entirely — for high-frequency reads on large tables this is often the single biggest performance win available.
For ORDER BY optimisation, index column order must match the sort order exactly — otherwise a filesort step appears in EXPLAIN.
Key Takeaway
Leftmost prefix is non-negotiable — index on (A, B) cannot help a WHERE B = ? query.
Equality columns first, range columns last in composite index definitions.
Covering indexes = zero heap access — identify your top read-heavy queries and cover them.

While B-tree indexes excel at equality and range queries on simple scalar columns, they cannot efficiently handle complex data types like JSONB, arrays, tsvectors, or geometric shapes. For such cases, PostgreSQL provides two generalized index methods: GIN (Generalized Inverted Index) and GiST (Generalized Search Tree).

GIN indexes are designed for composite types: JSONB, arrays, range types, and full-text search (tsvector). A GIN index stores a mapping from each possible key (e.g., each JSONB key/value pair, each array element, each lexeme in a tsvector) to a list of row locations. Queries using the @>, ?, ?|, ?& operators on JSONB become fast index scans instead of table-wide scans. GIN indexes are relatively expensive to build and maintain (each insert may touch many index entries), and they consume more storage than a B-tree on the same column. However, for read-heavy workloads that query JSONB with containment or existence operators, GIN is the only viable option.

GiST indexes are more versatile: they support range, point, polygon, and full-text search (though GIN is usually faster for text). GiST is also used for nearest-neighbor ordering and overlapping containment queries. When you need to find all rows that contain a point within a polygon or rows with a tsvector that matches a tsquery, a GiST index can accelerate those operations.

Picking the right one: For JSONB, prefer GIN with jsonb_path_ops operator class for smaller index and faster lookups on @> queries. For full-text search, GIN on a tsvector column is the standard. For geometric or range types, GiST is the natural choice. Both index types support concurrent creation just like B-trees.

specialized_indexes.sqlSQL
1
2
3
4
5
6
7
-- io.thecodeforge.optimization
-- GIN index on JSONB
CREATE INDEX idx_documents_data_gin
ON documents USING GIN (data jsonb_path_ops);

-- Query that now uses the GIN index
SELECT id
GIN and WAL Size
GIN indexes expand a single index entry into many (one per component), so UPDATE/DELETE operations on tables with GIN indexes generate more WAL than a B-tree. Monitor WAL generation rate after adding a GIN index, especially on high-write tables. Consider dropping and recreating GIN indexes during bulk loads rather than maintaining them incrementally.
Production Insight
GIN indexes are the default choice for JSONB in production, but they double index size compared to a B-tree on a uuid column. Always benchmark with realistic data – a GIN index on a JSONB column with 500+ distinct keys can become unwieldy. Use jsonb_path_ops operator class when your queries are primarily containment checks (data @> {...}) – it produces a smaller index by storing paths. In multi-tenant tables with a jsonb column storing varied schemas, GIN with jsonb_path_ops is often the only way to avoid full scans on filter queries.
Key Takeaway
Use GIN for JSONB containment/ existence queries and full-text search; use GiST for range and geometric queries – B-tree cannot efficiently index these types.

When Indexes Hurt: Write Amplification and Bloat

Every time you INSERT a row into a table with 5 indexes, the database must perform 6 writes (1 to the table, 5 to the indexes). This is known as Write Amplification. Furthermore, indexes can become 'fragmented' over time as rows are deleted and updated, leading to bloated files and degraded performance.

index_maintenance_and_audit.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
-- io.thecodeforge.maintenance
-- 1. Identifying unused indexes (PostgreSQL example)
SELECT relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND idx_read = 0;
-- Action: Drop these to reclaim write performance.

-- 2. Avoiding 'Functional' index traps
-- This ignores a standard index on 'created_at'
SELECT * FROM io_thecodeforge.orders WHERE DATE(created_at) = '2024-01-01';

-- Correct approach: Use a range or a functional index
CREATE INDEX idx_functional_date ON io_thecodeforge.orders ((DATE(created_at)));
Output
-- Unused indexes found:
table_name | index_name | times_used | index_size
orders | idx_orders_legacy_status | 0 | 156 MB
users | idx_users_created_month | 0 | 43 MB
-- 199 MB of indexes adding write overhead and never being used
Function Calls Kill Index Usage
WHERE DATE(created_at) = '2024-01-01' forces a full table scan even with an index on created_at — the function transforms the value before comparison, making the index unusable. Rewrite as a range: WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02'. This is the single most common indexing mistake in production systems.
Production Insight
A high-write table (logs, events) with 8+ indexes will show write latency degradation that looks like a capacity problem but is actually an over-indexing problem.
Run the unused index audit query monthly — pg_stat_user_indexes resets on restart, give it at least 2 weeks of representative traffic before trusting idx_scan counts.
Foreign key columns are the most commonly un-indexed columns in production databases — every parent DELETE implicitly queries the child table on that column.
Key Takeaway
Every index = one extra write per INSERT/UPDATE/DELETE — 10 indexes = 10x write amplification.
Audit unused indexes monthly — dead indexes are pure write tax with no read benefit.
Function calls on indexed columns silently disable the index — use range queries instead.

Index Maintenance: Fill Factor, Bloat, and Reindexing

Even well-chosen indexes degrade over time. Three key maintenance parameters and operations keep indexes healthy: fill factor, periodic reindexing, and bloat monitoring.

Fill Factor controls how much free space is left on each B-tree page when the index is built or rebuilt. The default in PostgreSQL is 90% for B-trees (10% free space). This space is reserved for future updates to existing rows that might cause the indexed value to change, or for inserts that fall into the same page range. Without that free space, every insert or update that targets a full page forces a page split — dividing the page into two half-full pages and promoting a key upward. Page splits increase index height slowly and cause fragmentation. Setting a fill factor of 70% for a table with frequent updates to indexed columns reduces splits but increases index size and storage cost.

Bloat occurs when page splits, dead tuples (after UPDATE/DELETE), and incomplete vacuum leave the index containing many empty or nearly empty pages. A bloated index is larger than necessary, uses more memory in shared buffers, and increases I/O for scans. You can measure bloat with the pgstattuple extension or by comparing the index size to the number of indexed values.

Reindexing rebuilds the entire index from scratch, creating a fresh copy with the correct fill factor and no bloat. In PostgreSQL, REINDEX INDEX or REINDEX TABLE locks the table. For production, use REINDEX INDEX CONCURRENTLY (available since PostgreSQL 12) to rebuild without blocking writes. Alternatively, use pg_repack or pg_squeeze for online reorganization. The frequency of reindexing depends on write volume — a table that sees 50% of its indexed columns updated monthly may benefit from quarterly reindexing.

A visual guide to fill factor and page splits: the diagram below shows two pages before and after an insert triggers a split with different fill factors.

Production Insight
Set fill factor to 70% on tables with high update rates on indexed columns (e.g., status or timestamps). Check index bloat monthly using a query based on pg_stat_user_indexes and pg_class. For high-traffic tables, schedule REINDEX CONCURRENTLY during low-activity windows and monitor progress via pg_stat_progress_create_index. Remember that REINDEX creates a full copy of the index, so ensure enough free disk space (roughly equal to the index size).
Key Takeaway
Fill factor reserves space for updates without page splits; measure index bloat regularly and reindex concurrently to reclaim performance.
Fill Factor and Page Split Effect
Step 1
10
20
30
40
__
Fill factor 80%: page has 1 free slot (5th slot reserved). Insert 25 works without split.
Step 2
10
20
25
30
40
Insert completed; page now full (100%).
Step 3
10
20
30
40
Fill factor 100%: page completely full. No free space.
Step 4
10
20
25
Insert 25 overflows; page splits into two (10,20) and (25,30,40). A new page allocated.

Creating an Index: Three Patterns That Won't Bite You Later

You don't just CREATE INDEX and walk away. The wrong index is worse than no index — it burns CPU on writes and fools your optimizer into bad plans. Three patterns cover 90% of real cases. Single-column indexes for high-cardinality filters like customer IDs or timestamps. Multi-column (composite) indexes when you filter on multiple columns together — but respect the leftmost prefix rule or the index is dead weight. Unique indexes for enforcement, not performance. They prevent duplicates but add a uniqueness check on every write. Never index a boolean column with 50% true/50% false — the database will ignore it anyway. Always check selectivity before creating. If a column has fewer than 100 distinct values across a million rows, a full scan is cheaper.

create_indexes.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
-- io.thecodeforge
-- Product search is our most frequent query pattern
CREATE INDEX idx_orders_shipped_at ON orders (shipped_at);
-- Composite for multi-column filter: department + created date
CREATE INDEX idx_products_dept_created 
  ON products (department_id, created_at);
-- Enforce business rule: no duplicate invoice numbers
CREATE UNIQUE INDEX idx_invoices_number 
  ON invoices (invoice_number);
-- Show what PostgreSQL actually built
SELECT indexname, indexdef 
  FROM pg_indexes 
  WHERE tablename = 'products';
Output
idx_products_dept_created | CREATE INDEX idx_products_dept_created ON products USING btree (department_id, created_at)
Production Trap:
Adding a unique index to a 50M-row table locks the table for writes during validation. Use CREATE INDEX CONCURRENTLY (PostgreSQL) or ONLINE (SQL Server) in production.
Key Takeaway
Index selectivity above 10% is usually wasted. Test with EXPLAIN before committing.

Confirming, Removing, and Altering Indexes Without Downtime

You need eyes on your indexes before you drop anything. Use SHOW INDEXES or system catalog views to see size, scan count, and last usage. In PostgreSQL, pg_stat_user_indexes tells you which indexes have zero scans — that's your drop list. Removing an index frees disk and speeds writes. But dropping the wrong one crateres query performance. Always check if any foreign key or unique constraint depends on it. Altering an index — renaming, rebuilding, or changing fill factor — often requires an exclusive lock. On large tables, do it in a maintenance window or use CONCURRENTLY variants. Renaming indexes follows a naming convention: idx_tablename_columnname. It saves hours of confusion during incident response. Never name an index 'idx_final_v3' — you will forget what it does by next quarter.

maintain_indexes.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- io.thecodeforge
-- Find unused indexes on our largest table
SELECT schemaname, tablename, indexname, idx_scan
  FROM pg_stat_user_indexes
  WHERE tablename = 'orders'
    AND idx_scan = 0;

-- Safe rename for clarity (PostgreSQL workaround)
ALTER INDEX idx_old_name RENAME TO idx_orders_shipped_at;

-- Drop unused index, no cascade needed
DROP INDEX IF EXISTS idx_orders_old_cache;

-- Rebuild with fill factor for write-heavy table
ALTER INDEX idx_orders_shipped_at 
  SET (fillfactor = 90);
REINDEX INDEX CONCURRENTLY idx_orders_shipped_at;
Output
NOTICE: index "idx_orders_old_cache" does not exist, skipping
REINDEX
Production Trap:
REINDEX acquires an ACCESS EXCLUSIVE lock. On a busy 200GB table, that means 20+ minutes of blocked writes. Always use REINDEX CONCURRENTLY (PG11+) or rebuild via CREATE INDEX CONCURRENTLY + DROP.
Key Takeaway
Unused indexes are just dead weight. Monitor scan counts, not just disk usage.
● Production incidentPOST-MORTEMseverity: high

An Unindexed Foreign Key Caused a 90-Minute Table Lock During a Nightly Deletion Job

Symptom
A nightly cleanup job deleting 50,000 expired records from an accounts table blocked all application writes for 90 minutes. No deadlock was detected — the lock was simply held for a very long time.
Assumption
The team indexed all columns used in WHERE clauses. Foreign keys were treated as constraints only, not as query columns that also need indexing.
Root cause
When PostgreSQL deletes a row from a parent table, it checks all child tables for referential integrity — running SELECT FROM child WHERE fk_column = $1 for every deleted row. With no index on the FK column, each check was a full sequential scan of the 8-million-row child table. 50,000 deletions × full scan = held locks while the engine worked through each one.
Fix
CREATE INDEX CONCURRENTLY idx_child_account_id ON child_table(account_id). The CONCURRENTLY keyword builds the index without a table lock. The next deletion job ran in 4 seconds.
Key lesson
  • Foreign key columns always need an index — they are implicitly queried on every parent-table DELETE and UPDATE
  • Use CREATE INDEX CONCURRENTLY in production to avoid blocking reads and writes during index creation
  • Run SELECT indexrelname, idx_scan FROM pg_stat_user_indexes weekly — idx_scan = 0 after 30 days means the index is adding write overhead for zero benefit
Production debug guideDiagnosing index failures and performance regressions4 entries
Symptom · 01
Query is slow despite an index existing on the WHERE column
Fix
Run EXPLAIN ANALYZE. If you see Seq Scan, the planner chose not to use the index. Check for function calls on the column (WHERE LOWER(email) = '...' disables the index on email). Check selectivity — if >20% of rows match, the planner may prefer seq scan. Run ANALYZE to refresh table statistics.
Symptom · 02
Composite index exists but queries on the second column still full-scan
Fix
Leftmost prefix violation. Index on (status, created_at) cannot be used for WHERE created_at alone. Add a separate single-column index on created_at, or restructure with created_at first if that filter is more common.
Symptom · 03
INSERT/UPDATE/DELETE latency has degraded gradually over months
Fix
Count indexes on the table: SELECT COUNT(*) FROM pg_indexes WHERE tablename = 'your_table'. Audit unused: SELECT indexrelname, idx_scan FROM pg_stat_user_indexes WHERE idx_scan = 0. Drop unused indexes with DROP INDEX CONCURRENTLY.
Symptom · 04
Need to add an index to a live production table without downtime
Fix
Use CREATE INDEX CONCURRENTLY idx_name ON table(col). Monitor progress: SELECT phase, blocks_done, blocks_total FROM pg_stat_progress_create_index. Takes longer than standard CREATE INDEX but never blocks reads or writes.
★ Index Quick Debug CommandsFast commands for diagnosing index problems in PostgreSQL
Slow query — need to verify index is being used
Immediate action
Run EXPLAIN ANALYZE on the exact query
Commands
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 1234;
SELECT indexrelname, idx_scan, idx_tup_read FROM pg_stat_user_indexes WHERE relname = 'orders';
Fix now
If idx_scan is 0 after weeks of traffic, the index is unused. Investigate why or DROP INDEX CONCURRENTLY.
Table has too many indexes — write latency degrading+
Immediate action
Audit indexes by usage frequency
Commands
SELECT indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS size FROM pg_stat_user_indexes WHERE relname = 'your_table' ORDER BY idx_scan ASC;
DROP INDEX CONCURRENTLY idx_unused_index_name;
Fix now
Drop indexes with idx_scan = 0 that have been monitored for at least 2 weeks of representative traffic.
FeatureClustered IndexNon-Clustered Index
Data StorageStores actual row data in the leaf nodesStores pointers (RID/PK) to the data
LimitOnly 1 per table (the physical order)Multiple per table (logical order)
Best ForRange searches and Primary Key lookupsFrequent filtering on non-PK columns
MaintenanceHigh overhead when updating the PKLower overhead but requires a 'lookup' step

Key takeaways

1
B-tree indexes transform $O(n)$ full table scans into $O(\log n)$ logarithmic lookups.
2
The Leftmost Prefix rule is non-negotiable for composite indexes—order your columns by search frequency.
3
Covering indexes are a 'cheat code'—they allow the database to answer queries without ever touching the main table file.
4
Always check your execution plans (EXPLAIN) to ensure your indexes are actually being utilized by the optimizer.
5
Balance is key
index for your slowest, most frequent queries, but keep an eye on write latency.

Common mistakes to avoid

5 patterns
×

Indexing low-cardinality columns (status, gender, is_active)

Symptom
The query planner ignores the index and performs a full table scan — EXPLAIN shows Seq Scan even though the index exists
Fix
Check selectivity: if >15-20% of rows match the typical WHERE value, the planner will prefer a seq scan. Use partial indexes instead: CREATE INDEX idx_pending_orders ON orders(created_at) WHERE status = 'pending' — index only the relevant subset.
×

Applying functions to indexed columns in WHERE clauses

Symptom
WHERE YEAR(created_at) = 2024 or WHERE LOWER(email) = 'alice@...' runs as a full table scan despite indexes on those columns
Fix
Rewrite as range queries: WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'. For case-insensitive searches, store data lowercase at write time or create a functional index: CREATE INDEX ON users(LOWER(email)).
×

Getting column order wrong in composite indexes — range column first

Symptom
EXPLAIN shows the composite index not being used for queries that filter on the second column — or the planner only uses the first column portion
Fix
Put the most-filtered equality column first. Verify with EXPLAIN that all critical query patterns use the index. Add separate single-column indexes for queries that only filter on non-first columns.
×

Not indexing foreign key columns

Symptom
DELETE operations on parent tables are extremely slow or lock for minutes — JOIN queries on related tables do full scans on the child
Fix
Always CREATE INDEX on every foreign key column. PostgreSQL does NOT auto-create indexes for foreign keys — unlike primary keys. Use CREATE INDEX CONCURRENTLY to avoid write locks on live tables.
×

Accumulating unused indexes on high-write tables

Symptom
INSERT and UPDATE latency gradually increases over months on a table with many write operations — no single change caused it
Fix
Run the pg_stat_user_indexes audit. Drop indexes with idx_scan = 0 after a full week of representative traffic. Use DROP INDEX CONCURRENTLY to avoid write locks.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the Leftmost Prefix rule. Can an index on (A, B, C) be used for ...
Q02SENIOR
What is a Covering Index and why is it faster than a standard index scan...
Q03SENIOR
A query on a table with 100M rows with an index on created_at is still s...
Q04SENIOR
What are the disadvantages of too many indexes on a single table?
Q05SENIOR
Compare B-tree and Hash indexes. When would you choose a Hash index?
Q01 of 05SENIOR

Explain the Leftmost Prefix rule. Can an index on (A, B, C) be used for a query on (A, C)?

ANSWER
The leftmost prefix rule means a composite index can only serve queries that include the leftmost columns. An index on (A, B, C) can serve queries on A, (A, B), or (A, B, C). For a query filtering on (A, C) that skips B, the index can be used for the A portion only — the C column filter cannot leverage the index because B is the intermediate sort key. The engine uses the index to find A matches, then filters C in memory. To fully support (A, C) queries, add a separate index on (A, C).
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is the difference between a clustered and non-clustered index?
02
Does a Primary Key automatically create an index?
03
Why would the database ignore my index and do a full scan?
04
What is Index Fragmentation?
N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Lessons pulled from things that broke in production.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's SQL Advanced. Mark it forged?

8 min read · try the examples if you haven't

Previous
SQL UPDATE Statement: Syntax, Examples and Best Practices
1 / 16 · SQL Advanced
Next
SQL Views