
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Slow queries are the most common cause of application outages and user churn I see in production environments across Nepal and globally. Proper database indexing for performance is usually the highest-ROI fix, often reducing query times from seconds to milliseconds without code changes or hardware upgrades. Before you scale up your RDS instance or add more replicas, verify that your schema actually supports efficient data retrieval patterns.
How does database indexing for performance actually work?
An index is a separate data structure that maintains a sorted or hashed copy of specific columns alongside pointers to the original heap rows. When you execute a query with a WHERE clause, JOIN condition, or ORDER BY, the optimizer checks available indexes before deciding whether to scan the base table. Understanding this mechanism prevents the common mistake of adding indexes blindly and hoping for improvement.
B-Tree indexes are the default in PostgreSQL, MySQL/MariaDB, and SQL Server because they handle equality, range, prefix, and sort operations efficiently. The tree stays balanced through splits and merges during writes, guaranteeing O(log n) lookup time regardless of table size. A million-row table typically requires only 3–4 page reads to locate a specific value, compared to reading every single page in a sequential scan.
Hash indexes, available in PostgreSQL via CREATE INDEX ... USING HASH, support only equality comparisons but can be faster than B-Trees for point lookups on high-cardinality columns. They cannot accelerate range queries (>, BETWEEN), prefix matches (LIKE 'abc%'), or sorting. In my experience auditing systems for MySQL performance tuning, hash indexes rarely outperform well-designed B-Trees unless the workload is exclusively key-value lookups with no range access.
When indexes hurt performance
Every index adds write amplification. INSERT, UPDATE, and DELETE operations must modify both the base table and every applicable index synchronously within the same transaction. On write-heavy tables like audit logs, event streams, or real-time telemetry ingestion, excessive indexes can double or triple write latency. Always measure write throughput before and after adding an index in staging.
- Storage overhead: Each index consumes disk space and memory buffer pool. A wide composite index on VARCHAR(255) columns can exceed the base table size.
- Maintenance cost: VACUUM (PostgreSQL) and OPTIMIZE TABLE (MySQL) must process indexes. Fragmented indexes degrade read performance over time.
- Optimizer confusion: Too many similar indexes can cause the planner to pick suboptimal plans due to stale statistics or cost model inaccuracies.
Which index type should you choose for different query patterns?
Selecting the right index structure depends entirely on your actual query predicates, not theoretical best practices. Profile your slow query log first, then match patterns to index capabilities. Here is a practical decision matrix based on production workloads I have tuned across AWS RDS, Azure SQL, and self-managed PostgreSQL clusters.
| Query Pattern | Recommended Index Type | Notes |
|---|---|---|
Equality (=) | B-Tree or Hash | Hash slightly faster for pure point lookups; B-Tree safer default |
Range (>, BETWEEN) | B-Tree | Hash cannot serve range scans |
Prefix LIKE ('abc%') | B-Tree | Must be left-anchored; LIKE '%abc' cannot use standard index |
| Full-text search | GIN / GiST / TSVECTOR | Use PostgreSQL tsvector or MySQL FULLTEXT, not B-Tree |
| JSONB containment | GIN | @>, ?| operators require GIN in PostgreSQL |
| Geospatial | GiST / SP-GiST | PostGIS geometry/geography columns need spatial index |
| Array contains | GIN | ANY(), @> on array columns |
| Covering (index-only scan) | B-Tree + INCLUDE | Avoids heap fetch when all selected columns are in index |
For teams running PostgreSQL administration essentials, remember that GIN indexes are expensive to update. If your JSONB column changes frequently, consider a partial index or materialized view instead. Partial indexes (CREATE INDEX ... WHERE active = true) dramatically reduce size and maintenance cost when queries always filter on a selective predicate.
How do you design effective composite indexes?
Composite (multi-column) indexes are where most teams get database indexing for performance wrong. The column order matters critically because of the leftmost prefix rule: the database can only use the index starting from the leftmost column and continuing contiguously. An index on (tenant_id, status, created_at) accelerates queries filtering on tenant_id alone, or tenant_id + status, but cannot help a query that filters only on status.
- Identify the query pattern. Extract the exact WHERE, JOIN, and ORDER BY clauses from your slow query log or pg_stat_statements. Do not guess.
- Place equality columns first. Columns compared with
=orINshould occupy the leftmost positions. Their order among themselves matters less for correctness but affects selectivity. - Add one range or sort column next. After equality columns, include at most one column used in range conditions (
>,<,BETWEEN) or ORDER BY. Columns after a range condition cannot be used for further filtering or sorting. - Consider covering columns. Use
INCLUDE (col)in PostgreSQL or add columns to the index in MySQL to enable index-only scans, avoiding expensive heap/table lookups. - Validate with EXPLAIN ANALYZE. Never assume the index helps. Run the actual query and confirm "Index Only Scan" or "Index Scan" appears, not "Seq Scan".
-- PostgreSQL: Optimal composite index for tenant-scoped listing
CREATE INDEX CONCURRENTLY idx_orders_tenant_status_created
ON orders (tenant_id, status, created_at DESC)
INCLUDE (order_total, currency);
-- Verify usage
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT order_total, currency
FROM orders
WHERE tenant_id = 'acme-corp'
AND status = 'shipped'
ORDER BY created_at DESC
LIMIT 50; The CONCURRENTLY keyword in PostgreSQL is mandatory for production systems. It allows reads and writes to continue during index creation, avoiding downtime. Standard CREATE INDEX locks the table exclusively. For MySQL 8.0+, invisible indexes let you test impact before making them visible to the optimizer.
How do you diagnose missing or unused indexes with EXPLAIN?
You cannot improve what you cannot measure. Every senior engineer I mentor learns to read execution plans before touching schema. The EXPLAIN ANALYZE command (PostgreSQL) or EXPLAIN FORMAT=JSON (MySQL) reveals whether the optimizer chose your index, how many rows it estimated versus actual, and where time was spent.
Key signals in PostgreSQL output:
- "Seq Scan" on large tables almost always indicates a missing index or statistics problem.
- "Index Scan" vs "Index Only Scan": The latter avoids heap access entirely and is significantly faster. If you see regular Index Scan with high "Heap Fetches", add INCLUDE columns.
- "Rows" mismatch: If estimated rows differ from actual by 10x+, run
ANALYZE table_nameto update statistics. Stale stats cause bad plan choices. - "Buffers: shared hit/read": High "read" values indicate cache misses. The index may exist but not fit in shared_buffers.
-- Check index usage statistics in PostgreSQL
SELECT schemaname, relname AS table, indexrelname AS index,
idx_scan, idx_tup_read, idx_tup_fetch,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelname NOT LIKE '%pkey%'
AND indexrelname NOT LIKE '%unique%'
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20; This query identifies unused indexes consuming storage and slowing writes. Remove them after confirming they are truly redundant. Be cautious with recently created indexes or seasonal queries that run infrequently. Cross-reference with application deployment schedules and batch job calendars before dropping.
What are common indexing anti-patterns to avoid?
Even experienced engineers fall into traps that negate the benefits of database indexing for performance. These anti-patterns appear repeatedly in code reviews and incident postmortems across startups and enterprises alike.
Indexing low-cardinality columns alone. A boolean is_active column with 95% true values provides almost no selectivity. The optimizer will ignore the index and scan the table anyway because fetching random heap pages costs more than a sequential scan. Combine low-cardinality columns with higher-selectivity ones in a composite index, or use partial indexes.
Wrapping indexed columns in functions. WHERE YEAR(created_at) = 2026 cannot use an index on created_at. Rewrite as WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'. In PostgreSQL, expression indexes (CREATE INDEX ON orders (date_trunc('month', created_at))) solve this when function-based filtering is unavoidable, but prefer sargable predicates.
Implicit type conversions. Querying a VARCHAR column with an integer literal (WHERE phone = 9841234567) forces casting and disables index usage. Always match types explicitly. This bites teams migrating from legacy systems where schemas were loosely typed.
Over-indexing foreign keys without analysis. While FK columns often benefit from indexes, not all do. If the child table is small or the FK has very low cardinality relative to table size, the index may never be chosen. Measure before adding.
For teams managing replication topologies, remember that indexes on replicas are independent of the primary. You can add read-optimized indexes on replicas without affecting write performance on the source. See PostgreSQL replication and high availability for safe strategies to distribute read load with specialized indexing.
Practical Next Steps for Production Systems
Database indexing for performance is iterative, not一次性. Start by enabling pg_stat_statements (PostgreSQL) or slow query logging (MySQL) to identify your top 10 queries by total time. For each, run EXPLAIN ANALYZE, check for sequential scans on large tables, and validate that existing indexes match actual predicates. Add or restructure indexes using CONCURRENTLY or online DDL. Monitor write latency and buffer pool hit ratios for 48 hours after changes. Drop unused indexes quarterly.
If your team needs help diagnosing persistent performance issues, designing compliance-ready schemas for SOC 2 audits, or building observable database infrastructure that survives traffic spikes, reach out to discuss your specific workload. Real-world tuning requires context that generic guides cannot provide.