SQLMentor // learn sql

Indexes

What Is an Index?

Imagine you're looking for the word "photosynthesis" in a biology textbook. You could start at page 1 and read every single page until you find it — that's a full table scan. Or you could flip to the back of the book, find "photosynthesis" in the alphabetical index, and jump directly to page 347 — that's an index scan.

A database index works exactly the same way. It's a separate data structure that the database maintains alongside a table. It stores a sorted copy of one or more column values, with pointers to the actual row locations. When you query with a WHERE clause or ORDER BY, the database can use the index to find the right rows in milliseconds instead of scanning every row in the table.

ℹ Indexes Are Automatic Maintenance
When you INSERT, UPDATE, or DELETE rows, the database automatically updates all indexes on that table. This is why indexes aren't "free" — they speed up reads but add overhead to writes. The art is knowing when the read benefit outweighs the write cost.

Why Indexes Matter for Performance

The difference between a query with and without an appropriate index can be staggering:

Scenario Without Index With Index
Find 1 employee by ID in 1M rows Scan all 1,000,000 rows Jump directly to 1 row
Find all orders for customer #5 Scan all orders Jump to customer's orders
Sort 500,000 products by price Sort entire dataset Read pre-sorted index
JOIN employees to departments Compare every combination Use index to match

A full table scan reads every row regardless of how many rows match your query. An index scan reads only the relevant rows. On a table with millions of rows, this is the difference between milliseconds and minutes.


B-Tree Index — The Default Index

The vast majority of indexes you'll ever create or use are B-Tree (Balanced Tree) indexes. This is the default when you write CREATE INDEX.

How a B-Tree Works (Conceptually)

Think of it as a sorted binary tree, like a phone directory sorted alphabetically:

                    [M]
                 /       \
            [G]               [T]
           /   \             /   \
        [C]    [J]        [P]    [W]
       / \    / \        / \    / \
     [A][D] [H][K]    [N][R] [U][Z]
  • The root and branch nodes store the sorted key values and pointers to child nodes
  • The leaf nodes store the actual index key values plus a rowid — a physical pointer to where the row lives in the table
  • To find "R", the database traverses just 3 comparisons: Root → T → P → R

A B-Tree index on a million-row table needs only about 20 comparisons to find a value — versus 1,000,000 for a full scan. The tree stays balanced automatically as data changes.

B-Tree Indexes Speed Up:

-- 1. WHERE clause equality
SELECT * FROM employees WHERE employee_id = 107;
-- Index on employee_id → direct lookup

-- 2. WHERE clause range queries
SELECT * FROM employees WHERE salary BETWEEN 50000 AND 80000;
-- Index on salary → scan the sorted range

-- 3. ORDER BY (reads the pre-sorted index)
SELECT first_name, last_name FROM employees ORDER BY last_name;
-- Index on last_name → already sorted, no sort operation needed

-- 4. JOIN conditions
SELECT e.first_name, d.department_name
FROM   employees e
JOIN   departments d ON e.department_id = d.department_id;
-- Index on e.department_id and/or d.department_id speeds up the join

Creating Indexes

Basic CREATE INDEX Syntax

-- Create a B-Tree index on a single column
CREATE INDEX idx_emp_last_name
ON employees (last_name);

-- Create an index on a column frequently used in WHERE and JOIN
CREATE INDEX idx_emp_department_id
ON employees (department_id);

-- Create an index on a column used for sorting
CREATE INDEX idx_emp_hire_date
ON employees (hire_date);

After creating the index, queries that filter, sort, or join on those columns may automatically use it — no query changes needed.

DROP INDEX

-- Remove an index (Oracle)
DROP INDEX idx_emp_last_name;

-- Remove an index (MySQL / PostgreSQL)
DROP INDEX idx_emp_last_name ON employees;

Unique Index

A unique index enforces uniqueness on a column (or combination of columns) — no two rows can have the same value. Creating a PRIMARY KEY or UNIQUE constraint automatically creates a unique index.

-- Explicitly create a unique index (alternative to UNIQUE constraint)
CREATE UNIQUE INDEX idx_emp_email
ON employees (email);

-- Now this INSERT would fail:
INSERT INTO employees (employee_id, email) VALUES (299, 'EXISTING_EMAIL');
-- ORA-00001: unique constraint violated
✓ Primary Keys Get Indexes Automatically
When you define a PRIMARY KEY constraint, the database automatically creates a unique index on those columns. You never need to manually create an index on your primary key column.

Composite (Multi-Column) Index

A composite index covers two or more columns. It's like an address book sorted first by last name, then by first name within each last name.

-- Composite index on last_name + first_name
CREATE INDEX idx_emp_name
ON employees (last_name, first_name);

The Left-Prefix Rule — Column Order Matters Enormously

A composite index on (last_name, first_name) can be used when you query:

  • WHERE last_name = 'King' ✓ (uses leftmost column)
  • WHERE last_name = 'King' AND first_name = 'Robert' ✓ (uses both columns)
  • WHERE first_name = 'Robert' ✗ (skips leftmost column — index NOT used)
  • WHERE last_name = 'King' AND salary > 50000 — partial use (only last_name portion used)
-- This query CAN use the composite index idx_emp_name (last_name, first_name)
SELECT * FROM employees
WHERE  last_name = 'Abel'               -- uses leftmost column ✓
AND    first_name = 'Ellen';            -- uses second column ✓

-- This query CANNOT use idx_emp_name
SELECT * FROM employees
WHERE  first_name = 'Ellen';            -- skips last_name (leftmost column) ✗
-- The database falls back to a full table scan
ℹ Design Composite Indexes Thoughtfully
Put the most selective column (the one that narrows down rows the most) first, unless the left-prefix rule forces a specific order based on your queries. A column used in equality conditions (`=`) should generally come before columns used in range conditions (`>`, `<`, `BETWEEN`).

Practical Composite Index Example

-- This query is very common: filter by department, sort by salary
SELECT first_name, last_name, salary
FROM   employees
WHERE  department_id = 60
ORDER BY salary DESC;

-- A composite index on (department_id, salary) serves both needs:
CREATE INDEX idx_emp_dept_salary
ON employees (department_id, salary);
-- WHERE department_id = 60 uses the first column
-- ORDER BY salary uses the second column (already sorted within dept 60)

Function-Based Index

A function-based index pre-computes the result of a function on a column and indexes that result. This is necessary when you query using a function on an indexed column.

-- Problem: this query cannot use a regular index on last_name
SELECT * FROM employees
WHERE UPPER(last_name) = 'KING';
-- UPPER() is applied to the column, breaking the index

-- Solution: create a function-based index on UPPER(last_name)
CREATE INDEX idx_emp_last_upper
ON employees (UPPER(last_name));

-- Now this query uses the index:
SELECT * FROM employees
WHERE UPPER(last_name) = 'KING';   -- matches the indexed expression ✓
-- Index on the month of hire_date (for monthly reports)
CREATE INDEX idx_emp_hire_month
ON employees (EXTRACT(MONTH FROM hire_date));

-- Uses the index:
SELECT * FROM employees
WHERE EXTRACT(MONTH FROM hire_date) = 3;   -- March hires

Partial / Filtered Index

A partial index (called a filtered index in SQL Server) indexes only a subset of rows — those matching a specific condition. This makes the index smaller and faster.

-- PostgreSQL: index only active employees (is_deleted = 0)
CREATE INDEX idx_active_employees
ON employees (last_name, first_name)
WHERE is_deleted = 0;

-- Oracle: partial indexes via invisible/filtered approach
-- (Oracle uses function-based or partitioned indexes for similar effect)

-- This query can use the partial index:
SELECT * FROM employees
WHERE  last_name  = 'King'
AND    is_deleted = 0;

Partial indexes are ideal for tables where most queries filter on a specific condition — like active records, unpaid invoices, or open orders.


Bitmap Index (Oracle — Low-Cardinality Columns)

A bitmap index stores a bitmap (a string of 0s and 1s) for each distinct value in a column. They're extremely efficient for columns with very few distinct values — called low-cardinality columns.

-- Good candidates for bitmap indexes:
-- gender: 'M', 'F' (2 values)
-- status: 'ACTIVE', 'INACTIVE', 'PENDING' (3 values)
-- region: 'NORTH', 'SOUTH', 'EAST', 'WEST' (4 values)

-- Oracle bitmap index syntax
CREATE BITMAP INDEX idx_emp_gender
ON employees (gender);

CREATE BITMAP INDEX idx_job_region
ON jobs (region);

How bitmaps work: If a column has the values 'ACTIVE' and 'INACTIVE', Oracle creates two bitmaps:

ACTIVE:   1 0 0 1 1 0 1 0 0 1 ...  (1 = row is ACTIVE)
INACTIVE: 0 1 1 0 0 1 0 1 1 0 ...  (1 = row is INACTIVE)

To find all ACTIVE employees in the NORTH region: bitwise AND the two bitmaps — extremely fast.

⚠ Bitmap Indexes and Write Performance
Bitmap indexes are designed for **data warehouse / read-heavy** workloads. In OLTP (Online Transaction Processing) systems with frequent INSERT/UPDATE/DELETE, bitmap indexes cause severe locking problems and should be avoided. Use B-Tree indexes for transactional tables.

When NOT to Use Indexes

Indexes have costs. Here's when to skip them:

Situation Reason to Skip Index
Small tables (< few thousand rows) Full scan is faster; index overhead isn't worth it
Columns rarely used in WHERE/JOIN/ORDER BY Index is built and maintained but never used
Columns with very low cardinality in OLTP B-Tree on 'M'/'F' isn't selective enough to help
Heavily updated columns Every UPDATE to the column must update the index too
Bulk load operations Drop indexes before bulk insert, recreate after
Columns that always appear inside functions in WHERE Function breaks the index unless it's function-based

How the Query Planner Decides to Use an Index

The database's query planner (also called the optimizer) decides whether to use an index by estimating costs. It considers:

  1. Table statistics — How many rows? How spread out are the values?
  2. Selectivity — What fraction of rows does the WHERE clause return?
  3. Index availability — Is there an index that matches the query?
  4. Cost estimate — Is using the index actually faster than a full scan?

The optimizer may ignore your index if it estimates the query will return more than ~10-20% of rows — at that point, a full scan is often faster due to the overhead of following index pointers.

-- The optimizer uses statistics to decide
-- If salary has values from 20K to 200K and you query:
SELECT * FROM employees WHERE salary > 10000;
-- This returns almost all rows → optimizer chooses FULL SCAN

SELECT * FROM employees WHERE salary > 190000;
-- This returns very few rows → optimizer chooses INDEX SCAN

Keeping Statistics Current

-- Oracle: gather statistics so the optimizer makes good decisions
EXEC DBMS_STATS.GATHER_TABLE_STATS('HR', 'EMPLOYEES');

-- Or gather for the entire schema:
EXEC DBMS_STATS.GATHER_SCHEMA_STATS('HR');

-- PostgreSQL:
ANALYZE employees;
⚠ Stale Statistics = Bad Plans
If table statistics are outdated (e.g., after a bulk load), the optimizer makes poor decisions — like choosing a full scan when an index would be perfect. Always gather statistics after significant data changes.

Covering Index — Eliminating Table Lookups

A covering index contains all the columns needed to satisfy a query — so the database never needs to touch the actual table at all. This is the fastest possible query execution.

-- Query: find name and salary for employees in department 60
SELECT first_name, last_name, salary
FROM   employees
WHERE  department_id = 60;

-- A covering index includes all columns in SELECT + WHERE:
CREATE INDEX idx_emp_dept_cover
ON employees (department_id, first_name, last_name, salary);
-- department_id → WHERE filter
-- first_name, last_name, salary → SELECT columns

-- The entire query is answered from the index; the table isn't touched
✓ Covering Index = Index-Only Scan
When the database can answer a query entirely from the index without accessing the table, it's called an "index-only scan" (PostgreSQL) or "fast full scan" (Oracle). This is significantly faster than a regular index scan because it avoids the random I/O of following rowid pointers to the table.

Index on Foreign Keys

Foreign key columns are extremely important to index, yet often forgotten. Without an index on the foreign key:

  1. Every DELETE from the parent table requires a full scan of the child table to check for dependent rows
  2. Every JOIN between parent and child tables does a full scan
-- employees.department_id references departments.department_id
-- Without an index on employees.department_id:
-- - DELETE FROM departments WHERE department_id = 60
--   requires scanning ALL employees to check for FK violation

-- Create the index:
CREATE INDEX idx_emp_department_id
ON employees (department_id);

-- Now FK checks and JOINs on department_id are fast

EXPLAIN — Verifying Index Usage

The EXPLAIN command shows you the execution plan — how the database intends to run your query. Use it to verify that your indexes are being used.

PostgreSQL EXPLAIN ANALYZE

-- EXPLAIN shows the plan; ANALYZE actually runs it and shows real times
EXPLAIN ANALYZE
SELECT first_name, last_name
FROM   employees
WHERE  last_name = 'King';

-- Sample output:
-- Index Scan using idx_emp_last_name on employees  (cost=0.14..8.16 rows=1 width=18)
--   Index Cond: ((last_name)::text = 'King'::text)
-- Planning Time: 0.5 ms
-- Execution Time: 0.1 ms

Key terms to look for:

Term Meaning
Seq Scan Full table scan — no index used
Index Scan Index used, then table rows fetched
Index Only Scan Covering index — table not accessed
Bitmap Index Scan Index used, results collected then fetched
Nested Loop Join method: for each row in outer, probe inner
Hash Join Join method: build hash table, probe it
Merge Join Join method: both sides sorted, merge together

Oracle EXPLAIN PLAN

-- Generate the execution plan (doesn't run the query)
EXPLAIN PLAN FOR
SELECT first_name, last_name
FROM   employees
WHERE  last_name = 'King';

-- View the plan:
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

-- Sample output:
-- PLAN_TABLE_OUTPUT
-- -----------------------------------------------------------------
-- Plan hash value: 1234567890
-- -----------------------------------------------------------------
-- | Id | Operation                   | Name              | Rows |
-- -----------------------------------------------------------------
-- |  0 | SELECT STATEMENT            |                   |    1 |
-- |  1 |  TABLE ACCESS BY INDEX ROWID| EMPLOYEES         |    1 |
-- |* 2 |   INDEX RANGE SCAN          | IDX_EMP_LAST_NAME |    1 |
-- -----------------------------------------------------------------

Common Index Mistakes

Mistake 1: Using a Function on an Indexed Column

-- Index on last_name EXISTS, but this query won't use it:
SELECT * FROM employees WHERE UPPER(last_name) = 'KING';
--                                  ^^^^^^
-- The function wrapping the column breaks the index

-- Fix option 1: function-based index
CREATE INDEX idx_emp_last_upper ON employees (UPPER(last_name));

-- Fix option 2: store data consistently (all uppercase) and query without function
SELECT * FROM employees WHERE last_name = 'KING';  -- if data stored as 'KING'

Mistake 2: Implicit Type Conversion

-- employee_id is NUMBER, but you pass a VARCHAR2
SELECT * FROM employees WHERE employee_id = '107';
--                                          ^^^^^ string
-- Oracle implicitly converts: TO_NUMBER('107')
-- This may or may not break the index depending on data type

-- Always match the data type of the column:
SELECT * FROM employees WHERE employee_id = 107;   -- NUMBER literal ✓

Mistake 3: Leading Wildcard in LIKE

-- Index on last_name, but leading wildcard makes it useless:
SELECT * FROM employees WHERE last_name LIKE '%ing';
--                                           ^
-- The % at the start means "any prefix" — can't use a sorted index

-- Trailing wildcard is fine (searches from the left):
SELECT * FROM employees WHERE last_name LIKE 'K%';   -- uses index ✓

-- For full-text search needs, use a full-text index instead

Mistake 4: OR on Different Indexed Columns

-- This may not efficiently use either index:
SELECT * FROM employees
WHERE  last_name = 'King'
OR     first_name = 'Steven';

-- Better: UNION (each branch can use its own index)
SELECT * FROM employees WHERE last_name = 'King'
UNION
SELECT * FROM employees WHERE first_name = 'Steven';

Index Summary

-- Quick reference: common index creation patterns

-- Standard B-Tree (most common)
CREATE INDEX idx_orders_customer ON orders (customer_id);

-- Unique (enforces no duplicates)
CREATE UNIQUE INDEX idx_emp_email ON employees (email);

-- Composite (column order matters — most selective first)
CREATE INDEX idx_orders_date_status ON orders (order_date, status);

-- Function-based (for queries with functions in WHERE)
CREATE INDEX idx_emp_lower_email ON employees (LOWER(email));

-- Covering (includes all SELECT columns to avoid table access)
CREATE INDEX idx_emp_dept_covering
ON employees (department_id, first_name, last_name, salary);

-- Bitmap (Oracle only — for low-cardinality columns in DW)
CREATE BITMAP INDEX idx_emp_status ON employees (status);
✓ Index Design Checklist
  1. Index every primary key (automatic) and foreign key (manual)
  2. Index columns frequently used in WHERE, JOIN ON, and ORDER BY
  3. Consider composite indexes for queries that always filter on multiple columns together
  4. Use function-based indexes when WHERE clauses apply functions to columns
  5. Verify index usage with EXPLAIN — don't assume the optimizer uses your index
  6. Keep statistics current after bulk data changes
  7. Monitor for unused indexes — they slow writes for no read benefit
  8. Avoid over-indexing — each index adds write overhead and storage cost

Common Errors

Error Cause Fix
ORA-00955 Index name already in use — duplicate index name within the schema Choose a unique name; check USER_INDEXES for existing names
ORA-01408 Such column list already indexed — creating an identical index that already exists Inspect USER_IND_COLUMNS; drop the duplicate or use the existing index
ORA-02429 Cannot drop index used for enforcement of unique/primary key Disable or drop the constraint first: ALTER TABLE t DROP PRIMARY KEY
ORA-08102 Index key not found — index is inconsistent with table data (corruption indicator) Rebuild: ALTER INDEX idx_name REBUILD; escalate if rebuild fails
ORA-01502 Index or partition of such index is in unusable state Rebuild the index: ALTER INDEX idx_name REBUILD [ONLINE]
ORA-00054 Resource busy — online index rebuild blocked by lock Use ALTER INDEX … REBUILD ONLINE to allow concurrent DML during rebuild

Interview Corner

IQ · Indexes
Why might Oracle choose a full table scan instead of using an existing index on a column?
▶ Show answer

Oracle's Cost-Based Optimizer (CBO) chooses a full table scan when it estimates the scan will be cheaper than an index access. Common reasons:

  1. Low selectivity — the indexed column has few distinct values (e.g. a GENDER column with 2 values). Fetching 50% of rows via an index means 50% of the table's blocks are visited anyway.
  2. Stale statisticsDBMS_STATS has not been run recently, so the optimizer makes poor cardinality estimates.
  3. Function applied to columnWHERE UPPER(last_name) = 'KING' cannot use a plain index on last_name; a function-based index is needed.
  4. Small table — for tables that fit in a few blocks, a full scan is faster than index lookup + row fetch.
  5. High-volume parallel scans — full scans can exploit parallel query; index scans are typically serial.

Diagnose with EXPLAIN PLAN and check the ACCESS predicates.

IQ · Indexes
What is the difference between a composite index on (A, B) and two separate single-column indexes on A and B?
▶ Show answer

A composite index on (A, B) stores entries sorted first by A, then by B within each A value. It benefits queries that:

  • Filter on A alone (leading column rule)
  • Filter on both A and B together
  • But not queries that filter on B alone (trailing column is not the leading key)

Two separate indexes on A and B each support single-column predicates. The optimizer can sometimes combine them via an INDEX MERGE (bitmap AND), but this is less efficient than a composite index for queries that always filter on both columns.

-- Composite (emp_dept_sal) covers:
WHERE department_id = 10                          -- leading column
WHERE department_id = 10 AND salary > 5000        -- both columns
-- NOT efficiently: WHERE salary > 5000 only      -- skip-scan may or may not apply

Choose a composite index when queries consistently filter on the same set of columns together; order the columns by most-selective first (or by which filters appear most often).

Related Topics

  • Performance — broad query tuning: statistics, hints, execution plan analysis
  • Explain Plan — reading TABLE ACCESS FULL vs INDEX RANGE SCAN in execution plans
  • DDL CommandsCREATE INDEX, ALTER INDEX REBUILD, index storage options
  • Partitioning — local vs global indexes on partitioned tables