Indexes
An index is a separate data structure that lets the database find rows by
the values of some columns without reading the whole table. The default type is
a B-tree: a sorted tree that answers =, <, >, BETWEEN and ORDER BY
in logarithmic time. Indexes speed up reads but cost disk space and slow down
writes: every INSERT, and every UPDATE of an indexed column, has to update
the indexes too. PRIMARY KEY and UNIQUE create an index automatically;
foreign key columns and other filtered columns need one created by hand. Whether
an index is actually used is shown by EXPLAIN.
Applies to: PostgreSQL 18.
Details
Kinds of Indexes
| Index | Example | Use for |
|---|---|---|
| Single column | CREATE INDEX ON orders (user_id) | Filters and joins on one column, foreign keys |
| Composite | CREATE INDEX ON orders (user_id, created_at) | Filter by the first column, sort or filter by the next one |
| Unique | CREATE UNIQUE INDEX ON users (email) | Enforces uniqueness like a UNIQUE constraint; can also be partial or on an expression |
| Partial | CREATE INDEX ON orders (created_at) WHERE status = 'pending' | Queries on a small subset of rows; the index stays small |
| Expression | CREATE INDEX ON users (lower(email)) | Queries that filter by the same expression |
| Covering | CREATE INDEX ON orders (user_id) INCLUDE (total) | Index-only scans: the answer is read from the index |
Index types other than B-tree:
| Type | Use for |
|---|---|
| GIN | jsonb (@>), arrays, full-text search |
| GiST | Geometry, ranges, nearest-neighbor search |
| BRIN | Huge tables whose rows are physically ordered, e.g. by time |
| Hash | Equality only; rarely better than B-tree |
Column Order in a Composite Index
An index on (user_id, created_at) is sorted by user_id, then by created_at
within each user_id, like a phone book sorted by last name, then first name.
So it helps:
WHERE user_id = 42;WHERE user_id = 42 ORDER BY created_at DESC LIMIT 10;- but usually not
WHERE created_at > …alone. (Since PostgreSQL 18, a skip scan can use it ifuser_idhas few distinct values.)
Put columns compared with = first and the range or sort column last.
When an Index Is Not Used
- The column is wrapped in a function:
WHERE lower(email) = …needs an expression index onlower(email). LIKE '%text'with a leading wildcard. A plain B-tree also skipsLIKE 'text%'unless the database uses theCcollation or the index was created withtext_pattern_ops.- The query returns a large share of the table: reading the table sequentially is cheaper than jumping through the index.
- The table is small, or the statistics are outdated (
ANALYZEupdates them).
Reading EXPLAIN
| Plan node | Meaning |
|---|---|
| Seq Scan | Reads the whole table |
| Index Scan | Finds rows through the index, then reads them from the table |
| Index Only Scan | Answers from the index; visits the table only for pages that VACUUM has not yet marked all-visible |
| Bitmap Heap Scan | Collects matching row locations from the index, then reads them in physical order |
EXPLAIN shows the plan and estimates; EXPLAIN ANALYZE also runs the query
and shows the actual time and row counts.
Example
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL
);
INSERT INTO orders (user_id, status, created_at)
SELECT i % 10000, CASE WHEN i % 100 = 0 THEN 'pending' ELSE 'done' END,
now() - i * interval '1 minute'
FROM generate_series(1, 200000) AS i;
ANALYZE orders;
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;
-- Seq Scan on orders … Rows Removed by Filter: 199980 … Execution Time: 3.5 ms
CREATE INDEX orders_user_id_created_at_idx ON orders (user_id, created_at);
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;
-- Bitmap Heap Scan on orders -> Bitmap Index Scan on orders_user_id_created_at_idx
-- … Execution Time: 0.08 ms
EXPLAIN SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 10;
-- Index Scan Backward using orders_user_id_created_at_idx: no separate sort
Follow-up Questions
- Why not index every column? Each index slows down writes, takes space and memory, and must be maintained; unused indexes are pure cost.
- How do you add an index to a large table in production?
CREATE INDEX CONCURRENTLY: it does not block writes, but takes longer and cannot run inside a transaction. - What is a clustered index? The table itself is stored as a B-tree ordered by
the clustered key: always the primary key in MySQL InnoDB, the primary key by
default in SQL Server. PostgreSQL tables are heaps; all indexes are separate,
and the one-time
CLUSTERcommand does not keep the order. - How do you find slow queries?
pg_stat_statementsfor the most expensive queries, thenEXPLAIN ANALYZEon them.
Common Mistakes
- Expecting an index on
(a, b)to help a query filtered only byb. - Forgetting indexes on foreign key columns.
- Adding an index for a query that returns most of the table.
- Filtering by
lower(column)orcolumn::textwith an index on the bare column.