Database Indexing Strategies Every Developer Must Know
Master database indexing in 2026. Learn B-tree, hash, composite, partial, and covering indexes with practical SQL examples for PostgreSQL and MySQL.
Indexes are the single highest-leverage performance tool in relational databases. A missing index turns a millisecond query into a multi-second table scan. A wrong index wastes disk space and slows writes. Understanding indexes isnβt optional β itβs foundational backend engineering.
This guide covers every index type youβll encounter, when to use each, and how to diagnose missing indexes.
How Indexes Work (The Foundation)
Without an index, a database executes a sequential scan (called βfull table scanβ in MySQL or βSeq Scanβ in PostgreSQL): it reads every row in the table from disk to find matching rows.
-- On a users table with 10M rows, this is catastrophic without an index
SELECT * FROM users WHERE email = '[email protected]';
-- Seq Scan: reads 10M rows to find 1
An index is a separate data structure that stores a subset of columns in a way that enables fast lookups β trading storage space and write overhead for read speed.
B-Tree Indexes (The Default)
B-tree (balanced tree) is the default index type in both PostgreSQL and MySQL. It handles equality, range queries, and ORDER BY efficiently.
-- Create a B-tree index (implicit, as it's the default)
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_orders_created ON orders (created_at);
B-trees are useful for:
- Equality:
WHERE email = ? - Range:
WHERE created_at BETWEEN ? AND ? - Prefix match:
WHERE name LIKE 'Alice%'(but NOTLIKE '%Alice%') - ORDER BY / GROUP BY: when the index matches the sort order
- IS NULL / IS NOT NULL: PostgreSQL handles this well; MySQL less so
Internal Structure
A B-tree index stores values in sorted order in a balanced tree. Lookups are O(log n) β a 10M row table requires at most ~24 comparisons to locate any value. Sequential scans are O(n).
B-tree height example:
- 1M rows, order-100 tree: height β 3 levels
- 10M rows: height β 4 levels
- Even at 1B rows: β 5 levels
Hash Indexes
Hash indexes use a hash function to map column values to bucket positions. Theyβre faster than B-trees for equality lookups but useless for range queries or sorting.
-- PostgreSQL: explicit hash index
CREATE INDEX idx_sessions_token ON sessions USING HASH (token);
-- MySQL: hash indexes only available in MEMORY engine (or as adaptive hash index, automatic)
Use hash indexes when: You only ever do equality lookups (WHERE token = ?) and the column has high cardinality (many unique values). Session tokens, UUIDs, API keys.
Donβt use when: You need range queries, LIKE, ORDER BY, or NULL handling.
Composite Indexes (Multi-Column)
A composite index spans multiple columns. Column order matters enormously.
-- Composite index on (last_name, first_name)
CREATE INDEX idx_users_name ON users (last_name, first_name);
The Leftmost Prefix Rule
A composite index (a, b, c) can be used for:
- Queries on
a - Queries on
a, b - Queries on
a, b, c - Queries on
a, c(uses onlya)
It cannot be used for queries on b alone, c alone, or b, c.
-- β
Index (last_name, first_name) is used
WHERE last_name = 'Smith'
WHERE last_name = 'Smith' AND first_name = 'John'
-- β Index NOT used (or used poorly)
WHERE first_name = 'John' -- not the leftmost column
Real-World Composite Index Design
-- Orders table: common query patterns
-- 1. Find orders by user, ordered by date
SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at DESC;
-- Optimal index: (user_id, created_at)
CREATE INDEX idx_orders_user_date ON orders (user_id, created_at);
-- 2. Find orders by status and date range
SELECT * FROM orders WHERE status = 'pending' AND created_at > NOW() - INTERVAL '7 days';
-- Optimal index: (status, created_at)
CREATE INDEX idx_orders_status_date ON orders (status, created_at);
Cardinality and Column Order
For equality filters, put lower cardinality columns first (less distinct values). For range filters, put the range column last in the composite index.
-- If you filter by country (low cardinality: ~250 values) AND user_id (high cardinality)
-- Put country first β it filters out more rows early
CREATE INDEX idx_orders_country_user ON orders (country, user_id);
Covering Indexes (Index-Only Scans)
A covering index includes all columns needed for a query, so the database never touches the main table. This is one of the most impactful performance optimizations.
-- Query
SELECT user_id, amount, status FROM orders WHERE created_at > '2026-01-01';
-- Without covering index: index scan + heap fetch for each matching row
-- With covering index: index-only scan (no heap access)
CREATE INDEX idx_orders_covering ON orders (created_at, user_id, amount, status);
In PostgreSQL, this is called an Index Only Scan. In MySQL, itβs a Using index access type in EXPLAIN.
-- Verify covering index is used (PostgreSQL)
EXPLAIN SELECT user_id, amount FROM orders WHERE created_at > '2026-01-01';
-- Look for "Index Only Scan" in output
Trade-off: Covering indexes are wider (more disk space) and slower to write. Use them on frequently-read, rarely-written tables.
Partial Indexes
A partial index includes only rows that satisfy a WHERE condition. Smaller, faster, and often more useful than full column indexes.
-- Index only active users (ignore archived/deleted)
CREATE INDEX idx_users_active ON users (email)
WHERE status = 'active';
-- Index only unfulfilled orders
CREATE INDEX idx_orders_pending ON orders (created_at, user_id)
WHERE status IN ('pending', 'processing');
This is extremely effective when:
- A small fraction of rows are βhotβ (active records vs. archived)
- You have soft-deleted records (
deleted_at IS NULL) - You query a specific status predominantly
-- Common pattern: index only non-deleted records
CREATE INDEX idx_products_active ON products (name, category_id)
WHERE deleted_at IS NULL;
Expression Indexes (Function-Based)
Index the result of a function or expression, not just a raw column value.
-- PostgreSQL: case-insensitive email lookup
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
-- Query MUST use the same expression to hit the index
SELECT * FROM users WHERE LOWER(email) = LOWER('[email protected]');
-- β
Uses the index
SELECT * FROM users WHERE email = '[email protected]';
-- β Does NOT use the function index (unless email is already lowercase)
-- MySQL: generated column + index (MySQL 5.7+)
ALTER TABLE users ADD COLUMN email_lower VARCHAR(255)
GENERATED ALWAYS AS (LOWER(email)) STORED;
CREATE INDEX idx_email_lower ON users (email_lower);
-- Date extraction: find all orders from a specific month
CREATE INDEX idx_orders_month ON orders (EXTRACT(MONTH FROM created_at));
SELECT * FROM orders WHERE EXTRACT(MONTH FROM created_at) = 3;
GIN Indexes (PostgreSQL)
Generalized Inverted Index β designed for multi-valued data: arrays, JSONB, full-text search.
-- Full-text search
CREATE INDEX idx_articles_fts ON articles USING GIN (
to_tsvector('english', title || ' ' || body)
);
SELECT * FROM articles
WHERE to_tsvector('english', title || ' ' || body) @@ plainto_tsquery('database indexing');
-- JSONB queries
CREATE INDEX idx_products_metadata ON products USING GIN (metadata);
SELECT * FROM products WHERE metadata @> '{"category": "electronics"}';
-- Array containment
CREATE INDEX idx_posts_tags ON posts USING GIN (tags);
SELECT * FROM posts WHERE tags @> ARRAY['postgresql', 'performance'];
GIN indexes are large and slow to build but essential for these data types.
Diagnosing Missing Indexes
EXPLAIN / EXPLAIN ANALYZE
Always use EXPLAIN before and after adding indexes:
-- PostgreSQL: full execution plan with timing
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 10;
-- MySQL: basic execution plan
EXPLAIN SELECT * FROM orders WHERE user_id = 123;
-- MySQL 8.0+: verbose plan
EXPLAIN FORMAT=JSON SELECT ...;
Key things to look for:
Seq Scan(PostgreSQL) orALLtype (MySQL) β table scan, likely needs an indexrowsestimate β if much larger than actual rows, statistics are stale (runANALYZE)Nested Loopwith large row estimates β may need a better index
Find Slow Queries
PostgreSQL: Enable pg_stat_statements:
-- Find top 10 slowest queries
SELECT query, mean_exec_time, calls, total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
MySQL: Enable slow query log:
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- log queries > 1 second
Find Unused Indexes
-- PostgreSQL: find indexes never used
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND indexname NOT LIKE '%pkey%'
ORDER BY pg_relation_size(indexrelid) DESC;
Unused indexes are pure overhead β they slow writes and waste disk space. Drop them.
Common Indexing Mistakes
1. Indexing Low-Cardinality Columns
-- BAD: boolean column has only 2 values β index rarely helps
CREATE INDEX idx_users_is_active ON users (is_active);
-- The query planner often prefers a seq scan when >5-10% of rows match
-- Exception: use a partial index instead
CREATE INDEX idx_users_active_email ON users (email) WHERE is_active = true;
2. Over-Indexing
Every index you create must be maintained on every INSERT, UPDATE, DELETE. A table with 10 indexes is 10x slower to write to than with 1 index.
Rule of thumb: Start with no extra indexes. Add indexes based on slow query evidence, not speculation.
3. Indexing the Wrong Side of a JOIN
-- Index the column on the INNER side of the join (the "many" side)
SELECT u.name, o.total
FROM users u
JOIN orders o ON o.user_id = u.id -- index orders.user_id
WHERE u.id = 123;
-- β
Correct
CREATE INDEX idx_orders_user_id ON orders (user_id);
-- β Indexing users.id (primary key) when it's already indexed doesn't help here
4. Forgetting to Update Statistics
After bulk data operations, query planners make bad decisions because statistics are stale:
-- PostgreSQL: update statistics
ANALYZE orders;
-- MySQL: rebuild statistics
ANALYZE TABLE orders;
Index Maintenance
-- PostgreSQL: rebuild bloated index
REINDEX INDEX CONCURRENTLY idx_orders_user_date;
-- PostgreSQL: check index bloat
SELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) as size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;
-- MySQL: rebuild index (rewrites entire table β use with caution in production)
ALTER TABLE orders ENGINE=InnoDB; -- triggers rebuild
FAQ
Q: How many indexes should a table have? Typically 2-5 for most OLTP tables. More than 10 usually indicates over-indexing. OLAP tables (data warehouse) may have more because writes are infrequent.
Q: Does indexing a foreign key help? Yes β always index foreign keys in MySQL (PostgreSQL does not auto-index FKs). Without an FK index, every DELETE on the parent table does a full scan of the child table.
Q: Why isnβt my index being used?
Common reasons: (1) The query planner estimates itβs faster to seq scan (low selectivity), (2) The index column is inside a function (WHERE YEAR(created_at) = 2026 canβt use an index on created_at), (3) Type mismatch between column and value, (4) Statistics are stale.
Q: Whatβs the difference between a clustered and non-clustered index? A clustered index (primary key in MySQL InnoDB, or any index marked CLUSTER in Postgres) determines the physical row order on disk. There can only be one per table. Non-clustered indexes are separate structures that point back to the heap/clustered index. InnoDB always clusters by primary key.
Q: Should I index NULL values? PostgreSQL includes NULLs in B-tree indexes. MySQL InnoDB also indexes NULLs. IS NULL and IS NOT NULL queries can use indexes in modern databases, though older MySQL versions had limitations.
50 CLI Commands That Save Hours Every Week
The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.
- Git mastery (10 cmds)
- File & search wizardry
- Docker & DevOps tricks
- Process & network tools
- Productivity boosters
- macOS & Linux install guide
No spam. 1 email/week max. Unsubscribe anytime.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links β we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.