SQL Indexing Best Practices: Choose, Use, and Maintain Indexes
An index is one of the most important tools for improving SQL query performance. A well-designed
index can help the database find the required rows without scanning an entire table. But indexes
are not free — they use storage and add work to INSERT, UPDATE, and
DELETE operations, and an index that does not match the query workload may not be
used at all. This guide covers the practical side of SQL indexing, including different index
types, how to choose columns, covering indexes, column cardinality, index maintenance and
fragmentation, and the fifteen questions most commonly asked in SQL interviews. The examples
use the same customer and order tables as the rest of the series, so you can run them alongside
the earlier pages.
🎯 Key Takeaways
Indexes speed up reads and slow down writes
Clustered index = the table itself; one per table
Covering indexes avoid touching the table
Cardinality decides how selective an index is
Rebuild fragmented indexes; drop unused ones
15 Interview QuestionsBeginner to AdvancedReal ExamplesUpdated Oct 7, 2026
KW
Kailas Warade
✓ Verified Author
Data Engineer · Oracle Certified · SQL & PL/SQL
Kailas works with SQL, PL/SQL, Python, and PySpark to build data pipelines.
He writes the SQL interview guides at Sankalan Data Tech, focused on
practical, real-world answers.
🏆 Oracle Certified💼 Data Engineer📝 SQL & PL/SQL Guides📍 Pune, India
An index is a data structure the database maintains to help find rows without scanning the
entire table. A simple way to think about it is the index at the back of a book. Instead of
going through every page to find a topic, you use the index to locate the relevant pages
directly. A database index works on the same basic idea: it provides another way to find
the rows a query needs.
The benefit becomes more noticeable as tables grow. On a table with a thousand rows, a
full table scan may be quick enough that an index makes little difference. On a table with
millions of rows, scanning the entire table for every query can become expensive. A
suitable index can allow the database to locate the required rows much more efficiently.
The actual benefit depends on the query, the data distribution, and the database's query
plan.
Indexes also have a cost. They require additional storage and the database has to maintain
them when indexed data changes. A table with several indexes can therefore have more
overhead during INSERT, UPDATE, and DELETE
operations. On a system with heavy write activity, unnecessary indexes can reduce
performance rather than improve it. Good indexing is about balancing read performance
against storage and write overhead for the actual workload.
The rest of this guide focuses on the practical side of SQL indexing — which indexes to
create, how to choose the right columns, when an index may not help, when to remove an
unused index, and how the database decides whether to use one. The examples use the same
customer and order tables as the rest of the series.
The Sample Data We Use in Every Example
Same customers and orders tables as the rest of the series. All index examples target
columns in these two tables, so you can run them alongside the earlier pages.
Three things in this data make the index examples interesting:
customer_id has high cardinality — every value is unique.
region has low cardinality — only three distinct values across seven rows.
orders.customer_id is a foreign key, the classic case for indexing.
The Main Index Types
Five types cover almost every indexing need. They differ in what they index and what
they are good for.
Single-column index
CREATE INDEX idx_customer_name ON customers (customer_name);
The simplest form — one column, one index. Any query that filters or sorts on
customer_name can use it.
Composite (multi-column) index
CREATE INDEX idx_region_limit ON customers (region, credit_limit);
Two or more columns in one index. Useful when queries filter on both together. The
column order matters — the index is useful for queries that filter on region,
or on region and credit_limit, but not for queries that filter
only on credit_limit.
Unique index
CREATE UNIQUE INDEX idx_customer_name_unique ON customers (customer_name);
Adds a constraint: no two rows can share the same value in that column. Useful for
business rules like "no two customers can have the same name".
Full-text index
-- SQL Server
CREATE FULLTEXT INDEX ON products (description) KEY INDEX pk_products;
Designed for text search across large blocks of text. Normal indexes do not work well
for WHERE description LIKE '%important%' because the pattern starts with a
wildcard. Full-text indexes are built differently and handle word-based search
efficiently.
Spatial index
-- PostgreSQL (PostGIS)
CREATE INDEX idx_location ON stores USING GIST (location);
For geographic data — points, lines, polygons. Supports queries like "find every store
within ten kilometres of this address". The internal structure is different from a
standard index because the data has two dimensions rather than one.
How to choose
Type
Best for
Overhead
Single-column
Frequently queried single columns
Low
Composite
Queries filtering on multiple columns
Medium
Unique
Natural keys, business rules
Medium
Full-text
Text search, large string columns
High (separate index type)
Spatial
Geographic data
High (complex structure)
Clustered vs Non-Clustered Indexes
This is the most important distinction in indexing, and it takes a bit of unlearning to
get right. In most databases, a clustered index is not a separate structure — it is the
table itself.
Clustered index — the table sorted by a key
CREATE CLUSTERED INDEX idx_customer_id
ON customers (customer_id);
When a table has a clustered index, the database physically stores the rows in the order
of that index. Reading the table in index order is the natural order — no separate
lookup needed. That is why a table can have only one clustered index: the rows cannot
be sorted two different ways at the same time.
On most tables, the primary key is the clustered index. That means the rows are stored
sorted by their primary key value. Range queries on the primary key become very fast,
because the rows for a range are physically adjacent.
Non-clustered index — a separate structure
CREATE INDEX idx_customer_name
ON customers (customer_name);
A non-clustered index is stored separately from the table. It contains the indexed
column values plus a pointer back to the row in the table. When you look up a value in
the index, the database then has to follow the pointer to the actual row to get the
other columns.
That extra step is what makes non-clustered indexes slower than clustered indexes on the
same data — a lookup is two reads instead of one. But you can have many non-clustered
indexes on the same table, which is how most real-world schemas work.
Comparing the two
Aspect
Clustered
Non-clustered
Stores
The rows themselves
Indexed values plus row pointer
How many per table
One
Many
Lookup cost
One read (the row is in the index)
Two reads (index then row)
Best for
Range scans, primary key access
Specific lookups on any column
Typical choice
Primary key column
Frequently queried foreign keys, filter columns
Oracle is a bit of a special case. It does not have a separate "clustered index" the way
SQL Server does. Oracle tables are heap-organized by default, with the primary key stored
as a regular unique index. Oracle does have INDEX ORGANIZED TABLES, which
behave like SQL Server's clustered index, but most Oracle schemas use the default heap
structure.
Choosing Columns to Index
Indexing every column is a common beginner mistake. The right approach is to index based
on the workload — which queries actually run, and what they filter on.
Columns worth indexing
Columns in WHERE clauses. A filter that scans the whole table is exactly what an index prevents.
Columns in JOIN conditions. Especially the foreign key side of a one-to-many relationship. Every join column on the "many" side should have an index.
Columns in ORDER BY. If the query sorts by a column that has an index, the database can read the rows in sorted order and skip the sort step entirely.
Columns in GROUP BY and DISTINCT. These benefit similarly to ORDER BY — the index provides the order the aggregation needs.
High-cardinality columns. Columns with many distinct values are more selective — the index skips more rows per lookup.
Columns to think twice about
Columns that change often. Every update has to update the index. A column that changes on every write is a bad candidate.
Columns with very few distinct values. A region column with three values does not help much — 1/3 of the table is still a large result set. Unless you also have a composite index or a bitmap index.
Columns that are rarely queried. If no query filters on it, an index on it costs storage and slows writes for no benefit.
Wide columns. Indexing a large text column takes more space and may not help much — especially if you also need full-text search.
The composite index decision
CREATE INDEX idx_region_limit ON customers (region, credit_limit);
Composite indexes are powerful when queries filter on multiple columns. But the column
order matters. The general guidance: put the most selective column first (the one that
filters out the most rows) or the column that appears in the most queries first.
A composite index on (region, credit_limit) supports:
WHERE region = 'North'
WHERE region = 'North' AND credit_limit > 50000
It does not support:
WHERE credit_limit > 50000
Because the index is sorted by region first. Queries that filter only on the second
column have to scan the whole index to find their rows.
The rule of thumb I use: index for the queries you actually run, not the
queries you might run. Look at your slow query log, look at the metrics from production,
and index accordingly. Adding an index for a hypothetical query is a bet that rarely pays
off.
Covering Indexes
A covering index is the fastest kind of lookup you can get. It contains every column a
specific query needs, so the database answers the query from the index alone and never
touches the table.
The idea
CREATE INDEX idx_covering_customer
ON customers (customer_name, region, credit_limit);
SELECT customer_name, region, credit_limit
FROM customers
WHERE customer_name = 'Alice Corp';
That query needs three columns: customer_name (for the filter),
region, and credit_limit. All three are in the index. The
database reads the index entry, extracts the values, and returns the row — without ever
looking at the customers table.
With a non-covering index, the same query would need two reads: the index entry to
identify the row, then the table row itself to fetch the other columns. The covering
index halves the work.
What makes an index covering
Every column that appears in the SELECT list, the WHERE clause, the JOIN conditions, and
the ORDER BY or GROUP BY of the target query must be present in the index. If even one
column is missing, the database has to go back to the table for it.
-- This query is covered by the index above
SELECT customer_name, region, credit_limit
FROM customers
WHERE customer_name = 'Alice Corp';
-- This query is not covered because it selects a column not in the index
SELECT customer_name, region, credit_limit, customer_id
FROM customers
WHERE customer_name = 'Alice Corp';
The second query needs customer_id, which is not in the index. The database
has to visit the table for that column.
How to add extra columns without breaking the index
-- SQL Server
CREATE INDEX idx_covering_customer
ON customers (customer_name) INCLUDE (region, credit_limit);
-- Oracle
CREATE INDEX idx_covering_customer
ON customers (customer_name, region, credit_limit);
SQL Server supports an INCLUDE clause that adds columns to the leaf level of
the index without including them in the sort key. That is useful because the extra
columns do not affect the sorted order of the index, and they do not count against the
index's key size limit.
Oracle does not have a direct equivalent, but a regular composite index with the columns
in the right order achieves the same effect for most queries.
When covering indexes are worth it
High-frequency queries. If a query runs thousands of times per hour, every saved millisecond matters.
Dashboard and report queries. These usually have a fixed set of columns and filters — perfect candidates for a covering index.
Queries on large tables where the table read is expensive. If the table is stored on disk and the index fits in memory, a covering index avoids disk I/O entirely.
The trade-off is storage. A covering index is wider than a simple index because it
contains more columns. On a large table, that can be significant. And every write to the
table updates every column in the index, so wide covering indexes slow writes more than
narrow ones.
Cardinality and Selectivity
Cardinality is the number of distinct values in a column. Selectivity is the ratio of
distinct values to total rows. Together they decide how useful an index on that column
will be.
The concept in one example
-- High cardinality — almost every value is unique
customer_id → C001, C002, C003, C004, C005, C006, C007
-- Low cardinality — only three distinct values
region → North, South, North, West, South, West, North
A query WHERE customer_id = 'C001' on the high-cardinality column returns
one row out of seven. A query WHERE region = 'North' on the low-cardinality
column returns three rows out of seven — almost half the table.
For an index lookup to beat a full table scan, the index has to filter out most rows.
Returning one row from an index is much faster than scanning seven. Returning three
rows from an index — where the index lookup plus three table reads might cost more than
the scan itself — is a different calculation.
How the optimizer uses cardinality
Every query optimizer knows the cardinality of each indexed column. It uses that
information to estimate how many rows each step of a plan will produce and pick the
cheapest plan. On a low-cardinality column, the optimizer might choose to skip the index
and do a full scan — because the index would not save anything.
-- Probably uses an index
SELECT * FROM customers WHERE customer_id = 'C001';
-- Might skip the index and scan — depends on the optimizer
SELECT * FROM customers WHERE region = 'North';
That second query is a good example of why low-cardinality columns rarely need a simple
index. Unless the table is huge and there are only a handful of queries, a region
column with three values is not a great indexing candidate.
What to index when cardinality is low
Two options. First, a composite index that combines the low-cardinality column with a
high-cardinality one:
CREATE INDEX idx_region_limit ON customers (region, credit_limit);
The combined selectivity is much higher than either column alone — the pair
(region, credit_limit) is more likely to identify a specific customer
than either value on its own.
Second, a bitmap index. On low-cardinality columns where the values are few and stable,
a bitmap index can be dramatically faster and smaller than a normal index. Bitmap
indexes are covered in more detail further down.
A rule worth remembering
The rule of thumb: if a column's cardinality is less than 5% of the total row count,
a simple index on that column is probably not worth it. That column needs a composite
index or a bitmap index to be useful, or it should be left un-indexed and treated as a
filter that runs after the index has already narrowed the rows.
How Indexes Affect INSERT, UPDATE, DELETE
Every index on a table has to be kept up to date when the table changes. That is the cost
side of indexing, and it matters more on write-heavy tables than on read-heavy ones.
The multiplier effect
-- A table with three indexes
INSERT INTO customers (customer_id, customer_name, region, credit_limit)
VALUES ('C008', 'Henry Co', 'East', 45000);
That single INSERT does four writes. The row itself. Plus an update to each of the three
indexes. On a table with ten indexes, it would be eleven writes.
That multiplier matters most when writes are heavy. A table that receives thousands of
INSERTs per second cannot afford ten indexes — the write amplification would slow the
system to a crawl. A table that receives a few INSERTs per day can afford a large number
of indexes because the writes are rare.
Which operations are affected
INSERT — must add the new row's value to every index on the table.
UPDATE — must update every index on any column that changed. If the update touches a column the index covers, that index is updated. If the update touches a column the index does not cover, the index is not touched.
DELETE — must remove the row's entry from every index.
That is why indexes on columns that change frequently are more expensive than indexes on
columns that stay stable. A created_on column that is set once at insert
never changes, so its index is only touched on INSERT and DELETE. A
last_login column that updates on every session would have an index that
updates on every write — much more expensive.
Finding the balance
The right number of indexes depends on the ratio of reads to writes on the table. A
lookup table that is written to once a month and read thousands of times per hour can
have ten indexes without a problem. A log table that receives a million rows per day
and is queried rarely should have very few indexes — maybe just the primary key.
On a mixed workload, the general approach is to index only what queries actually need,
and to review the index set periodically. Dead indexes — ones that no query is using —
are pure overhead and should be dropped.
Fragmentation and Index Maintenance
Indexes are not static. As rows are inserted, updated, and deleted, the physical layout
of the index drifts away from ideal. Pages fill unevenly, splits accumulate, and reads
slow down because the database has to visit more pages than necessary. This is
fragmentation.
How to check fragmentation
-- SQL Server
SELECT * FROM sys.dm_db_index_physical_stats(NULL, NULL, NULL, NULL, 'DETAILED');
-- Oracle — rebuild uses the index's own statistics
ANALYZE INDEX idx_customer_name VALIDATE STRUCTURE;
SELECT name, (del_lf_rows_len / lf_rows_len) * 100 AS frag_pct
FROM index_stats;
Every database has a way to report fragmentation. The output shows a percentage for each
index — the higher the number, the more fragmented the index.
Rebuild vs reorganize
-- Rebuild (heavier, more thorough)
ALTER INDEX idx_customer_name REBUILD;
-- Reorganize (lighter, less thorough)
ALTER INDEX idx_customer_name REORGANIZE;
A rebuild drops the fragmented structure and builds a fresh compact one. It is thorough
but resource-intensive, and on a large table it can take a while. A reorganize reorders
the pages in place without rebuilding the whole structure. It is faster but does less to
reduce fragmentation.
The usual rule: fragmentation under 5% is fine and needs no action. Between 5% and 30%
gets a reorganize. Above 30% gets a rebuild.
Preventing fragmentation with fill factor
CREATE INDEX idx_customer_name
ON customers (customer_name)
WITH (FILLFACTOR = 80);
A fill factor of 80 tells the database to leave each index page only 80% full at build
time. The 20% free space gives new inserts a place to go without triggering a page
split. Fewer page splits means slower fragmentation.
The trade-off is that the index takes up more space — the same 20% that is left free.
For high-churn tables (lots of inserts and updates), a lower fill factor is worth it.
For read-mostly tables that rarely change, a fill factor of 100 wastes less space and
works just as well because there is nothing to fragment.
Scheduling maintenance
Index maintenance is not a one-time task. On a table that receives continuous writes,
fragmentation builds up over weeks. The standard practice is a scheduled job — nightly,
weekly, or monthly depending on write volume — that checks fragmentation and rebuilds or
reorganizes as needed.
Schedule during off-peak hours. A rebuild locks the index while it runs (or requires
online-rebuild support), so running it during the busiest part of the day can cause
noticeable slowdowns for everyone else. On systems with strict uptime requirements,
databases that support online rebuilds let you avoid the lock at the cost of a longer
rebuild.
Statistics and Query Optimizer Choices
Statistics are the database's picture of what is in your indexed columns — how many
distinct values, how many rows per value, how often NULLs appear. The query optimizer
uses them to estimate the cost of different plans.
With that information, the optimizer estimates that WHERE region = 'North'
will return about 3 rows out of 7 — roughly 43%. That is a large fraction, so the
optimizer may decide that a full scan is cheaper than an index lookup.
For WHERE customer_id = 'C001', the statistics show that customer_id is
unique. The estimate is 1 row out of 7 — 14%. Now the index lookup wins clearly.
Stale statistics cause bad plans
If the statistics say a table has a thousand rows but it actually has a million, every
estimate the optimizer makes is wrong. It might choose a hash join where a nested loop
would be better, or scan a table where an index would be faster. The plan looks
reasonable but runs slowly.
Most databases refresh statistics automatically, but the schedule can be hours or days
apart. After a large bulk load — say, importing a million new rows — the statistics can
be wildly out of date until the next refresh. Running a manual refresh after any bulk
operation is worth the few seconds it takes.
When to worry about statistics
After a large bulk insert, update, or delete.
After creating a new index on an existing table.
When a query's execution plan changes for no obvious reason — the statistics may have drifted.
When a query is much slower than expected based on its shape — same symptom.
On small tables, statistics barely matter — the database can scan everything quickly
regardless of the plan. On large tables, statistics are the difference between a plan
that runs in seconds and one that runs in minutes. Everything the optimizer decides is
downstream of what its statistics tell it, so keeping them current is not optional.
Bitmap Indexes and Low-Cardinality Columns
A bitmap index is a different kind of index designed for a specific situation: columns
with few distinct values, queried with combinations of filters. It is uncommon in
transaction-processing databases but very common in data warehouses.
How a bitmap index works
-- Standard B-tree index for region: one entry per row, sorted
-- Each entry has a pointer to its row
-- Bitmap index for region: one bitmap per distinct value
-- North: 1 0 1 0 0 0 1 (customers C001, C003, C007)
-- South: 0 1 0 0 1 0 0 (customers C002, C005)
-- West: 0 0 0 1 0 1 0 (customers C004, C006)
Instead of storing a pointer per row, a bitmap index stores one bit per row for each
distinct value. The bit is 1 if that row has the value, 0 if it does not. For a
column with three values and a table with a million rows, that is three bitmaps of a
million bits each.
Lookups are very fast because the database is just reading bits. And queries that filter
on multiple bitmap-indexed columns can combine the bitmaps with bitwise AND and OR,
which is dramatically faster than combining B-tree lookups.
When to use a bitmap index
Low cardinality columns — gender, status flags, boolean columns, category codes with a handful of values.
Data warehouses — where reads dominate and writes are batched.
Combinations of filters — where the query filters on several low-cardinality columns at once.
Read-only or read-mostly data — because bitmap indexes have a serious write limitation.
When not to use them
CREATE BITMAP INDEX idx_customer_region
ON customers (region);
That index works well in a warehouse. It works badly in an OLTP system with concurrent
writes. The reason: a bitmap index locks a whole block of rows when a single row is
updated. On a table with frequent single-row updates, two writers touching rows in the
same block will block each other — even though they are updating different rows.
That is why Oracle, the database most associated with bitmap indexes, warns against
using them on tables that are updated frequently. For OLTP workloads, stick with B-tree
indexes. Reserve bitmap indexes for warehouses and read-mostly tables.
Not to be confused with bitmap joins
Some databases use "bitmap" as an internal join algorithm — building temporary bitmaps
during a query to speed up joins. That is a completely different concept from a
bitmap index. If you see "bitmap join" in an execution plan, it does not mean the table
has a bitmap index.
15 Interview Questions and Answers
These are the indexing questions that come up most often. Every answer stands on its own
— read them in any order, or work through them in sequence to build up the full picture.
/
🔍
No matching questions found
Try a keyword like clustered, covering, or cardinality.
An index is a data structure the database maintains alongside a table so it can
find rows without scanning the whole table. It is the same idea as the index at
the back of a book — instead of flipping through every page, you go straight to
the entries you need.
CREATE INDEX idx_customer_name ON customers (customer_name);
SELECT customer_id, credit_limit
FROM customers
WHERE customer_name = 'Alice Corp';
Without the index, the database reads every row of the customers table to find
the one matching Alice Corp. With it, the database looks up the value in the
index and jumps to the matching row. On a small table, that difference is
invisible. On a table with millions of rows, it is the difference between
milliseconds and seconds.
The cost is on the write side. Every INSERT, UPDATE, or DELETE has to maintain
the index in addition to the table. A table with three indexes means every write
does four times the work. Indexes are a trade — reads get faster, writes get
slower, and storage grows.
The reason they matter: on almost every real database, indexes are the single
biggest performance lever a developer has. A query that scans a hundred million
rows versus a query that reads three rows is a difference measured in orders of
magnitude, and the index is what makes it possible.
Five types cover most use cases. Each is designed for a specific shape of query.
-- Single-column index
CREATE INDEX idx_customer_name ON customers (customer_name);
-- Composite (multi-column) index
CREATE INDEX idx_region_limit ON customers (region, credit_limit);
-- Unique index
CREATE UNIQUE INDEX idx_customer_name_unique ON customers (customer_name);
-- Full-text index (SQL Server)
CREATE FULLTEXT INDEX ON products (description) KEY INDEX pk_products;
-- Spatial index (PostgreSQL with PostGIS)
CREATE INDEX idx_location ON stores USING GIST (location);
Single-column indexes are the simplest form — one column, one
index. Useful for frequently queried single columns.
Composite indexes cover multiple columns. They help when queries
filter on both columns together. The column order matters — the index is
primarily sorted by the leading column.
Unique indexes enforce uniqueness in addition to speeding up
lookups. Business rules like "no two customers can have the same email" are
enforced by a unique index.
Full-text indexes are for text search across large string
columns. They handle word-based queries efficiently where a normal index cannot.
Spatial indexes handle geographic data — points, lines, polygons.
Used for queries like "find every store within ten kilometres".
In practice, single-column and composite B-tree indexes cover 90% of real-world
indexing needs. Full-text and spatial indexes show up when the data has a
specific shape. Unique indexes are everywhere because they back business rules.
The most important factor is how often the column appears in queries. A column
that filters, joins, or sorts frequently is a candidate. A column that is never
queried is not.
-- Frequently filtered column — good candidate for an index
SELECT * FROM orders WHERE customer_id = 'C001';
-- Rarely filtered column — likely not worth indexing
SELECT * FROM orders WHERE order_status = 'Pending';
Beyond frequency, three factors matter:
Cardinality. Columns with many distinct values are more selective and index better. Columns with few distinct values (like a status column with three options) benefit less.
Update frequency. Columns that change often make the index expensive to maintain. A column that is set once at insert is cheap to index; a column that changes on every write is expensive.
Column width. Narrow columns make smaller indexes, which fit in memory better and take less storage. Very wide text columns are usually better served by a full-text index.
The trade-off is always between the read benefit and the write cost plus storage.
An index that speeds up a query by 100x but slows every insert by 20% might be
worth it on a read-heavy table and a poor choice on a write-heavy one.
The practical rule: index for the queries that actually run, not the ones that
might. Use query logs and metrics to see what real queries are doing, and index
based on that evidence rather than guesses.
Large databases make the indexing trade-offs more visible. Three practices cover
most of what matters.
Index the columns that are actually queried. Look at the
production query workload. The columns in WHERE, JOIN, and ORDER BY clauses are
the candidates. Everything else is a guess.
-- If this is the query that runs most often
SELECT c.customer_name, o.order_id
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_status = 'Delivered';
-- Then index orders.customer_id (join column)
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
-- And possibly orders.order_status if it is highly selective
CREATE INDEX idx_orders_status ON orders (order_status);
Do not over-index. Every index slows down writes. On a table
with heavy insert traffic, three well-chosen indexes beat ten mediocre ones.
When you are tempted to add an index, first check whether an existing index
already covers the query — a composite index on (region, credit_limit) makes a
simple index on region unnecessary.
Use composite indexes wisely. A composite index on the right
columns can replace two single-column indexes and be more selective. The rule:
put the most selective column first, or the column that appears in the most
queries first.
Beyond those three, the maintenance side matters too. Review index usage
periodically — the database can tell you which indexes are being used and which
are not. Drop the ones that no query hits. Rebuild fragmented indexes. Keep
statistics current after bulk operations. On large databases, this maintenance is
an ongoing responsibility, not a one-time setup.
Three disadvantages come up regularly. They are real costs, not minor caveats.
Writes get slower. Every INSERT, UPDATE, or DELETE has to update
every index on the table. On a table with five indexes, a single insert becomes
six writes. On write-heavy systems, that overhead can be the dominant cost.
-- One INSERT with three indexes on the table
INSERT INTO customers (customer_id, customer_name, region, credit_limit)
VALUES ('C008', 'Henry Co', 'East', 45000);
-- That statement updates: the table row + idx_customer_name + idx_region + idx_credit_limit
Storage grows. Indexes take space. On a table with a billion
rows, a single index can be tens of gigabytes. Multiple indexes on the same table
multiply that. Storage costs add up, and on distributed databases where storage
is priced per gigabyte, this is a real bill.
Maintenance overhead. Indexes fragment over time and need
periodic rebuilds. Statistics need refreshing. Old indexes accumulate and need
to be dropped. All of that is ongoing work that has to be scheduled and monitored.
Those three reasons are why the guidance is "index what queries actually need"
rather than "index everything that might be useful". Every index is a commitment
— storage, write overhead, and maintenance — and it should pay for itself in read
performance.
The query optimizer picks based on cost. It estimates how expensive each candidate
plan will be and picks the cheapest one. The estimate depends on statistics,
index availability, and the shape of the query.
EXPLAIN
SELECT customer_id, customer_name
FROM customers
WHERE region = 'North' AND credit_limit > 50000;
That EXPLAIN shows the plan the optimizer chose. The output might
say it used idx_region_limit, or scanned the table, or something
else — depending on what it estimated would be fastest.
The cost estimate for each candidate plan factors in:
Index selectivity — how many rows the index will return. A highly selective index returns few rows and is cheap. A low-cardinality index returns many and may be more expensive than a scan.
Table size — how many rows the table has and how many pages it takes up.
Index size — the index that fits in memory is cheaper than one that does not.
Statistics freshness — the optimizer trusts the statistics. Stale statistics produce wrong estimates and bad plans.
Query complexity — multi-join queries have many possible plans, and the optimizer considers combinations.
When a query runs slowly, the first step is EXPLAIN. If the plan
looks wrong — a full scan where an index lookup should be, a hash join where a
nested loop would be faster — the fix is usually refreshing statistics,
adding a missing index, or rewriting the query so the optimizer sees a better
path.
Every database has a slightly different optimizer with its own quirks. MySQL's
optimizer, PostgreSQL's, SQL Server's, and Oracle's all take slightly different
approaches and produce different plans for the same query. That is why hints and
platform-specific tuning exist — sometimes the optimizer gets it wrong even with
good statistics.
Fragmentation is the drift between the physical order of index pages and the
logical order they should be in. It happens naturally as rows are inserted,
updated, and deleted over time.
Imagine a sorted list of names stored on paper cards. Inserting a new name in the
middle means shuffling cards around. Do this repeatedly and the physical order of
the cards no longer matches the sorted order — some cards are out of place, some
pages are partly empty. Reading the list is slower because you have to skip
around. That is fragmentation.
-- Check fragmentation
SELECT * FROM sys.dm_db_index_physical_stats(NULL, NULL, NULL, NULL, 'DETAILED');
-- Fix it with rebuild
ALTER INDEX idx_customer_name REBUILD;
-- Or with reorganize
ALTER INDEX idx_customer_name REORGANIZE;
Rebuild drops the fragmented structure and builds a fresh
compact one. Thorough but resource-intensive.
Reorganize reorders the pages in place without rebuilding from
scratch. Lighter but does less.
The rule of thumb: fragmentation under 5% is fine, 5–30% gets a reorganize, and
30%+ gets a rebuild.
To prevent fragmentation in the first place, use a fill factor:
CREATE INDEX idx_customer_name
ON customers (customer_name)
WITH (FILLFACTOR = 80);
That leaves 20% free space on each index page at build time. New inserts fill the
free space before a page split is needed. Fewer splits means slower fragmentation.
Schedule maintenance for off-peak hours. A rebuild locks the index (unless the
database supports online rebuilds), so running it during peak traffic can slow
everyone else down.
A covering index contains every column a query needs. The database can answer the
query entirely from the index without ever touching the table, which is the
fastest kind of lookup you can get.
CREATE INDEX idx_covering_customer
ON customers (customer_name, region, credit_limit);
SELECT customer_name, region, credit_limit
FROM customers
WHERE customer_name = 'Alice Corp';
That query needs three columns: customer_name for the filter,
region and credit_limit for the output. All three are in
the index. The database reads the index entry, extracts the three values, and
returns the row — no table access.
With a normal non-covering index, the query would need two reads: one to find the
matching index entry, another to fetch the missing columns from the table. The
covering index halves the work.
Use covering indexes when a specific query runs very frequently. Dashboard queries
and report queries are the classic candidates — they have a fixed column set and
often run thousands of times per hour. On a large table, the difference between
one read and two reads per execution adds up fast.
-- SQL Server syntax for including columns without adding to the sort key
CREATE INDEX idx_covering_customer
ON customers (customer_name) INCLUDE (region, credit_limit);
SQL Server's INCLUDE clause adds columns to the leaf level of the
index without making them part of the sort key. That is useful when the extra
columns are only needed for output, not for filtering or sorting.
The trade-off is storage. A covering index is wider than a normal index because it
contains more columns. On a large table, that can be significant — a covering
index on five columns takes roughly the same space as a copy of those five
columns. And writes are slower because every column in the index is updated on
every relevant write. Use covering indexes where the query frequency justifies
the cost.
Every index on a table has to be updated whenever the table changes. That is the
cost side of indexing, and it multiplies with every index on the table.
-- Table with three indexes
INSERT INTO customers (customer_id, customer_name, region, credit_limit)
VALUES ('C008', 'Henry Co', 'East', 45000);
-- Cost: 1 table write + 3 index writes = 4 writes total
That multiplication is the main reason over-indexing is a problem. A table with
ten indexes does eleven writes per insert instead of one. On a system that
receives thousands of inserts per second, that difference is the difference
between a healthy system and a bottleneck.
Each operation is affected differently:
INSERT — every index gets a new entry. The cost is uniform across all indexes.
UPDATE — only the indexes that cover the updated columns are touched. An update to credit_limit only updates indexes that include that column. An update to customer_name only touches indexes that include customer_name.
DELETE — every index gets an entry removed. Same uniformity as INSERT.
That asymmetry between UPDATE and INSERT/DELETE is worth remembering. If most of
your writes are updates and they only touch a few columns, indexing many other
columns is cheaper than it looks. But if you are inserting new rows constantly,
every index is a cost.
The practical approach: match the index count to the read-write ratio of the
table. Read-heavy tables (reference data, lookup tables) can afford many
indexes. Write-heavy tables (logs, events, order history) should have very few —
usually just the primary key and maybe one or two critical lookup columns.
The distinction comes down to where the data lives. A clustered index stores the
table's rows in the order of the index — the table itself is the index. A
non-clustered index is stored separately, with pointers back at the table.
-- Clustered index
CREATE CLUSTERED INDEX idx_customer_id ON customers (customer_id);
Once that runs, the rows in customers are physically stored sorted
by customer_id. Reading the table in that order is a sequential
scan. Reading a range of customer ids is very fast because the rows for the range
are physically adjacent.
A clustered index is the table. That is why a table can have only one — rows
cannot be physically sorted two ways at once. On most real-world tables, the
primary key is the clustered index.
-- Non-clustered index
CREATE INDEX idx_customer_name ON customers (customer_name);
A non-clustered index is a separate structure. It stores the values of
customer_name in sorted order, each with a pointer to the row in the
table. Looking up a name goes through the index first, then to the table row for
the other columns.
That extra step is what makes non-clustered indexes slower than clustered indexes
— a lookup is two reads instead of one. But you can have many non-clustered
indexes on the same table, and that flexibility is why they are so common.
The choice of clustered key matters more than most people realise. It determines
the physical layout of the whole table, so it affects every range query, every
sequential scan, and the size of every non-clustered index (which contains a
copy of the clustered key). Wide clustered keys waste space in every non-clustered
index on the table.
Oracle works slightly differently. Its default table structure is heap-organized,
with the primary key stored as a regular unique index. Oracle's
INDEX ORGANIZED TABLES behave like SQL Server's clustered indexes,
but most Oracle schemas use heap tables.
Complex queries with multiple joins and filters often need composite indexes
that cover the columns involved in the query's filters and joins.
SELECT c.customer_name, o.order_id
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE c.region = 'North'
AND o.order_status = 'Delivered';
That query filters on region, joins on customer_id,
and filters on order_status. The indexes that would help:
-- Index for the join column (on the "many" side)
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
-- Composite index combining filter and join columns
CREATE INDEX idx_orders_customer_status ON orders (customer_id, order_status);
-- Index for the customer filter
CREATE INDEX idx_customers_region ON customers (region);
The composite index (customer_id, order_status) is more selective
than either column alone. When the database joins customers to orders on
customer_id, it can use the composite index to look up matching orders and
immediately filter by status without scanning through all of a customer's orders.
The general approach for complex queries:
Index every join column, especially on the "many" side of a one-to-many.
Combine frequently co-occurring filter columns into composite indexes.
Use covering indexes for the whole query if it runs often enough.
Review the execution plan after the change to confirm the index is being used.
Regular maintenance matters for complex queries too. Statistics drift over time,
fragmentation accumulates, and the plan that worked six months ago may not be
the best plan today. Rebuild fragmented indexes and refresh statistics on a
schedule, and re-check the plan after any significant data volume change.
Cardinality is the number of distinct values in an indexed column. It decides how
selective an index is — how much of the table a lookup filters out.
-- High cardinality: 7 distinct values across 7 rows
customer_id → C001, C002, C003, C004, C005, C006, C007
-- Low cardinality: 3 distinct values across 7 rows
region → North, South, North, West, South, West, North
A query filtering on customer_id returns one row out of seven. A
query filtering on region returns three rows out of seven. The
high-cardinality lookup is much more selective — the index skips 6/7 of the table
versus 4/7.
Selectivity is the ratio of distinct values to total rows. High selectivity means
each value appears in few rows. Low selectivity means each value appears in many.
Indexes on high-selectivity columns are more useful because they filter out more
of the table per lookup.
There is a rough threshold: if a column's cardinality is less than about 5% of
the total row count, a simple B-tree index on that column probably does not help.
The database has to read so many rows anyway that the index lookup overhead
outweighs the benefit of skipping rows.
When a column has low cardinality, two options help:
A composite index that combines the low-cardinality column with a high-cardinality one. The pair is more selective than either value alone.
A bitmap index, which is designed for exactly this case. Bitmap indexes are covered separately.
The reason this matters in practice: a column with low cardinality (a status flag,
a boolean, a small list of categories) is not worth a simple index. Understanding
cardinality before creating an index saves you from creating indexes that the
optimizer will end up ignoring.
Statistics are the database's internal picture of the data distribution in an
indexed column. They tell the optimizer how many distinct values a column has,
how many rows per value, and how often NULLs appear.
With that information, the optimizer can estimate the cost of a plan. For
WHERE region = 'North', it estimates 3 rows out of 7. That is a
large fraction, so a full scan might be cheaper than an index lookup. For
WHERE customer_id = 'C001', the statistics say 1 row out of 7, so an
index lookup wins.
Statistics are refreshed automatically on a schedule in most databases, but the
schedule varies. Some databases refresh hourly, some daily, some only when the
table changes by a certain percentage. If you have just loaded a million rows
into a table, the statistics may be wildly out of date.
Stale statistics are one of the most common causes of a query suddenly running
slowly for no apparent reason. The query has not changed, the data has not
changed in an obvious way, but the optimizer's estimate is wrong and it is
picking a bad plan. Refreshing the statistics often fixes the problem
immediately.
The times to refresh statistics manually: after a bulk load, after creating an
index on an existing table, when the plan has changed without an obvious cause,
and when a query is much slower than its shape suggests.
An index should be dropped when it costs more than it earns. Three situations
make that true.
It is never used. Every database tracks which indexes are being
read. If an index has zero reads over a long period, it is pure overhead. Drop
it. The storage it consumes is freed, and every write to the table gets slightly
faster.
-- Oracle: find unused indexes
SELECT index_name, table_name, used
FROM v$object_usage
WHERE used = 'NO';
It is made redundant by a composite index. A composite index on
(region, credit_limit) already covers queries that filter on
region alone. If there is also a simple index on
region, the simple one is redundant — the composite index handles
both cases. Drop the simple one.
-- Redundant pair
CREATE INDEX idx_region ON customers (region);
CREATE INDEX idx_region_limit ON customers (region, credit_limit);
-- The first index is not needed; the second covers it
Its write overhead outweighs its read benefit. On a high-write
table, an index that is used rarely but adds cost to every insert may not be
worth keeping. Compare the read time saved against the write time added, and if
the write cost is higher, the index is a net loss.
DROP INDEX idx_customer_name;
Before dropping, two things worth checking. First, confirm that no other object
depends on it — foreign keys, constraints, materialized views. Some databases
refuse the drop if something depends on the index; others drop it and break the
dependent. Second, run the queries that used the index and confirm they still
perform acceptably without it. Once it is dropped, restoring it means rebuilding,
which on a large table can take time.
The good news: dropping an index is reversible in the sense that you can recreate
it. Unlike dropping a column, no data is lost. If the drop turns out to be a
mistake, recreating the index restores the previous behaviour — it just takes a
bit of time on a large table.
A bitmap index stores one bit per row per distinct value. Instead of a pointer
per row, it uses a bitmap where each bit tells you whether that row has that
value.
Lookups are extremely fast because the database is just reading bits. A query
WHERE region = 'North' reads one bitmap and finds the matching rows
immediately. And when a query filters on multiple bitmap-indexed columns, the
bitmaps can be combined with bitwise operations — AND for "both conditions",
OR for "either condition" — which is dramatically faster than combining B-tree
lookups.
CREATE BITMAP INDEX idx_customer_region
ON customers (region);
Bitmap indexes are ideal when the column has low cardinality — few distinct
values. Gender, status flags, boolean columns, small category codes. In a data
warehouse with a hundred million rows and a status column with three values,
a bitmap index takes very little space and makes filtering by status very fast.
The reason they are not universal: writes. A bitmap index locks a whole block of
rows when a single row is updated. In an OLTP system where many sessions
concurrently insert and update rows, two writers touching different rows in the
same block block each other — even though they are not logically related.
That is why the guidance is: bitmap indexes for data warehouses and read-mostly
tables; B-tree indexes for OLTP workloads. Oracle is the database most associated
with bitmap indexes, and its documentation explicitly warns against using them
on tables that are updated frequently.
One thing not to confuse: some databases use "bitmap" internally as a join
algorithm — building temporary bitmaps during a query for a hash or merge join.
That is a completely different concept from a bitmap index. If you see "bitmap
join" in an execution plan, the table does not necessarily have a bitmap index.
Mistakes I Keep Seeing in Interviews
These come up repeatedly. None of them are exotic, and every one of them is easy to avoid
once you know it is coming.
Mistake one: indexing every column "just in case". Each index slows
every write and takes storage. Index the columns queries actually filter and join on, not
the columns that might be useful some day.
Mistake two: creating a composite index with the wrong column order.
(region, credit_limit) helps queries that filter on region, but
not queries that filter only on credit_limit. The leftmost column is the one
the index is primarily built around.
Mistake three: forgetting to index foreign key columns. The foreign key
constraint works without an index, so nothing complains. But every join or referential
check scans the whole table without one. Index every foreign key column.
Mistake four: assuming a simple index on a low-cardinality column will
help. On a status column with three values, the index returns a third of the table per
lookup. That is usually slower than a full scan. Use a composite or bitmap index instead.
Mistake five: never refreshing statistics after a bulk load. The
optimizer's estimates go stale the moment a million new rows land in the table. The plan
it picks after the load may be much worse than the plan it picked before.
Mistake six: using bitmap indexes on OLTP tables. They lock whole blocks
per row update, which serialises writes that should be independent. Reserve bitmap
indexes for warehouses and read-only data.
Follow-Up Questions Interviewers Ask
Getting the first answer right is only half the interview. What usually decides the
outcome is what happens next. Here is what to prepare for on the indexing topics.
After "What is an index?" — expect
"What is the trade-off of adding an index?"
"How do you know if a query is using an index?"
"How is an index different from a primary key?"
"Can you have too many indexes?"
After "What is a clustered index?" — expect
"How does it differ from a non-clustered index?"
"How many clustered indexes can a table have?"
"What happens if you update the clustered key column?"
"What is the equivalent in Oracle?"
After "What is a covering index?" — expect
"How is it different from a composite index?"
"What is the INCLUDE clause for?"
"When is a covering index not worth the storage?"
"How would you verify an index is being used as covering?"
After "What is cardinality?" — expect
"How does low cardinality affect index usefulness?"
"What is a bitmap index and when would you use one?"
"How does the optimizer use cardinality estimates?"
"What is the difference between cardinality and selectivity?"
After "How do you handle fragmentation?" — expect
"What is the difference between REBUILD and REORGANIZE?"
"What is a fill factor and when would you use a lower one?"
"How often should you rebuild indexes?"
"Can fragmentation be prevented rather than fixed?"
For every question above, prepare a second-level answer. The candidates who pass are not
the ones who memorised the first answer — they are the ones who kept going for another
thirty seconds and said something useful about why it matters.
Quick Quiz
Three questions to check whether the topics above have landed.
1. How many clustered indexes can a single table have?