Back to Indexing
Q5Question 5 of 18

What is an index in SQL, the types (primary, unique, non-unique, composite, clustered vs non-clustered), and how to create one with `CREATE INDEX`

Key Concepts
  • An index is a sorted on-disk data structure, usually a B-tree, that enables logarithmic-time lookups instead of full table scans.
  • Common types include primary-key (unique, not-null, one per table), unique, non-unique, and composite (multi-column) indexes.
  • A clustered index stores the actual rows in key order so there is only one per table, while non-clustered indexes are separate structures pointing back to rows.
  • Every index costs extra disk space and slows writes because the database must maintain it on every INSERT, UPDATE, and DELETE.
  • You create an index with `CREATE INDEX name ON table(columns)` and verify it is used with `EXPLAIN ANALYZE`.
  • In InnoDB the primary key is the clustered index and every secondary index stores the primary key as its row pointer.
  • Index design should target the queries you actually run, confirmed by the execution plan, not added speculatively.

Definition

An index is a sorted shortcut that trades a little write cost and disk space for massively faster reads.
  • Index – An auxiliary on-disk data structure, usually a balanced B-tree, that stores a sorted copy of one or more column values together with pointers to the full rows.
  • Purpose – It lets the database locate matching rows in O(log n) time instead of scanning every row, at the cost of extra storage and slower writes.
  • Primary-key index – A unique, NOT NULL index that identifies each row; most engines make it the clustered index automatically.
  • Unique index – Enforces that no two rows share the same value in the indexed columns, protecting data integrity at the database layer.
  • Composite index – Spans multiple columns in a defined order and obeys the left-prefix rule for query matching.
  • Clustered vs non-clustered – A clustered index physically orders the rows by the key (one per table), while a non-clustered index is a separate structure that points back to the rows (many per table).

Think of the National Bank of India customer ledger as a giant book with ten million accounts. Without an index, finding Rajesh's account SAV2001 means reading every page from the first until you hit it – a full table scan. An index is like alphabetical thumb-tabs and a back-of-book index: a small sorted list of account numbers with the exact page where each one lives. Finding SAV2001 takes a few jumps instead of reading the whole book. A clustered index means the book itself is physically sorted by account number; a non-clustered index is a separate card box that says "SAV2001 is on page 4,502."

Understanding the Concepts

What is an index?

An index is a separate, sorted data structure that the database maintains alongside the table data. It stores a copy of the indexed column values in sorted order, together with a pointer (a row locator) back to the full row. When a query filters, joins, sorts, or groups by an indexed column, the database navigates the index instead of scanning every row.

In our National Bank of India system, the accounts table has millions of rows. Without an index on account_id, every lookup scans the entire table.

SQL
-- National Bank of India: accounts table
CREATE TABLE accounts (
    account_id   VARCHAR(20) PRIMARY KEY,
    customer_name VARCHAR(100) NOT NULL,
    pan          VARCHAR(10)  NOT NULL,
    branch_code  VARCHAR(10)  NOT NULL,
    balance      DECIMAL(15,2) NOT NULL DEFAULT 0.00,
    status       VARCHAR(10)  NOT NULL DEFAULT 'ACTIVE',
    created_at   TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

The PRIMARY KEY declaration automatically creates a B-tree index on account_id, so lookups by account number are logarithmic.

Why do indexes exist?

Indexes exist because sequential scanning becomes unacceptable as tables grow. A full scan of ten million rows reads every page on disk. An index narrows the read to a handful of pages.

SQL
-- Without an index on customer_name, this scans the entire table
SELECT account_id, balance
FROM   accounts
WHERE  customer_name = 'Rajesh Kumar';

-- EXPLAIN shows Seq Scan: cost = high, rows examined = 10,000,000

After creating an index, the same query becomes an index seek.

SQL
CREATE INDEX ix_accounts_name ON accounts(customer_name);

-- Now EXPLAIN shows Index Scan on ix_accounts_name: cost = low
SELECT account_id, balance
FROM   accounts
WHERE  customer_name = 'Rajesh Kumar';

What are the types of indexes?

There are five common types, and each serves a different purpose.

Primary-key index

Created automatically when you declare PRIMARY KEY. It enforces uniqueness and not-null. In InnoDB and SQL Server it is also the clustered index.

SQL
-- The PRIMARY KEY gives you a unique clustered B-tree on account_id for free
CREATE TABLE accounts (
    account_id VARCHAR(20) PRIMARY KEY,
    balance    DECIMAL(15,2) NOT NULL
);

Unique index

Enforces uniqueness on a non-primary-key column. At National Bank of India every customer has a PAN number that must be unique.

SQL
CREATE UNIQUE INDEX ux_accounts_pan ON accounts(pan);

-- Any duplicate PAN insert fails with a constraint violation
INSERT INTO accounts (account_id, customer_name, pan, branch_code, balance)
VALUES ('SAV3001', 'Priya Sharma', 'ABCDE1234F', 'MUM001', 50000.00);

-- This fails if PAN 'ABCDE1234F' already exists
INSERT INTO accounts (account_id, customer_name, pan, branch_code, balance)
VALUES ('SAV3002', 'Amit Verma', 'ABCDE1234F', 'DEL001', 30000.00);
-- ERROR: duplicate key value violates unique constraint "ux_accounts_pan"

Non-unique index

Allows duplicate values and exists purely for lookup speed. Many accounts share the same branch code.

SQL
CREATE INDEX ix_accounts_branch ON accounts(branch_code);

-- Speeds up branch-level queries without forbidding duplicates
SELECT account_id, customer_name, balance
FROM   accounts
WHERE  branch_code = 'MUM001';

Composite index

Covers more than one column in a defined order.

SQL
CREATE INDEX ix_txn_acct_date
    ON transactions(account_id, created_at);

-- Serves queries that filter by account and sort by date
SELECT txn_id, amount, created_at
FROM   transactions
WHERE  account_id = 'SAV2001'
ORDER BY created_at DESC
LIMIT 10;

Clustered vs non-clustered

A clustered index determines the physical row storage order. There can be only one per table. A non-clustered index is a separate B-tree with pointers back to the row.

| Feature | Clustered Index | Non-Clustered Index |
|---|---|---|
| Row storage | Rows live in the leaf nodes | Leaf holds key + row pointer |
| Count per table | Exactly one | Many |
| Extra lookup | None – leaf is the row | Bookmark lookup to fetch row |
| Range scan speed | Very fast – data is contiguous | Slower – random heap fetches |
| Example engine | InnoDB PK, SQL Server PK | PostgreSQL PK (heap-based) |

How is a clustered index different from a non-clustered index?

In InnoDB, the primary key is the clustered index. The leaf pages of the primary-key B-tree contain the actual row data. Reaching the key gives you the full row with no extra hop.

A non-clustered (secondary) index leaf holds only the indexed columns plus the primary key as a pointer. To get the remaining columns, the engine does a second traversal into the clustered index. This is the bookmark lookup.

SQL
-- Clustered index lookup: one traversal, full row returned
SELECT * FROM accounts WHERE account_id = 'SAV2001';
-- Plan: Index Scan using accounts_pkey (clustered)

-- Non-clustered index lookup: two traversals
SELECT * FROM accounts WHERE pan = 'ABCDE1234F';
-- Plan: Index Scan on ux_accounts_pan -> then fetch from clustered index

PostgreSQL differs: its primary key is a unique non-clustered index over a heap table. The CLUSTER command reorders the heap once, but the order drifts as rows change.

How do you create and drop indexes?

The basic syntax works across all major engines.

SQL
-- Create a non-unique index
CREATE INDEX ix_accounts_branch ON accounts(branch_code);

-- Create a unique index
CREATE UNIQUE INDEX ux_accounts_email ON accounts(email);

-- Create a composite index
CREATE INDEX ix_txn_acct_date ON transactions(account_id, created_at);

-- Drop an index
DROP INDEX ix_accounts_branch;

-- PostgreSQL: build without blocking writes on a busy table
CREATE INDEX CONCURRENTLY ix_txn_amount
    ON transactions(amount);

What are the trade-offs of indexing?

Every index has a cost. The database must update every relevant index on every INSERT, UPDATE, and DELETE. More indexes mean slower writes.

SQL
-- National Bank of India: transactions table with 3 indexes
CREATE TABLE transactions (
    txn_id      BIGSERIAL PRIMARY KEY,
    account_id  VARCHAR(20) NOT NULL,
    amount      DECIMAL(15,2) NOT NULL,
    txn_type    VARCHAR(10) NOT NULL,
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX ix_txn_acct_date ON transactions(account_id, created_at);
CREATE INDEX ix_txn_amount    ON transactions(amount);
CREATE INDEX ix_txn_type      ON transactions(txn_type);

-- Every INSERT now updates 4 B-trees (PK + 3 secondary indexes)
INSERT INTO transactions (account_id, amount, txn_type)
VALUES ('SAV2001', 15000.00, 'CREDIT');

| Benefit | Cost |
|---|---|
| Faster WHERE lookups | Slower INSERT, UPDATE, DELETE |
| Faster JOIN matching | Extra disk space per index |
| Faster ORDER BY, GROUP BY | Write amplification |
| MIN/MAX from index tips | Memory pressure from more pages |

What are common mistakes with indexes?

The most common mistake is creating indexes speculatively without checking whether the query actually uses them.

SQL
-- Wrong: index on a low-selectivity column
CREATE INDEX ix_accounts_status ON accounts(status);
-- 95% of accounts are 'ACTIVE', so the optimizer ignores this index

SELECT * FROM accounts WHERE status = 'ACTIVE';
-- EXPLAIN: Seq Scan (not Index Scan) because almost all rows match

-- Better: partial index on the rare value
CREATE INDEX ix_accounts_frozen
    ON accounts(account_id)
    WHERE status = 'FROZEN';

Another mistake is wrapping the indexed column in a function, which defeats the index.

SQL
-- Wrong: function on the indexed column
SELECT * FROM transactions
WHERE YEAR(created_at) = 2026;
-- EXPLAIN: Seq Scan – the B-tree is sorted by full timestamps, not extracted years

-- Correct: sargable range on the raw column
SELECT * FROM transactions
WHERE created_at >= '2026-01-01'
  AND created_at <  '2027-01-01';
-- EXPLAIN: Index Scan on ix_txn_acct_date

How do you verify an index is being used?

Never assume an index is used. Always check the execution plan.

SQL
-- PostgreSQL
EXPLAIN ANALYZE
SELECT txn_id, amount, created_at
FROM   transactions
WHERE  account_id = 'SAV2001'
  AND  created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 10;
TEXT
Expected output:
Limit (actual time=0.035..0.042 rows=10)
  -> Index Scan Backward using ix_txn_acct_date
       on transactions
       Index Cond: (account_id = 'SAV2001'
         AND created_at >= '2026-01-01')

If you see Seq Scan instead of Index Scan, investigate: a function on the column, stale statistics, low selectivity, or a type mismatch.

SQL
-- Refresh statistics after a large data load
ANALYZE transactions;

What should a senior engineer know?

A senior engineer should know these production-level details.

  • In InnoDB every secondary index stores the primary key as its row pointer, so a wide primary key (like a 36-character UUID string) bloats every secondary index.
  • PostgreSQL's primary key is a unique non-clustered index over a heap, unlike InnoDB where it is the clustered index.
  • Index-only (covering) scans avoid heap access entirely when all needed columns are in the index.
  • CREATE INDEX CONCURRENTLY in PostgreSQL builds the index without blocking writes on a busy table.
  • After large data loads, run ANALYZE to update statistics so the optimizer makes correct decisions.
JAVA
// com.bank.service.AccountLookupService
package com.bank.service;

import java.math.BigDecimal;
import java.sql.*;

public class AccountLookupService {

    // Uses the primary-key clustered index: one B-tree traversal
    public BigDecimal getBalance(Connection conn, String accountId)
            throws SQLException {
        String sql = "SELECT balance FROM accounts WHERE account_id = ?";
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, accountId); // match VARCHAR type
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    return rs.getBigDecimal("balance");
                }
                throw new IllegalArgumentException(
                    "Account not found: " + accountId);
            }
        }
    }
}

What are common interview follow-up questions?

  • What is the left-prefix rule for composite indexes?
  • When does the database ignore an index?
  • What is a covering index and when does it produce an index-only scan?
  • Why do random UUID primary keys cause index fragmentation?
  • How do you build an index on a busy production table without blocking writes?

Visual Overview

DIAGRAM
┌─────────────────────────────────────────────────┐
│              B-tree Index Structure              │
├─────────────────────────────────────────────────┤
│                                                 │
│                  ┌──────────┐                   │
│                  │ Root Page│                   │
│                  │ [M]      │                   │
│                  └────┬─────┘                   │
│              ┌────────┴────────┐                │
│              ▼                 ▼                │
│        ┌──────────┐     ┌──────────┐           │
│        │ Internal │     │ Internal │           │
│        │ [D] [H]  │     │ [R] [V]  │           │
│        └──┬──┬──┬─┘     └──┬──┬──┬─┘           │
│           ▼  ▼  ▼          ▼  ▼  ▼             │
│  ┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐   │
│  │Leaf1││Leaf2││Leaf3││Leaf4││Leaf5││Leaf6│   │
│  │A→C  ││D→G  ││H→L  ││M→Q  ││R→U  ││V→Z  │   │
│  └──┬──┘└──┬──┘└──┬──┘└──┬──┘└──┬──┘└──┬──┘   │
│     └──────┴──────┴──────┴──────┴──────┘       │
│         Leaves linked for range scans           │
│                                                 │
│  Clustered Index: Leaf = actual row data        │
│  Non-clustered:   Leaf = key + row pointer      │
└─────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────┐
│         Clustered vs Non-Clustered              │
├─────────────────────────────────────────────────┤
│                                                 │
│  Clustered (InnoDB PK):                         │
│  Index Leaf ──► [account_id | name | balance]   │
│                  (full row in the leaf)          │
│                                                 │
│  Non-Clustered (secondary index):               │
│  Index Leaf ──► [pan | account_id (PK)]         │
│                       │                         │
│                       ▼                         │
│              Clustered Index lookup              │
│              ──► [full row]                     │
│              (bookmark lookup)                  │
└─────────────────────────────────────────────────┘

Key Takeaway

An index is an auxiliary sorted data structure, almost always a B-tree, that lets the database find rows in logarithmic time instead of scanning the entire table. The trade-off is that every index costs extra disk space and slows down inserts, updates, and deletes because the index must be maintained, so you create indexes deliberately for the queries you actually run.

The main types are the primary-key index, which enforces uniqueness and not-null and is usually the clustered index that physically orders the rows; the unique index, which enforces uniqueness on a non-primary-key column like PAN; the non-unique index, which is purely for lookup speed and allows duplicates; and the composite index, which spans multiple columns in a specific order. The clustered versus non-clustered distinction is physical: a clustered index is the table itself sorted by the key, so there is only one, while non-clustered indexes are separate structures that point back to the rows, so you can have many.

At the National Bank of India our transactions table had grown past two hundred million rows and the "last ten transactions" screen took four seconds because the database scanned every row. Rajesh added a composite index on account id and created-at in that order, so the planner could seek straight to the customer's rows already sorted by date and stop after ten. The screen dropped to a few milliseconds. He also added a unique index on the PAN column so a bug in the application could never create two accounts with the same tax number, and a plain non-unique index on branch code for operations lookups.

The senior habit Pankaj always enforces is that you never assume an index is being used. You create it with CREATE INDEX, then you run EXPLAIN ANALYZE and confirm the plan says Index Scan rather than Sequential Scan, because indexing the wrong column order or wrapping a column in a function silently defeats the index.

Remember one sentence: an index is a sorted shortcut that trades a little write cost and disk space for massively faster reads.

---

Share
Use ← → arrow keys to navigate