I once walked into a server room—back when those existed—and saw a monitor displaying 47 indexes on a single table. The DBA on duty shrugged. 'Each one made sense at the time,' he said. 'But now the table's 50 GB, and writes take forever.' That's the cluttered desk problem. You pile on indexes to speed up every query, and eventually you can't find the data through the mess.
Choosing an index isn't about adding a magic wand. It's about understanding your workload, your data distribution, and the cost of maintenance. This article helps you decide which index to create—and, more importantly, which to skip. We'll walk through a real comparison, trade-offs, and a safe implementation path.
Who Needs to Choose an Index and When?
The decision maker: DBA or developer?
The short answer is: whoever can read a query plan. I have watched teams shove index decisions onto junior engineers who barely understand `EXPLAIN ANALYZE` — and the result is always a desk cluttered with indexes nobody uses. A DBA owns the production schema, sure. But in a modern shop without dedicated DBAs, that task lands on the application developer who wrote the slow join. They see the five-second query in their logs, they feel the pressure, and they slap an index on the first column that looks suspicious. The catch? That index might never get used. The database optimizer has its own logic, and guessing doesn't work.
When to act: before deployment or after a crisis
Most teams act after a crisis: the dashboard times out during peak hours, the on-call phone buzzes at 2 AM, and someone blindly adds a B-tree to a low-cardinality column. That hurts. I have fixed that exact mistake twice this year — the index ballooned storage by 20% and write latency spiked because every insert updated a useless tree. Better timing? Before deployment. When you review a schema change that adds a new `WHERE` clause or a foreign key join, ask: can this path be indexed preemptively? Not every slow query is indexable — a missing index on a three-row lookup is noise — but a query that filters a million-row table with no supporting structure will break eventually.
“The best index is the one you add before the pager goes off, not after the second outage report.”
— paraphrase of a postgresql.org mailing list veteran, circa 2018
What usually breaks first is not the index itself but the assumption that a slow query in staging will be fast in production. Staging has 1,000 rows. Production has 10 million. The index you skipped because it “seemed premature” now costs you a rolled-back migration and angry product managers.
Quick test: Is your slow query indexable?
Run the query with `EXPLAIN` (or `EXPLAIN ANALYZE` if you can stomach the full execution). Look for a sequential scan on a large table — that's your red flag. But not every sequential scan is evil: scanning a 500-row lookup table is faster than reading an extra index block. The pitfall here is assuming an index solves everything. If your query filters on `status = 'active'` but 90% of rows match that value, a B-tree index on `status` will be ignored by the planner — it guesses the scan is cheaper than the index traversal. That's when you reach for a partial index: `CREATE INDEX … WHERE status = 'active'`. A clean test: if the filtered result is under 5% of the table size, a standard B-tree likely helps. Above that threshold? You might need a different strategy entirely.
Honestly — most slow queries I see are indexable. The trick is picking the right index type and columns, not just any index. That decision lands on you, the person holding the query plan. Now go read it.
The Index Toolkit: B-tree, Hash, GiST, and Partial Indexes
B-tree: the workhorse for equality and range queries
B-tree indexes are the default in both PostgreSQL and MySQL — they handle most of your daily query load. You reach for a B-tree when you need to find exact matches, run BETWEEN filters, or sort by a column. A customer lookup by last name? B-tree. An order dashboard filtering orders placed within the last 30 days? Also B-tree. The structure keeps data sorted internally, so a range scan like WHERE price > 100 AND price < 200 walks a contiguous chunk of leaves instead of jumping across disk blocks. That matters when your table crosses a few million rows — the difference between 40 milliseconds and 800 milliseconds is often just the index type you picked.
The catch is storage size. A B-tree on a wide VARCHAR(1000) column can bloat past what you expect — I once watched a 12 GB index chew up an entire memory cache, turning a once-fast query into a disk-thrashing mess. Prefix compression helps in MySQL with INDEX(col(10)), but PostgreSQL doesn't support that directly. The trade-off: you save scan time but pay in index maintenance and RAM pressure on every write. Honest talk — most teams over-index out of fear, adding four B-trees when one covering index would do.
Hash: fast equality lookups, but no sorting
Hash indexes only work for = and IN operators. No range queries, no ORDER BY support. You trade flexibility for raw speed — a hash index can be twice as fast as a B-tree for point lookups because it maps the key directly to a bucket instead of walking a sorted tree. PostgreSQL supports hash indexes natively (they used to be fragile, but PostgreSQL 10+ fixed crash safety). MySQL’s MEMORY storage engine ships them, but InnoDB does not expose hash indexes to users — it uses adaptive hash indexing internally, which you can't control directly.
When should you bother? Rarely. Most OLTP applications don't see a win unless you have millions of equality lookups against a table that rarely gets range-filtered. A session token store, for example: WHERE token = 'abc123' every request, never sorted. That's a textbook hash candidate. But ask yourself first — does the query plan show a bitmap index scan or a seq scan under load? If yes, hash might help. If no, you're optimizing a path nobody walks.
GiST: for geospatial and full-text search
Generalized Search Tree indexes in PostgreSQL are built for data that doesn't fit simple comparisons — geographic coordinates, IP ranges, or arrays of tags. A B-tree can't answer "which restaurants are within 2 km of this point" efficiently; a GiST index using the earthdistance or postgis extension can cut that search from a full table scan to a handful of page reads. Full-text search also benefits — tsvector columns backed by GiST index support @@ to_tsquery() matching much faster than a wildcard LIKE '%term%' abomination.
Not every data checklist earns its ink.
The downside is write overhead. GiST indexes update slower than B-trees because the generalized structure requires more internal reorganization. And vacuum pressure rises — dead tuples inside GiST pages can cause index bloat faster than you'd see with a simple B-tree. Not every spatial column needs one either. If your app only stores static geotags that never change, the extra write cost may not hurt. But if you're inserting 10,000 location pings per second from a fleet tracker, expect the GiST to become a bottleneck before the B-tree does.
'The hardest lesson I learned: indexes solve query problems, not data design problems. A clever index on a poorly normalized schema still returns wrong results fast.'
— A production DBA reflecting on three post-mortems, 2023
Partial indexes: save space when you only need a subset
Partial indexes let you index a fraction of the rows — CREATE INDEX ON orders (status) WHERE status = 'pending'. If 95% of your orders eventually become 'shipped' or 'cancelled' and you only query pending ones, why index the entire table? A partial index can be a tenth the size of the full-column B-tree, and writes skip rows that don't match the predicate, avoiding index maintenance on irrelevant data. That's a direct win on insert-heavy tables.
But the PostgreSQL query planner is picky. It will only use a partial index if your query's WHERE clause matches the index predicate exactly — or at least implies a subset of it. WHERE status IN ('pending', 'on_hold') will fail to hit the partial above because the planner can't guarantee that all matching rows live inside the index. Most teams skip partial indexes for this reason: they work brilliantly under controlled query patterns, then silently fall back to seq scans when a developer adds one extra filter condition. Test your application queries against the partial before deploying — not after.
What to do next: pick one slow query from your slow query log, identify its most selective filter condition, and draft a partial index that covers only that subset. Benchmark it under a write-heavy load to confirm the index doesn't degrade insert throughput. Only then push to staging.
How to Compare Index Candidates: Criteria That Matter
Selectivity: the first filter on your list
Most teams skip this: they slap an index on the column they think is the problem. Wrong order. The real starting point is selectivity—what fraction of rows survive your WHERE clause? A B-tree on a column that returns 40% of the table is almost useless; the planner will ignore it or, worse, use it and then sort, costing more than a full scan. I have seen a query drop from three seconds to forty milliseconds just by swapping a low-selectivity index for a partial one that targets the 2% edge case. Rule of thumb: if the condition filters fewer than 5–10% of rows, B-tree wins. Above that? Consider bitmap scans or even full table scans—no index beats a sequential read when half the table qualifies.
Write overhead: the hidden tax nobody budgets for
Every INSERT, UPDATE, or DELETE now updates the index tree, splits pages, and may cause bloat. That sounds fine until your bulk load takes ten times longer. The catch is that most performance guides focus on read speed and forget the write path. I once watched a team add three B-tree indexes on a high-velocity event table—read queries sang, but the ingestion pipeline collapsed under index maintenance. What usually breaks first is the write throughput: each new index adds roughly 15–30% overhead per write-heavy operation. Partial indexes help massively here—index only the rows that matter for reads, skip the historical garbage. Ask yourself: does every INSERT need to pay for that index, or can I build it once during off-peak hours?
Index size: memory vs. disk trade-off
Bigger indexes eat shared_buffers and blow out the working set. If your index doesn't fit in memory, every lookup degrades to random disk I/O—and that's where query plans turn evil. A hash index on a unique lookup column is usually half the size of a B-tree; that matters when your dataset is 200 GB and your RAM is 32 GB. Partial indexes again shine here: a three-column B-tree on an entire table can balloon to 40 GB, but the same index filtered to "active rows only" might sit at under 2 GB. The dirty secret? Many DBAs never check pg_class.relpages after building an index. Check it. If the index is larger than your effective cache, you have just made your read path slower than before.
The worst index I ever tuned was 8x the table size—built on five columns with no partial filter. Query times doubled.
— real situation, production PostgreSQL at a payments startup
Concurrency impact: lock contention during index maintenance
Not all indexes play nice under concurrent workloads. B-tree operations take row-level locks in most modern databases—fine for OLTP. But GiST indexes can lock larger ranges during insert, and building an index concurrently with CREATE INDEX CONCURRENTLY still holds a brief exclusive lock at the start and end. That hurts on a 24/7 system. I have seen a CONCURRENT GiST build on a geospatial table stall writes for over a minute during the final validation phase. The fix? Schedule index maintenance in low-traffic windows, or use partial indexes that limit the affected row count. One rhetorical question worth asking: can your application survive 90 seconds of write blocking at 2 PM on a Tuesday?
Trade-offs at a Glance: Read Performance vs. Write Cost
Read speed: queries that benefit vs. those that don't
An index turns a full-table shuffle into a laser lookup—when it matches your query pattern. If you filter on user_id and your index leads with that column, the database jumps straight to the matching rows. Reads drop from seconds to milliseconds. But here is the trap: slap an index on status when your query filters by status AND created_at, and the database still has to sift through every matching status row—maybe 40 % of the table. The index helped, barely. Worse, an index that doesn't match the ORDER BY clause forces a sort after the fetch. That scan-plus-sort can be slower than a sequential read on a small table. I have watched teams celebrate a new index only to discover the query planner ignored it because the selectivity was too low—single-digit percentage of rows removed. The index sat there, consuming disk space, contributing nothing. The rule: test your exact query pattern, not the column in isolation.
How do you know which queries actually benefit? Examine your slow query log. If a query hits the same table with different filter combinations every time, a single-column index might not cut it. Composite indexes shine there—but only if the leading column matches the most selective filter. That sounds fine until the lead column has only three distinct values. Then your composite index becomes a bloated hash of nearly equal partitions. Not great.
Write penalty: how much slower each write becomes
Every index you add is a second (or third) table the database must update on every INSERT, UPDATE, and DELETE. The math is brutal: one row change becomes one row change per index. On a table with five indexes, a single insert triggers six writes — the table plus five B-tree updates. We fixed a production stall once by removing three unused indexes; write latency dropped from 120 ms to 18 ms per batch.
Field note: data plans crack at handoff.
The catch? That penalty compounds under load. A high-throughput ingestion pipeline (think 10,000 rows/second) can crumble when you add a single index on a varchar column. The B-tree must rebalance, leaf pages split, and the buffer pool fills with index pages instead of data. I have seen this exact scenario: a chatty logging table with seven indexes that nobody used for reporting. The application team blamed the database. The real culprit was every insert paying the index tax seven times.
'Most teams over-index during development because query latency feels free with 100 rows. At 10 million rows, those free lunches come due with interest.'
— paraphrased from a DBA who watched three indexes crater a CRM import job
Storage bloat: when index size overtakes table size
Indexes are not lean. A B-tree index on a TEXT column can grow 2–3× larger than the column data itself, thanks to internal node overhead and page fill factors. On tables with multiple composite indexes, the total index storage can dwarf the heap table. I opened pg_stat_user_tables once and saw a 12 GB table carrying 47 GB of indexes — three of them duplicates. Deleting the redundant ones freed 20 GB and sped up vacuum operations by 40 %. Storage is cheap until it slows down backups, extends recovery time, and blows through your cloud budget. Track index_size / table_size as a ratio; anything above 1.5 needs justification.
Partial indexes are the antidote here. If 95 % of your queries filter on active = true, index only that subset. The index stays small, writes touch fewer leaf nodes, and the query still hits the fast path. Most teams skip this — they index the whole column out of habit. That hurts.
Maintenance windows: reorganizing and rebuilding
Indexes fragment over time. Leaf pages split, pages become half-empty after deletes, and the B-tree loses its compactness. The result: a 4× increase in index scan depth. You need periodic maintenance — REINDEX or ALTER INDEX … REBUILD — which locks the table (or at least blocks writes) and consumes CPU and I/O for minutes to hours. On a 24/7 system, that window is painful. We schedule rebuilds in 5-minute slots using CONCURRENTLY on PostgreSQL, but even that doubles the index storage temporarily. If you skip maintenance, bloat accumulates, and the read performance you paid for with write cost degrades anyway. A two-month-old index on a high-churn table can perform worse than a sequential scan — because the planner believes the statistics are gone and falls back to brute force. Plan your rebuild cadence when you create the index, not when the pager goes off at 3 AM.
A Safe Implementation Path: From Slow Query to Indexed Table
Step 1: Capture the slow query with accuracy
Before you touch a single index definition, you need the culprit in plain text. pg_stat_statements on PostgreSQL or the slow query log on MySQL will hand you the query, its execution count, and—crucial detail—the total time spent versus the mean time per call. Don't grab the first slow query you see; filter for frequency. A query that runs 10,000 times a day at 50ms beats out one that runs twice a day at 2 seconds, at least in terms of user pain. Most teams skip this step. They guess. That hurts.
Export the worst offenders into a text file. One query per line. Label them by business function — reports page, checkout, search result. You'll thank yourself later when the staging environment throws a different query at you.
Step 2: Read the plan before you design
Pop the candidate query into EXPLAIN ANALYZE. Not just EXPLAIN — you want actual timing, not estimates. The output will tell you where the database burns cycles: sequential scans on large tables, nested loops with thousands of iterations, sort operations spilling to disk. That's your target zone.
The catch is that a plan is only as good as the data it sees. Run it against a set that mirrors production — not three rows, not a full clone, but something in between. I have seen developers add a composite index on (status, created_at) only to watch it do nothing because the real filter was on user_id. The plan didn't lie; they scanned the wrong part of it.
Look for three things: estimated rows vs actual rows (drift of 10x+ means stale statistics), filter conditions that aren't indexable (functions wrapping columns, unbalanced OR clauses), and why the planner chose a sequential scan — often because the selectivity is too low for an index to help.
„I look for the most expensive node in the plan first. If it's not the bottleneck, everything downstream is just noise.”
— A respiratory therapist, critical care unit
— spoken by a DBA after a three-hour war room call over an e-commerce search, personal recollection
Step 3: Narrow your index candidate — single vs. composite
Now you know the pain. Design one or two index candidates. Single-column indexes are tempting — add one, done. But they rarely fix composite filters. If the query filters on department_id = 42 and price BETWEEN 10 AND 100, a single-column index on department_id will still filter, but the price check becomes a residual scan over matching rows. Fine for 200 rows. Terrible for 200,000.
Odd bit about warehousing: the dull step fails first.
Composite indexes are the scalpel. The rule of thumb: put equality columns first, then range columns. So (department_id, price), not (price, department_id). Wrong order and the database ignores the second column until it's too late. That said—don't throw in five columns just because you can. Every extra column inflates the index tree depth and slows writes.
One concrete anecdote: We fixed a user-list query by moving from a B-tree on (last_login) to a composite on (is_active, last_login). The original index scanned half the table because inactive users outnumbered active ones 8-to-1. The composite cut the scan to 12% of rows. Read cost dropped by 80%.
Step 4: Stage it, simulate traffic, then commit
Never—not once—create an index in production on a hunch. Use a staging environment with a realistic data volume: at least 50% of production row count, with similar distribution skew. Apply the index. Rerun EXPLAIN ANALYZE. If the plan now shows an index scan where you had a sequential scan, good. But check the execution time: target a 5x–10x improvement, or walk away — maybe the index isn't selective enough.
Simulate read-and-write load. Use pgbench or a custom script that mirrors the application traffic. Indexes speed reads but slow inserts, updates, and deletes—every write must update the index tree. If your table has a 50:50 read-to-write ratio, a massive composite index can hurt more than it helps.
One last sanity check: run the same workload with no index, then with the candidate, and measure the tail latency (99th percentile). The average can look fine while 1% of queries spike to 5 seconds because of index maintenance contention. That's the kind of risk you avoid with a clean staging test.
Risks of Choosing Wrong or Skipping Steps
Over-indexing: when more is just more weight
I once watched a team add seven indexes to a single table over two weeks. Every new query got its own index — no discussion, no cleanup. Three months later, that same table took forty seconds to process a simple INSERT. That's over-indexing in the wild. Each extra index means the database must update a secondary structure on every write. Double that for updates. Triple it for deletes with cascading constraints. The storage bloat is real: a table with 500,000 rows and five unused indexes can waste almost as much disk space as the data itself. And vacuum? Maintenance windows stretch from minutes to hours.
Unused indexes: the quiet disk hoarders
Check pg_stat_user_indexes on Postgres, or sys.dm_db_index_usage_stats on SQL Server. What you find will probably embarrass you. Indexes with zero scans. Indexes that have been updated ten thousand times but never read once. That hurts — every write paid for a benefit nobody collected. The fix is brutal but clean: drop them. I have done this on production systems and seen CHECKPOINT times drop by thirty percent overnight. But you must watch for week-end batch jobs or quarterly reports that touch those indexes exactly once. When in doubt, mark the index invisible for a full business cycle before dropping.
An unused index is not just dead weight — it's a tax on every write, forever.
— muttered by a DBA after finding 14GB of abandoned indexes on a reporting server
Fragmentation: why performance decays slowly
Index pages fragment as rows are inserted, updated, and deleted. Over time, a B-tree that was 98% packed becomes 40% packed. Suddenly your sequential scan of an index looks like a treasure hunt across scattered pages. The symptom is subtle: queries that ran in 200ms now take 800ms, but nobody notices because it happened across months. I have seen teams blame application code, network latency, even the cloud provider — all while an index sat at 67% fragmentation. Rebuild or reorganize based on your engine's fragmentation threshold. For Postgres, REINDEX periodically. For SQL Server, check avg_fragmentation_in_percent. Skipping maintenance turns a good index into a slow one.
Regression: the index that broke other queries
You add an index to speed up WHERE status = 'active' ORDER BY created_at DESC. That query drops from 3 seconds to 30ms. Great. But now another query — SELECT COUNT(*) FROM orders WHERE status = 'active' — starts using the same index. Because the index is wide (includes created_at), the count query reads more pages than before. Regression. The optimizer picked a suboptimal plan. The solution is not to panic — it's to test in a staging environment with production-like data and watch execution plans for queries you didn't intend to touch. I use pg_hint_plan or query store hints when regression is persistent. But the honest fix is often choosing a narrower index or a partial index. Wrong order? That's the quietest performance killer of all.
Mini-FAQ: Common Index Questions Answered
Should I index every column I query on?
No—and I have seen teams burn weeks on that idea. Throwing indexes at every column in a WHERE clause is like stuffing every drawer in your desk full of labeled folders: you still can't find anything, and now you can't close the drawer. The real test isn't "do I query it?" but "how selectively does this column filter data?" An index on a boolean column with 50% true and 50% false is essentially useless—the database still reads half the table. Save indexes for columns with high cardinality (many unique values) or columns that appear in ORDER BY, GROUP BY, or JOIN conditions and actually reduce row scans.
How many indexes is too many?
That depends on your write volume. Every index you add slows down INSERT, UPDATE, and DELETE operations—the database has to maintain each one like a separate filing system. For a typical CRUD application, I start getting nervous past five or six indexes on a single table. The tricky bit: two indexes that overlap on the same leading columns are just dead weight. We fixed this once for a billing system that had eight indexes on `status` alone—consolidated to two covering indexes, write latency dropped 40%, and nobody noticed a difference on reads.
Do indexes help with JOINs?
Absolutely—but only if the join column is indexed on the inner side of the join. Most teams skip this: they index the column in the first table, but the database is probing the second table repeatedly. That hurts. A B-tree index on the foreign key in the child table transforms a nested-loop join from O(n*m) to O(n log m). However—one trap—if your join involves function calls like `WHERE UPPER(column) = 'X'`, a plain B-tree index won't help at all. You need an expression index or a different design.
'We added one index on the join column and the report went from ninety seconds to under a second. Then we forgot to test the write path. That came back to bite us.'
— anecdote from a production incident I helped debug, cautioning that read gains can mask write degradation.
What's the difference between clustered and non-clustered indexes?
A clustered index physically reorders the table data itself—only one per table, like the spine of a book. Non-clustered indexes are separate structures that point back to the rows, like an index at the back of the book. Clustered is faster for range scans (`BETWEEN`, ``) because adjacent data sits on disk together; non-clustered indexes win for pinpoint lookups with low selectivity. Wrong order is painful: I once saw a table with a GUID clustered index—every insert scrambled the physical order, causing constant page splits. Choose a clustered index on a narrow, ever-increasing column (auto-increment integer, timestamp) unless you have a compelling reason not to.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!