The essentials

Quick reference

One focused task per row. Jump to the related section for complete, working examples.

UseSyntaxExamples
Create a B-tree indexCREATE INDEX orders_customer_idx ON orders (customer_id);View examples
Enforce uniquenessCREATE UNIQUE INDEX users_email_uidx ON users (email);View examples
Index several columnsCREATE INDEX orders_customer_created_idx ON orders (customer_id, created_at DESC);View examples
Include payload columnsCREATE INDEX orders_status_idx ON orders (status) INCLUDE (total);View examples
Index a subsetCREATE INDEX orders_open_idx ON orders (created_at) WHERE status = 'open';View examples
Index an expressionCREATE INDEX users_lower_email_idx ON users (lower(email));View examples
Index composite values with GINCREATE INDEX documents_tags_idx ON documents USING GIN (tags);View examples
Summarize ordered storage with BRINCREATE INDEX events_time_brin_idx ON events USING BRIN (occurred_at);View examples
Build without blocking writesCREATE INDEX CONCURRENTLY orders_customer_idx ON orders (customer_id);View examples
Drop with less write blockingDROP INDEX CONCURRENTLY orders_customer_idx;View examples
Inspect estimated planEXPLAIN SELECT * FROM orders WHERE customer_id = 42;View examples
Measure executionEXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 42;View examples
Return a machine-readable planEXPLAIN (FORMAT JSON) SELECT * FROM orders WHERE customer_id = 42;View examples
Refresh planner statisticsANALYZE orders;View examples
List table indexesSELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'orders';View examples

An index trades storage and write work for faster access to selected rows. Start from real query predicates and ordering, verify with EXPLAIN, and remove indexes that do not justify their maintenance cost. Plan output is evidence about one database state, not a permanent promise.

Step by step

Detailed examples

01

Index access paths that queries actually use

B-tree is the default and supports equality, range, and ordered access for compatible operator classes. A unique index also enforces a data rule, although a UNIQUE constraint is often clearer when uniqueness belongs to the schema. Every index consumes space and must be updated by writes, so duplicates and speculative indexes have a cost.

Index a foreign-key lookup and enforce normalized email
CREATE INDEX orders_customer_idx
  ON orders (customer_id);

CREATE UNIQUE INDEX users_email_uidx
  ON users (lower(email));
Back to quick reference ↑
02

Order multicolumn keys from query shape

A multicolumn B-tree is most effective when predicates constrain leading columns. Equality conditions on leading columns can be followed by range or ordering columns. PostgreSQL can sometimes scan or skip within other combinations, but an index beginning with customer_id is not a general substitute for one beginning with created_at.

Serve customer history in newest-first order
CREATE INDEX orders_customer_created_idx
  ON orders (customer_id, created_at DESC);

SELECT order_id, created_at, total
FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 20;
Back to quick reference ↑
03

Match specialized indexes to operators and data distribution

Expression indexes require query expressions the planner can match. Partial indexes save space and update work when a useful subset has a stable predicate, but parameterized or logically different predicates may not imply it. GIN works well for values containing multiple keys, while BRIN is compact and effective only when values correlate with physical block order.

Index active rows, normalized lookup, and time-correlated events
CREATE INDEX orders_open_created_idx
  ON orders (created_at)
  WHERE status = 'open';

CREATE INDEX users_lower_email_idx
  ON users (lower(email));

CREATE INDEX events_time_brin_idx
  ON events USING BRIN (occurred_at);
Back to quick reference ↑
04

Treat index-only scans as an optimization, not a guarantee

INCLUDE stores columns as payload without making them search keys or part of uniqueness. An index-only scan is possible only when all referenced columns are available from the index, and it is profitable when visibility-map state avoids many heap visits. Wide payload columns increase index size and can exceed index tuple limits.

Cover a status summary query
CREATE INDEX orders_status_cover_idx
  ON orders (status)
  INCLUDE (order_id, total);

SELECT order_id, total
FROM orders
WHERE status = 'open';
Back to quick reference ↑
05

Use concurrent builds deliberately in production

CREATE INDEX CONCURRENTLY avoids blocking normal inserts, updates, and deletes, but performs more work, takes longer, and cannot run inside a transaction block. A failed concurrent build can leave an invalid index that still adds update overhead. Check completion and validity before depending on it; concurrent drop has its own restrictions and supports one index at a time.

Build, verify, and later remove concurrently
CREATE INDEX CONCURRENTLY orders_customer_idx
  ON orders (customer_id);

SELECT i.relname, x.indisvalid, x.indisready
FROM pg_index AS x
JOIN pg_class AS i ON i.oid = x.indexrelid
WHERE i.relname = 'orders_customer_idx';

-- Run separately when removal is intended:
DROP INDEX CONCURRENTLY orders_customer_idx;
Back to quick reference ↑
06

Read plans from the outside inward

EXPLAIN shows a tree of plan nodes. Costs are planner units, not milliseconds; the first number is startup cost and the second is total cost if all rows are consumed. Estimated rows and width influence join and access choices. A sequential scan can be correct for a large fraction of a table or a small table even when an index exists.

Compare text and structured estimates
EXPLAIN
SELECT order_id, total
FROM orders
WHERE customer_id = 42;

EXPLAIN (FORMAT JSON)
SELECT order_id, total
FROM orders
WHERE customer_id = 42;
Back to quick reference ↑
07

Measure safely with EXPLAIN ANALYZE

ANALYZE executes the statement, so using it on INSERT, UPDATE, DELETE, or DDL changes data unless you deliberately contain and roll back the operation. Compare estimated rows with actual rows at each node, multiply per-loop values when interpreting repeated nodes, and use BUFFERS to distinguish cache and I/O behavior. Test with representative parameters and data.

Collect execution and buffer metrics
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT order_id, total
FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 20;
Back to quick reference ↑
08

Keep planner statistics representative

ANALYZE samples values and records distribution statistics used for estimates. Autovacuum normally runs it automatically, but bulk changes or skewed columns may require attention. A bad estimate does not automatically mean an index is missing; inspect data distribution, predicates, statistics targets, and parameter behavior first.

Refresh and inspect the catalog view
ANALYZE orders;

SELECT schemaname, indexname, indexdef
FROM pg_indexes
WHERE tablename = 'orders'
ORDER BY indexname;
Back to quick reference ↑

Sources and further reading

References

Authoritative documentation used to verify and expand this cheat sheet.

  1. PostgreSQL Global Development GroupPostgreSQL: Indexespostgresql.org
  2. PostgreSQL Global Development GroupPostgreSQL: Using EXPLAINpostgresql.org
  3. PostgreSQL Global Development GroupPostgreSQL: CREATE INDEXpostgresql.org
  4. PostgreSQL Global Development GroupPostgreSQL: Multicolumn Indexespostgresql.org
  5. PostgreSQL Global Development GroupPostgreSQL: Index-Only Scans and Covering Indexespostgresql.org

Help us improve

Found a typo or missing example?

Tell us what would make this cheat sheet clearer, more complete, or more useful.

Share feedback