Why an Index Exists
Picture a students table with two million rows and a query asking for exactly one of them: WHERE roll_number = 'CS2026114'. With no index the database has only one option. It reads row one and checks it, reads row two and checks it, and keeps going until it has examined all two million — because until it has seen the last row it cannot know whether another match is hiding there. That is a full table scan, and it is the thing indexes exist to avoid.
An index is a second structure, stored separately from the table, holding the values of one or more columns in sorted order with a pointer back to the full row. Almost every index you will ever create is a B-tree — a shallow, wide, sorted tree. Because it is sorted, the engine can throw away most of the remaining possibilities at each step instead of walking through them, so finding one value among two million takes a handful of page reads rather than two million row reads. It is the same reason you can find a word in a dictionary without reading the dictionary.
Sorted order buys three things, not one. Equality lookups land directly on the value. Range scans such as joined_on >= '2026-01-01' become one contiguous slice of the index that can be read in a single sweep. And an ORDER BY on an indexed column can sometimes be answered by simply walking the index in order, skipping the sort altogether.
None of it is free. The index is a real structure taking real space, and it has to stay correct, so every INSERT, every DELETE and every UPDATE that touches an indexed column must update the index as well as the row. Indexes make reads faster and writes slower. Every decision in this lesson is some version of that single trade.
-- With no index on roll_number, this reads every row in the table
SELECT name, branch FROM students WHERE roll_number = 'CS2026114';
-- Create one. Name it so anybody can tell what it covers.
CREATE INDEX idx_students_roll_number ON students(roll_number);
-- Same query, same result, a completely different amount of work
SELECT name, branch FROM students WHERE roll_number = 'CS2026114';
-- What indexes does this table already have?
SHOW INDEX FROM students; -- MySQL
-- PostgreSQL: SELECT * FROM pg_indexes WHERE tablename = 'students';
-- SQL Server: EXEC sp_helpindex 'students'; - You never mention an index in a query. There is no
USING INDEXclause in ordinary SQL — you create the index and the query planner decides for itself whether to use it. That is why adding or dropping an index changes how fast a query runs but never what it returns. The one exception isUNIQUE, which is a rule as well as an index and can make anINSERTfail.
Indexes You Already Have, and Ones You Must Add
Some indexes exist whether you asked for them or not. A PRIMARY KEY is always indexed. A UNIQUE constraint is implemented as a unique index, which is how the database checks the rule cheaply instead of scanning the table on every insert. So a lookup by id is already fast on day one, and adding your own index on the primary key achieves nothing except wasted space.
In MySQL's InnoDB engine the primary key is special in a further way: it is a clustered index, meaning the rows themselves are stored in primary-key order rather than sitting in a separate heap, and every other index on the table stores the primary key value as its pointer back to the row. The practical consequence is that a large primary key inflates every other index on that table. A four-byte INT costs almost nothing; a 36-character UUID stored as text is repeated inside every secondary index you create. PostgreSQL stores rows in a heap and does not work this way, which is one reason UUID keys hurt less there.
Foreign key columns are the ones people forget, and the behaviour genuinely differs by product. MySQL's InnoDB creates an index on a foreign key column automatically if you have not created one yourself. PostgreSQL does not — it requires an index on the column being referenced, which is normally the primary key already, and leaves the referencing column bare. That is why in PostgreSQL a join from orders back to students, or a delete of one student row that has to go looking for child rows, can be unexpectedly slow until you add the index by hand.
Beyond those, the candidates are the columns that actually appear in your WHERE clauses, your JOIN ... ON conditions and your ORDER BY clauses. Not the columns that look important on the diagram — the columns your real queries filter by. Index the workload you have, not the schema you drew.
-- These two are indexed for you
CREATE TABLE students (
id INT AUTO_INCREMENT PRIMARY KEY, -- indexed automatically
email VARCHAR(120) UNIQUE, -- indexed automatically
roll_number VARCHAR(20),
branch VARCHAR(40),
joined_on DATE
);
-- The foreign key column is the one that needs thought
CREATE TABLE orders (
id INT AUTO_INCREMENT PRIMARY KEY,
student_id INT,
ordered_at DATETIME,
amount DECIMAL(10,2),
FOREIGN KEY (student_id) REFERENCES students(id)
);
-- MySQL/InnoDB indexes student_id for you.
-- PostgreSQL does not, so there you would also write:
-- CREATE INDEX idx_orders_student_id ON orders(student_id);
-- Adding, and then removing, where the dialects diverge
CREATE INDEX idx_students_branch ON students(branch);
-- MySQL needs the table name in order to drop an index
DROP INDEX idx_students_branch ON students;
ALTER TABLE students DROP INDEX idx_students_branch; -- the same thing
-- PostgreSQL: DROP INDEX idx_students_branch;
-- SQL Server: DROP INDEX idx_students_branch ON students; - A
UNIQUEindex is a constraint first and a performance aid second. Creating one on a column that already contains duplicates fails with an error, which is irritating at the time and valuable in the long run — the database has just told you your data does not mean what you assumed it meant.
Composite Indexes and the Leftmost Prefix Rule
One index can cover several columns. CREATE INDEX idx_students_branch_joined ON students(branch, joined_on) builds a single structure sorted by branch first and, within each branch, by joined_on. It is a printed telephone directory: sorted by surname, and within each surname by first name.
That ordering produces the rule which decides whether a composite index is any use at all, the leftmost prefix rule. The index can serve a query filtering on branch, or on branch and joined_on together — but not one filtering on joined_on alone. Back in the directory: finding every Sharma is easy, and finding Ananya Sharma is easy, but finding everyone called Ananya regardless of surname means reading the whole book. The database is in precisely that position, and it will give up and scan.
Column order is therefore a design decision, not a formatting one. The working rule is: columns compared with = first, and the column used for a range (>, <, BETWEEN) last. For WHERE branch = 'CSE' AND joined_on >= '2026-01-01', an index on (branch, joined_on) narrows to one branch and then reads one contiguous run of dates. Reverse the two columns and the engine has to sweep every date since January and test the branch of each row it finds.
There is a bonus when the index happens to contain every column a query needs. The engine can answer from the index alone and never read the table rows at all. MySQL reports this in EXPLAIN as Using index; PostgreSQL calls it an index-only scan. It is also why a composite index on (branch, joined_on) makes a separate single-column index on (branch) redundant — the composite already begins with branch, so the smaller one is dead weight.
CREATE INDEX idx_students_branch_joined
ON students(branch, joined_on);
-- Uses the index: filters on the leftmost column
SELECT name FROM students WHERE branch = 'CSE';
-- Uses it fully: equality on the first column, range on the second
SELECT name FROM students
WHERE branch = 'CSE'
AND joined_on >= '2026-01-01';
-- Cannot use it: joined_on is not the leftmost column
SELECT name FROM students WHERE joined_on >= '2026-01-01';
-- That query needs an index of its own:
-- CREATE INDEX idx_students_joined ON students(joined_on);
-- Covering: every column the query needs lives inside the index,
-- so the table itself is never touched
SELECT branch, joined_on FROM students WHERE branch = 'CSE';
-- Redundant. The composite above already starts with branch.
-- CREATE INDEX idx_students_branch ON students(branch); - Three separate single-column indexes are not a substitute for one composite index. MySQL can sometimes combine two of them using a strategy called index merge, but that is a fallback rather than a plan — it reads two indexes and intersects the results, which is nearly always slower than one index built for the query. If a query consistently filters on two columns together, build the index that way.
What Silently Defeats an Index
The frustrating part of indexing is that a query which cannot use an index does not complain. It returns the correct answer, slowly, and gives you no hint that the index you carefully created is being ignored. Four patterns account for most of it.
A function wrapped around the column. WHERE YEAR(ordered_at) = 2026 cannot use an index on ordered_at, because the index holds dates while the query asks about years — the engine would have to compute YEAR() for every row to find out which qualify. The fix always has the same shape: move the work off the column and onto the constant, giving ordered_at >= '2026-01-01' AND ordered_at < '2027-01-01'. The same applies to LOWER(email), DATE(created_at) and any other function you are tempted to wrap around an indexed column.
A leading wildcard. LIKE 'An%' can use an index, because the index is sorted by the start of the string and the engine knows exactly where to begin reading. LIKE '%an%' cannot, because a match might begin at any position and the only way to be certain is to inspect every row. Search-as-you-type boxes are built on full-text indexes for this reason — FULLTEXT in MySQL, or a tsvector column with a GIN index in PostgreSQL — and not on LIKE '%...%'.
A type mismatch. If roll_number is a VARCHAR and you write WHERE roll_number = 20261140, MySQL does not reject it. It converts the column to a number for every row, which defeats the index and can also match rows you did not intend, since a string-to-number conversion stops at the first character that is not a digit. Quote literals that belong to text columns. The fourth pattern is low selectivity: an index on an is_active flag where ninety per cent of rows are active saves nothing, and the planner will sensibly ignore it in favour of a scan.
-- Function on the column: correct, and slow
SELECT id FROM orders WHERE YEAR(ordered_at) = 2026;
-- The same question, asked so the index can answer it
SELECT id FROM orders
WHERE ordered_at >= '2026-01-01'
AND ordered_at < '2027-01-01';
-- Still index-friendly, because the function is on the constant
SELECT id FROM orders
WHERE ordered_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY);
-- Prefix search: usable. Substring search: not usable.
SELECT name FROM students WHERE name LIKE 'An%';
SELECT name FROM students WHERE name LIKE '%an%';
-- Quote text values, even when they look like numbers
SELECT * FROM students WHERE roll_number = '20261140'; -- good
-- SELECT * FROM students WHERE roll_number = 20261140; -- silent scan
-- OR across two different columns often uses neither index.
-- Two indexed lookups combined with UNION frequently beat it:
SELECT id, name FROM students WHERE email = 'ananya@example.com'
UNION
SELECT id, name FROM students WHERE roll_number = 'CS2026114'; - Oracle does not store
NULLs in a plain B-tree index at all, soWHERE last_login IS NULLcannot use one there, while the same query can use an index in MySQL and PostgreSQL. If you learn indexing on one database, check this particular behaviour before assuming it carries across.
Reading EXPLAIN
EXPLAIN is how you stop guessing. Put it in front of a SELECT and the database describes the plan it intends to follow: which indexes it will use, in what order it will read the tables, and roughly how many rows it expects to touch. It does not execute the query, so it is safe to run anywhere.
In MySQL four columns carry most of the meaning. type describes how each table is reached, and the values run roughly from best to worst: const and eq_ref (a single row via a unique index), ref (several rows via an index), range (a slice of an index), index (the whole index read end to end) and ALL (the whole table read end to end). possible_keys lists the indexes that could have been used and key names the one actually chosen — a populated possible_keys next to a key of NULL means the planner looked at your index and decided against it. rows is the estimated number of rows examined, and Extra carries the annotations: Using index is the good one, while Using filesort and Using temporary mean work being done on the side.
Treat rows as an estimate, because that is exactly what it is. The planner reasons from statistics about how values are distributed in each column, and if those statistics are stale it will make confident, wrong decisions. After a large import, refresh them with ANALYZE TABLE students; in MySQL or ANALYZE students; in PostgreSQL.
One warning about method. Test against data of a realistic size. With five hundred rows in a table every query is instant and the planner will often ignore your index entirely — correctly, because reading five hundred rows costs less than jumping back and forth between an index and a table. Performance work done on a toy dataset teaches you nothing, and occasionally teaches you something false.
-- The plan, without running the query
EXPLAIN SELECT name FROM students WHERE branch = 'CSE';
-- MySQL 8.0.18 and later: actually run it and report real timings
-- EXPLAIN ANALYZE SELECT name FROM students WHERE branch = 'CSE';
-- A fuller, structured version (MySQL)
EXPLAIN FORMAT=JSON
SELECT s.name, o.amount
FROM students s
JOIN orders o ON o.student_id = s.id
WHERE s.branch = 'CSE';
-- Refresh the statistics the planner reasons from
ANALYZE TABLE students; -- MySQL
-- ANALYZE students; -- PostgreSQL
-- Which queries are even worth looking at? Let the server tell you.
-- MySQL slow query log:
-- SET GLOBAL slow_query_log = 'ON';
-- SET GLOBAL long_query_time = 1; -- seconds - PostgreSQL's
EXPLAINprints something that looks nothing like MySQL's: a tree of plan nodes with cost estimates, using terms such as Seq Scan, Index Scan and Bitmap Heap Scan. The concepts map across almost exactly — Seq Scan is MySQL'sALL— but the vocabulary does not. Note also thatEXPLAIN ANALYZEin PostgreSQL really does execute the statement, including anUPDATEorDELETE, so run those inside a transaction you can roll back.
The Cost Side, and When Not to Index
Now the other half of the trade. Every index has to be maintained. Inserting one row into a table carrying six indexes means writing the row and then updating six separate structures, so a generously indexed table is a table that writes slowly. This is why bulk imports are often done by dropping the indexes, loading the data and rebuilding them afterwards: building an index once over a finished table is far cheaper than maintaining it a million times during the load.
Indexes also compete for memory. The database keeps recently used pages in a cache, and pages belonging to an index nobody queries are pages unavailable to one everybody queries. An unused index is not neutral, it is pure cost. Both major databases will tell you which indexes are never used — sys.schema_unused_indexes in MySQL, pg_stat_user_indexes in PostgreSQL — and on a database you have inherited the answer is usually surprising.
Watch for redundancy too. An index on (branch) sitting alongside one on (branch, joined_on) is redundant, by the leftmost prefix rule. Tools that generate schemas for you are enthusiastic producers of near-duplicate indexes, so it is worth listing what actually exists rather than trusting what you believe you created.
When something is slow, work in this order. Measure, using EXPLAIN and the slow query log. Fix the query first — stop selecting columns you never use, take the function off the column, add a LIMIT. Then add the index the fixed query needs. Only then reconsider the schema. Adding indexes hopefully, one at a time, until something improves leaves you with a table that is slow to write to and nobody who remembers why.
-- A bulk load pattern: build the index once, at the end
-- ALTER TABLE orders DROP INDEX idx_orders_student_id;
-- ... load a few million rows ...
-- CREATE INDEX idx_orders_student_id ON orders(student_id);
-- Which indexes is nobody using? (MySQL 8.0, sys schema)
SELECT * FROM sys.schema_unused_indexes;
-- PostgreSQL: idx_scan counts how often the index was used
-- SELECT relname, indexrelname, idx_scan
-- FROM pg_stat_user_indexes
-- ORDER BY idx_scan;
-- Redundant pair: the composite already covers branch on its own
-- CREATE INDEX idx_students_branch ON students(branch);
-- CREATE INDEX idx_students_branch_joined ON students(branch, joined_on);
-- Fix the query before reaching for an index
SELECT id, name FROM students WHERE branch = 'CSE' LIMIT 50;
-- rather than
-- SELECT * FROM students WHERE branch = 'CSE'; - Creating an index on a large live table is not a free operation. MySQL 5.6 and later perform most index builds online, so reads and writes continue, though not every case qualifies. PostgreSQL's ordinary
CREATE INDEXtakes a lock that blocks writes for the whole build, which on a big table is a visible outage;CREATE INDEX CONCURRENTLYavoids that, at the cost of being slower and unable to run inside a transaction. Check which applies before running it on something people are using.
