PostgreSQL Indexing - Complete Notes

A practical and interview-focused guide to B-tree, Hash, GIN, GiST, SP-GiST and BRIN indexes, composite column order, partial and covering indexes, expression indexes, planner decisions and production-safe index design.

00. The indexing mental model

An index is an additional data structure that trades write work and storage for a faster way to find, order or validate rows. It is useful only when its structure and operators match a real query.

PostgreSQL tables store rows in a heap. The heap does not promise key order. Most indexes store indexable values plus a reference to a heap tuple, called a tuple identifier or TID. A normal index scan first navigates the index and then visits matching heap locations. This second step matters because PostgreSQL must read columns not stored in the index and check row visibility under MVCC.

Think of a textbook. The table is the full set of pages. A B-tree index is an alphabetized back index that points to exact pages. A GIN index is like a word-to-pages index. A BRIN index is a small note saying which page ranges may contain dates from each month. Every shortcut helps a different question, and every shortcut must be updated when the book changes.

Question Index design concern Typical evidence
Which rows are needed? Predicates, operators and selectivity WHERE and join conditions
In which order? B-tree key order and scan direction ORDER BY with LIMIT
Which values must be returned? Key columns and INCLUDE payload Select list and heap fetches
How many rows qualify? Index versus sequential scan cost Statistics and actual row counts
How often does the table change? Write amplification and index-only potential Insert, update, delete and vacuum rates
How large is the table? Index size, cache behavior and build impact pg_relation_size and workload measurements
The central rule

Design an index from a query shape, not from a list of columns that look important. Write the predicate, ordering and returned columns first. Then choose the smallest index whose access method and operator class support that shape. Finally, prove the result with a representative plan and runtime.

01. Index anatomy, syntax and cost

PostgreSQL indexes are secondary structures stored separately from the heap. They speed reads by reducing the locations examined, but the database must maintain them during writes.

CREATE INDEX essentials

Common forms
-- B-tree is the default access method.
CREATE INDEX orders_customer_ordered_idx
    ON orders (customer_id, ordered_at DESC);

-- Name the access method when it is not B-tree.
CREATE INDEX documents_search_idx
    ON documents USING GIN (search_vector);

-- Conditional, expression and payload features can be combined.
CREATE INDEX orders_open_customer_idx
    ON orders (customer_id, ordered_at DESC)
    INCLUDE (total)
    WHERE status = 'open';

An index belongs to one table and one schema. PostgreSQL generates a name if one is omitted, but an intentional name makes migrations, monitoring and incident work easier. A useful convention is table_purpose_idx. Constraint-owned indexes should normally be changed through the constraint rather than dropped independently.

Ordinary, bitmap and index-only access

Plan shape How it works Good fit
Index Scan Read an index entry, then fetch its heap tuple Few matching rows or useful index order
Bitmap Index + Heap Scan Collect matching heap locations, then visit heap pages together Moderate result set or combined indexes
Index Only Scan Return stored values when visibility can be proved cheaply Covered query on mostly all-visible heap pages
Sequential Scan Read table pages in physical order Large fraction of table or small table

A bitmap scan loses index ordering because it reorganizes work by heap page. It can combine several indexes with bitmap AND or OR. It may become lossy when its bitmap needs to summarize whole pages, so the heap scan can show Rows Removed by Index Recheck. Lossy here means "return candidates and verify them", not incorrect results.

The cost side of every index

  • Every insert adds index entries for the new row.
  • An update may add new entries to every affected index. Even an index whose key value is unchanged can prevent a heap-only tuple update when the update changes any column stored by that index.
  • Deletes leave entries that vacuum must later clean after tuple versions become removable.
  • Indexes consume disk, buffer cache, backup, restore, replication and WAL capacity.
  • More candidate indexes increase planning work and operational complexity.
  • Wide keys reduce fan-out, grow tree height sooner and move more bytes through memory.
Indexes are not free read acceleration

A duplicate or unused index is a permanent tax on writes. Before adding one, inspect existing indexes, including primary-key and unique-constraint indexes. Before removing one, observe a complete workload cycle and account for replicas, rare reports and constraint ownership.

02. Choosing an index type

The access method describes the physical search strategy. The operator class connects a data type and a set of operators to that strategy.

Type Best mental model Strong use cases Main limitation
B-tree Sorted search tree Equality, ranges, ordering, uniqueness Not an inverted index for multi-valued content
Hash Hash bucket to matching values Equality only No ranges or ordering
GIN Component value to containing rows Arrays, JSONB, full-text search Write-heavy and no index-only scans
GiST Extensible tree of bounding summaries Ranges, geometry, nearest neighbor, exclusion Behavior depends strongly on operator class
SP-GiST Partitioned search space Points, prefixes, networks, nearest neighbor Specialized operator-class support
BRIN Summary per physical block range Huge, physically correlated tables Lossy and ineffective without useful correlation

Do not ask which type is fastest in general. Ask which type supports the query's operator and data distribution at the lowest total workload cost. B-tree is the correct default for ordinary scalar equality and range lookups. Specialized data and predicates justify specialized indexes.

03. B-tree indexes

B-tree is PostgreSQL's default and most broadly useful index type. It keeps sortable keys in logical order and supports equality, ranges, null tests and ordered output.

Predicates B-tree can support

A compatible B-tree operator class normally supports <, <=, =, >= and >. PostgreSQL can translate constructs such as BETWEEN and many IN conditions into compatible searches. It can also use B-tree for IS NULL and IS NOT NULL.

Equality, range and top-N access
CREATE INDEX orders_customer_time_idx
    ON orders (customer_id, ordered_at DESC);

SELECT order_id, ordered_at
FROM orders
WHERE customer_id = 42
  AND ordered_at >= TIMESTAMPTZ '2026-07-01 00:00:00+00'
ORDER BY ordered_at DESC
LIMIT 20;

Here the equality on customer_id selects one contiguous key region. Within that region, rows are already ordered by descending ordered_at. PostgreSQL can stop after finding 20 qualifying entries instead of sorting every matching order.

Ordering, direction and null placement

A default B-tree key is ascending with nulls last. A backward scan reverses both direction and null placement. For one key, an ordinary index can therefore serve ascending and descending requests. Mixed ordering in a multicolumn query may require explicit key directions.

Mixed-direction ordering
CREATE INDEX leaderboard_region_score_idx
    ON leaderboard (region ASC, score DESC, player_id ASC);

SELECT player_id, score
FROM leaderboard
WHERE region = 'APAC'
ORDER BY score DESC, player_id ASC
LIMIT 100;

B-tree is the only built-in index method that can directly produce general sorted output. For a large portion of a table, a sequential scan plus sort may still cost less than many heap visits through the index. Ordering is especially valuable with a small LIMIT.

Text prefixes and collations

B-tree can support an anchored constant pattern such as name LIKE 'Sam%', but not a leading wildcard such as name LIKE '%Sam%'. With non-C collations, deterministic prefix matching can require text_pattern_ops, varchar_pattern_ops or bpchar_pattern_ops. A pattern-operator index may need to coexist with the default operator-class index when ordinary locale-aware comparisons are also important.

Prefix search under a non-C locale
CREATE INDEX customers_name_prefix_idx
    ON customers (full_name text_pattern_ops);

SELECT customer_id, full_name
FROM customers
WHERE full_name LIKE 'Som%';
B-tree deduplication is an implementation optimization

Modern PostgreSQL B-tree indexes can compact many duplicate key values into posting lists. This can reduce space for non-unique indexes with duplicates, but it does not turn a low-selectivity predicate into a cheap query. The planner still considers how many heap rows must be visited.

04. Hash indexes

Hash indexes store a hash code and support equality comparisons only. They do not support range predicates or ordered output.

Hash equality lookup
CREATE INDEX sessions_token_hash_idx
    ON sessions USING HASH (token_hash);

SELECT user_id, expires_at
FROM sessions
WHERE token_hash = $1;

PostgreSQL hash indexes are WAL-logged and crash-safe in supported modern releases. That historical concern is no longer a valid reason to reject them. The practical comparison is still usually against B-tree: B-tree also supports equality, can be unique, can order results, can support ranges and often performs very well.

Question B-tree Hash
Equality Yes Yes
Range and ordering Yes No
Unique index Yes No
Multicolumn key Yes No
Index-only scan Yes, when visibility permits No

Consider Hash only after measuring a narrow equality-only workload where its size or behavior is beneficial. Do not select it merely because the application calls a value a hash or token. The query operator, not the column's business name, determines index compatibility.

05. GIN inverted indexes

GIN maps component keys to the rows containing them. It is designed for values that hold many searchable elements, such as array members, JSONB keys and values, or full-text lexemes.

Arrays and full-text documents

Array containment and text search
CREATE INDEX articles_tags_idx
    ON articles USING GIN (tags);

SELECT article_id, title
FROM articles
WHERE tags @> ARRAY['postgresql', 'performance'];

CREATE INDEX articles_search_idx
    ON articles USING GIN (search_vector);

SELECT article_id, title
FROM articles
WHERE search_vector @@ websearch_to_tsquery('english', 'index tuning');

One indexed row can produce many GIN entries. This makes membership and containment efficient but makes inserts and updates more expensive than a simple scalar index. GIN entries generally do not reconstruct the complete original value, so GIN cannot provide index-only scans.

JSONB operator classes

Broad JSONB operations versus targeted containment
-- Default jsonb_ops supports key existence, containment and JSON path operators.
CREATE INDEX events_payload_ops_idx
    ON events USING GIN (payload);

SELECT event_id
FROM events
WHERE payload ? 'tenant_id';

-- jsonb_path_ops supports @>, @? and @@, and is often smaller for that workload.
CREATE INDEX events_payload_path_idx
    ON events USING GIN (payload jsonb_path_ops);

SELECT event_id
FROM events
WHERE payload @> '{"kind":"purchase","currency":"INR"}';

A whole-document GIN index is flexible, but it can be much larger than a targeted expression index. If queries repeatedly compare one scalar JSON path, extract that value with the same expression and index it using an appropriate type.

Typed scalar expression from JSONB
CREATE INDEX events_tenant_created_idx
    ON events (((payload ->> 'tenant_id')::bigint), created_at DESC);

SELECT event_id
FROM events
WHERE (payload ->> 'tenant_id')::bigint = 42
ORDER BY created_at DESC
LIMIT 50;

GIN write behavior and pending list

With the default fastupdate behavior, PostgreSQL can place new GIN entries into a pending list and merge them into the main structure later. This improves many writes, but searches must also inspect the pending list. A large pending list can slow reads, and the write that crosses its limit may perform cleanup work.

  • Keep autovacuum healthy so maintenance can move pending entries.
  • Measure write latency as well as read latency before adding broad GIN indexes.
  • Avoid indexing enormous unbounded documents without proven predicates.
  • Consider fastupdate = off only when consistent read behavior matters more than foreground update cost and measurements justify it.
GIN is not a faster B-tree

GIN answers "which rows contain this component?" It is usually the wrong structure for ordinary scalar ordering, range scans and top-N queries. Choose it because the operator class supports a multi-valued predicate, not because the column is JSONB or an array.

06. GiST and SP-GiST

GiST and SP-GiST are extensible indexing frameworks. Their capabilities come from the selected operator class, so the data type and operators matter more than the access-method name alone.

GiST: generalized search trees

GiST is a balanced tree framework commonly used for geometric relationships, range overlap, nearest-neighbor distance, text similarity through extensions and exclusion constraints. Internal keys can summarize or bound many child values. A search may return candidates whose exact predicates are rechecked against the heap, depending on the operator class.

Range overlap and nearest neighbor
CREATE INDEX bookings_during_idx
    ON bookings USING GIST (during);

SELECT booking_id
FROM bookings
WHERE during && tstzrange($1, $2, '[)');

CREATE INDEX places_location_idx
    ON places USING GIST (location);

SELECT place_id, name
FROM places
ORDER BY location <-> point '(77.59, 12.97)'
LIMIT 10;

Nearest-neighbor ordering is called a KNN-GiST search and depends on an operator class that declares a distance-ordering operator. An ordinary B-tree cannot answer multidimensional "closest" questions from independent latitude and longitude ordering.

Exclusion constraints

Prevent overlapping bookings per room
CREATE EXTENSION IF NOT EXISTS btree_gist;

ALTER TABLE bookings
ADD CONSTRAINT bookings_no_overlap
EXCLUDE USING GIST (
    room_id WITH =,
    during WITH &&
);

The constraint states that no two rows may have all listed comparisons true at once. Here, two bookings cannot share a room and overlap in time. The btree_gist extension supplies B-tree-like GiST behavior for the scalar room id so it can share one GiST structure with the range-overlap operator.

SP-GiST: space-partitioned search

SP-GiST supports non-balanced partitioning structures such as quadtrees, k-d trees and radix trees. Built-in operator classes cover selected point, range, network and text-prefix use cases. Like GiST, it can support nearest-neighbor search where the operator class provides distance ordering.

Network containment with SP-GiST
CREATE INDEX firewall_network_idx
    ON firewall_rules USING SPGIST (network inet_ops);

SELECT rule_id
FROM firewall_rules
WHERE network >>= inet '10.42.3.8';
GiST versus GIN is workload-specific

For full-text search, GIN is generally preferred for lookup speed while GiST can be smaller and supports additional situations through its extensible tree model. For ranges and geometry, GiST often provides the natural operators. Compare operator support, read shape, update rate, index size and recheck behavior rather than memorizing one global winner.

07. BRIN block-range indexes

BRIN stores a compact summary for each range of adjacent heap pages. It is powerful when column values correlate with physical row location, especially on very large append-oriented tables.

How BRIN skips work

A minmax BRIN summary can record the smallest and largest timestamp in each block range. For a time-window query, PostgreSQL discards ranges whose summaries cannot overlap the requested window. It reads every candidate heap page and rechecks its rows, so BRIN scans are deliberately lossy.

Append-oriented event history
CREATE INDEX events_created_brin_idx
    ON events USING BRIN (created_at)
    WITH (pages_per_range = 64, autosummarize = on);

SELECT COUNT(*)
FROM events
WHERE created_at >= TIMESTAMPTZ '2026-07-01 00:00:00+00'
  AND created_at <  TIMESTAMPTZ '2026-08-01 00:00:00+00';
Property Smaller pages_per_range Larger pages_per_range
Summary precision Higher Lower
Index size Larger Smaller
False-positive heap pages Usually fewer Usually more
Maintenance entries More ranges Fewer ranges

Correlation is the critical variable. An append-only created_at often follows heap order. A randomly assigned customer id usually does not. Updates that scatter old and new values across the heap make each summary wider, causing more candidate pages and less skipping.

Summarization and inspection

Vacuum summarizes unsummarized BRIN ranges. The optional autosummarize setting asks autovacuum to summarize newly filled ranges. Operators can also call brin_summarize_new_values(). An unsummarized range must be treated as possibly matching, so it cannot be skipped.

Check correlation before choosing BRIN
SELECT attname, correlation
FROM pg_stats
WHERE schemaname = 'public'
  AND tablename = 'events'
  AND attname = 'created_at';

SELECT brin_summarize_new_values('events_created_brin_idx');
BRIN decision rule

Choose BRIN when the table is large enough that a tiny summary index matters and qualifying values are physically clustered enough to skip many block ranges. Choose B-tree when precise point or narrow-range lookup is worth a much larger per-row index. Some workloads benefit from both for different queries, but prove that the extra write cost is justified.

08. Composite indexes and column order

A composite index is one ordered structure over several keys. In a B-tree, key order determines which query prefixes define a narrow scan and which conditions are merely checked during it.

The leftmost-prefix rule, accurately stated

For a multicolumn B-tree, equality constraints on leading keys, plus an inequality on the first following key without equality, reliably limit the scanned portion. Conditions farther right can still be checked in the index and save heap visits, but they may not shrink the contiguous range read.

Index on tenant, status and time
CREATE INDEX orders_tenant_status_time_idx
    ON orders (tenant_id, status, ordered_at DESC);

-- Excellent prefix: tenant equality, status equality, then time range.
WHERE tenant_id = 7
  AND status = 'paid'
  AND ordered_at >= $1

-- Useful prefix: tenant equality constrains a contiguous region.
WHERE tenant_id = 7

-- Usually cannot use the ordinary leading prefix efficiently.
WHERE ordered_at >= $1

PostgreSQL 18 can use B-tree skip scan in selected cases. For an index on (region, email) and a condition on email alone, the planner may repeatedly search one email value for each region. This is attractive only when the omitted leading column has few distinct values and most leaf pages can be skipped. Do not treat skip scan as permission to ignore deliberate key order.

A practical key-order method

  1. Put columns required to isolate the access domain first, such as tenant_id in a shared multi-tenant table.
  2. Within that domain, place frequently used equality keys before the main range key.
  3. Choose directions that satisfy important mixed ORDER BY patterns.
  4. Add a stable tiebreaker when keyset pagination needs deterministic ordering.
  5. Use INCLUDE for returned payload that should not affect search ordering.
  6. Verify the design against all important query shapes, not one example.
Stable keyset pagination
CREATE INDEX orders_tenant_feed_idx
    ON orders (tenant_id, ordered_at DESC, order_id DESC)
    INCLUDE (status, total);

SELECT order_id, ordered_at, status, total
FROM orders
WHERE tenant_id = $1
  AND (ordered_at, order_id) < ($2, $3)
ORDER BY ordered_at DESC, order_id DESC
LIMIT 50;

The "most selective column first" myth

Selectivity alone is not a universal ordering rule. If every important query constrains both leading keys with equality, either ordering can identify the same key combination. The meaningful differences are which single-key prefixes other queries need, where range predicates start, which ordering is required and whether tenant or security boundaries must always lead.

More columns are not automatically better

Very wide indexes cost storage and writes, reduce cache density and may duplicate narrower indexes. PostgreSQL supports many columns, but the official guidance is to use multicolumn indexes sparingly. Most useful designs have a small number of keys aligned to stable access paths.

09. Combining indexes and satisfying order

PostgreSQL can combine indexes through bitmap operations, but one well-designed composite B-tree often wins when filtering and ordered output must happen together.

Two single-column indexes can answer an AND
CREATE INDEX orders_customer_idx ON orders (customer_id);
CREATE INDEX orders_status_idx ON orders (status);

SELECT *
FROM orders
WHERE customer_id = 42
  AND status = 'paid';

The planner might use one index and filter, or bitmap-AND both. Bitmap combination is flexible, but it loses each index's ordering. If the workload repeatedly needs a customer's paid orders in newest-first order, (customer_id, status, ordered_at DESC) can filter and order in one scan.

Design Strength Tradeoff
Separate single-column indexes Flexible across independent predicates and OR conditions Bitmap work and no preserved order
Composite index Efficient stable query shape and ordering Prefix-dependent and more specialized
Both Covers independent and combined workloads Highest write and storage cost

Redundancy decisions require care. An index on (a, b) can often serve queries on a, so a separate index on a may be redundant. But different uniqueness, predicates, operator classes, sort orders, payload columns, access methods or workload behavior can make apparently overlapping indexes meaningfully different.

10. Partial indexes

A partial index contains only rows satisfying its predicate. It can be smaller, cheaper to update and more selective when queries repeatedly target a stable subset.

Index only active work
CREATE INDEX jobs_ready_idx
    ON jobs (priority DESC, run_at, job_id)
    WHERE status = 'ready';

SELECT job_id
FROM jobs
WHERE status = 'ready'
  AND run_at <= clock_timestamp()
ORDER BY priority DESC, run_at, job_id
LIMIT 100;

The planner can use a partial index only when it can prove at planning time that the query's conditions imply the index predicate. Exact textual matching is not always required, and simple mathematical implications can be recognized, but PostgreSQL is not a general theorem prover.

Parameterized-query trap

A generic parameter may not imply a fixed predicate
CREATE INDEX orders_unbilled_idx
    ON orders (ordered_at)
    WHERE billed IS NOT TRUE;

-- The fixed clause clearly implies the predicate.
SELECT * FROM orders
WHERE billed IS NOT TRUE
  AND ordered_at < $1;

-- A generic plan cannot assume what $1 will be.
SELECT * FROM orders
WHERE billed = $1
  AND ordered_at < $2;

The second query might execute with $1 = false, but a plan intended for arbitrary values cannot assume that. Keep the invariant subset literal in the query shape when a partial index depends on it, and confirm actual prepared-statement plans.

Conditional uniqueness

One active subscription per customer
CREATE UNIQUE INDEX subscriptions_one_active_idx
    ON subscriptions (customer_id)
    WHERE ended_at IS NULL;

This permits historical ended rows while enforcing one current row. It is an index-backed uniqueness rule, not a normal SQL unique constraint. Foreign keys generally cannot reference a partial unique index because it does not prove uniqueness across every table row.

Do not use many partial indexes as manual partitioning

A separate partial index for every category or customer gives the planner many predicates to consider and creates operational churn. Use one suitable full index, proper table partitioning or a deliberately bounded set of high-value partial indexes. Revisit a predicate when data distribution changes.

11. Covering indexes and INCLUDE

A covering index stores every column needed by a query. PostgreSQL may then use an index-only scan and avoid heap reads when MVCC visibility can be established from the visibility map.

Search key plus payload
CREATE INDEX orders_customer_cover_idx
    ON orders (customer_id, ordered_at DESC)
    INCLUDE (order_id, status, total);

SELECT order_id, ordered_at, status, total
FROM orders
WHERE customer_id = 42
ORDER BY ordered_at DESC
LIMIT 25;

customer_id and ordered_at are key columns: they guide search and order. The included columns are payload only. They do not change B-tree ordering and do not participate in uniqueness.

Uniqueness applies only to keys
CREATE UNIQUE INDEX users_email_cover_idx
    ON users (lower(email))
    INCLUDE (user_id, display_name);

-- lower(email) must be unique.
-- user_id and display_name do not expand the unique key.

Why "covering" does not guarantee index-only

Index entries do not hold tuple visibility information. PostgreSQL checks the visibility-map bit for each relevant heap page. If the page is all-visible, the index entry can be returned without visiting the heap. If not, the executor fetches the heap tuple to check visibility.

  • Frequently updated tables may have many pages that are not all-visible.
  • Vacuum helps set all-visible bits after old tuple versions no longer matter.
  • EXPLAIN (ANALYZE, BUFFERS) reports heap fetches for an index-only scan.
  • A plan named Index Only Scan can still perform heap fetches.
  • Wide included values make every matching index entry larger and can exceed tuple limits.
Be conservative with payload

Include narrow, frequently returned columns only for valuable read paths on tables likely to benefit from index-only access. Do not copy every select-list column into an index. If the heap must be visited anyway, payload duplication may add write and storage cost without a useful win.

12. Expression indexes, operator classes and collations

An expression index stores a computed key. It works when the query uses a compatible expression and the functions involved satisfy PostgreSQL's immutability requirements.

Case-insensitive lookup and uniqueness

Index the normalized expression
CREATE UNIQUE INDEX users_email_lower_uidx
    ON users (lower(email));

SELECT user_id
FROM users
WHERE lower(email) = lower($1);

The stored result is computed during inserts and non-HOT updates, not during each index search. That moves CPU work from reads to writes and consumes index space. The query must present an expression the planner can match. An index on lower(email) does not accelerate WHERE email = $1.

Immutability and stable meaning

Index expressions and partial-index predicates may use only immutable functions. PostgreSQL must trust that the same input always maps to the same indexed result. Session settings, time zones, current time and mutable lookup tables can violate that contract.

Use a stable typed expression
CREATE INDEX measurements_rounded_idx
    ON measurements ((round(value, 2)));

SELECT measurement_id
FROM measurements
WHERE round(value, 2) = 19.95;

Casts and implicit conversions also matter. If an application compares a text expression to a differently typed parameter, PostgreSQL may introduce a cast on the indexed side or choose an operator that is outside the index's operator class. Bind parameters with the intended database type and inspect the actual plan.

Operator classes and families

An operator class tells an access method how a data type's operators map to index behavior. One type can have several classes for different semantics, such as default JSONB GIN behavior versus jsonb_path_ops, or locale ordering versus text prefix matching.

Inspect available operator classes
SELECT am.amname, opc.opcname, opc.opcintype::regtype
FROM pg_opclass AS opc
JOIN pg_am AS am ON am.oid = opc.opcmethod
WHERE opc.opcname IN (
    'text_ops',
    'text_pattern_ops',
    'jsonb_ops',
    'jsonb_path_ops'
)
ORDER BY am.amname, opc.opcname;

Collation must match the query

A collatable index key uses a declared collation. A query with a different explicit collation cannot generally use that index for the incompatible ordering or comparison semantics. If an application needs multiple collation-specific orderings, it may need multiple indexes, each with a measured benefit.

Collation-specific index and query
CREATE INDEX customers_name_c_idx
    ON customers (full_name COLLATE "C");

SELECT customer_id, full_name
FROM customers
ORDER BY full_name COLLATE "C"
LIMIT 50;

13. Why PostgreSQL ignores an index

PostgreSQL does not prefer indexes by rule. The planner estimates the total cost of legal plans and chooses the cheapest. A sequential scan can be the correct plan even when an index exists.

1. Too many rows qualify

An index scan may perform many scattered heap reads. When a predicate returns a large fraction of the table, reading heap pages sequentially and filtering can cost less. Low-cardinality does not make a column unindexable, but a common value often has weak selectivity.

A common value may favor a sequential scan
CREATE INDEX users_active_idx ON users (active);

-- If 99 percent of users are active, the index may add work.
SELECT *
FROM users
WHERE active = true;

2. The table is small

If the entire table fits in a few pages, a sequential scan is simple and cheap. This is why tiny development fixtures often produce different plans from production-shaped data. Test with representative volume and distribution.

3. The predicate does not match the indexed expression

Function on the column changes the searchable expression
CREATE INDEX orders_ordered_at_idx ON orders (ordered_at);

-- The plain timestamp index does not directly search this derived date.
WHERE ordered_at::date = DATE '2026-07-23'

-- A half-open range uses the plain timestamp index and handles all instants that day.
WHERE ordered_at >= TIMESTAMPTZ '2026-07-23 00:00:00+00'
  AND ordered_at <  TIMESTAMPTZ '2026-07-24 00:00:00+00'

Alternatively, create an expression index if the precise derived expression is stable and common. Prefer half-open ranges for timestamps because they avoid fragile "end of day" values and align with ordinary ordered keys.

4. The operator or operator class is unsupported

  • A B-tree cannot accelerate LIKE '%middle%' with its ordinary scalar ordering.
  • A Hash index cannot support >, BETWEEN or ordering.
  • A JSONB jsonb_path_ops index does not support the key-existence ? operator.
  • A collation or data-type mismatch can select incompatible comparison semantics.
  • A custom operator needs membership in a suitable operator family to use that index path.

5. Composite key order does not fit

A condition on a trailing B-tree key without useful leading constraints may require reading most of the index. Skip scan can help only for favorable distinct counts. Add a correctly ordered index when the trailing-only query is important enough to justify it.

6. A partial predicate cannot be proven

If the query omits the index predicate, phrases it incompatibly, or hides the required value behind a generic parameter, the planner cannot assume that every needed row is in the partial index. The index is legally unusable, not merely expensive.

7. Statistics are stale or insufficient

The planner needs row-count, null-fraction, distinct-value, most-common-value and histogram estimates. If data changed substantially or important correlations are invisible, it can misestimate selectivity and compare the wrong costs.

Refresh and improve estimates deliberately
ANALYZE orders;

ALTER TABLE orders
ALTER COLUMN status SET STATISTICS 500;

CREATE STATISTICS orders_tenant_status_stats (dependencies, mcv)
ON tenant_id, status
FROM orders;

ANALYZE orders;

A higher per-column target stores more detailed statistics at added analysis and catalog cost. Extended statistics help the planner estimate related columns, but they do not create an access path. An index and statistics solve different problems.

8. Cost settings or cache assumptions differ from reality

Planner costs model sequential I/O, random I/O, CPU and expected caching. Lowering random_page_cost relative to seq_page_cost makes random index access look cheaper. effective_cache_size is an estimate used by the planner, not allocated memory.

Do not tune global cost constants to force one query

First fix query-index mismatch, data realism and statistics. Then compare estimated plans with measured I/O and time across the workload. Cost constants are system-wide averages, so a value chosen from one experiment can damage many other queries.

9. Prepared plans and parameter values differ

A custom plan can use a supplied value's selectivity. A generic prepared plan must be safe and reasonable across possible values. Skewed predicates can therefore use different plans in a literal query and a long-lived prepared statement. Inspect the plan through the same driver and parameter types used by the application.

10. Another plan is simply better

PostgreSQL may prefer a different index, bitmap combination, join-driven lookup, partition pruning or sequential scan. An index being unused in one plan does not make it broken. Compare total execution behavior, not whether a preferred index name appears.

A disciplined diagnostic workflow

  1. Capture the exact SQL, bound parameter types and representative values.
  2. Confirm the index is valid, ready and defined on the expected table or partition.
  3. Write the index keys, predicate, expressions, operator classes and collation explicitly.
  4. Check whether the query is legally compatible with those definitions.
  5. Run ANALYZE if statistics do not reflect current data.
  6. Use EXPLAIN (ANALYZE, BUFFERS) safely on a representative environment.
  7. Compare estimated versus actual rows at the earliest divergence.
  8. Measure the current plan and a plausible alternative, including buffers and repeated runs.
  9. Change query, index or statistics for a concrete reason, then measure again.
  10. Remove experimental indexes that do not improve the target workload.
Useful catalog checks
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
  AND tablename = 'orders'
ORDER BY indexname;

SELECT
    i.relname AS index_name,
    x.indisvalid,
    x.indisready,
    pg_size_pretty(pg_relation_size(i.oid)) AS size
FROM pg_index AS x
JOIN pg_class AS i ON i.oid = x.indexrelid
WHERE x.indrelid = 'public.orders'::regclass
ORDER BY i.relname;
Use enable_seqscan only as a diagnostic clue

Temporarily disabling a plan type in one test session can reveal whether an alternative is legal and what it costs. It is not a production hint and does not guarantee an index scan. PostgreSQL still uses a sequential scan if no valid alternative exists. Restore the setting and solve the underlying compatibility or estimation problem.

14. Production-safe index lifecycle

Building, monitoring and removing an index are operational changes. Treat lock duration, write amplification, failure cleanup and replica impact as part of the design.

Regular versus concurrent builds

Build Benefit Cost and restriction
CREATE INDEX One table scan and usually faster Blocks writes while building
CREATE INDEX CONCURRENTLY Allows normal inserts, updates and deletes More work, waits and scans; cannot run in a transaction block
Online-style build
CREATE INDEX CONCURRENTLY orders_customer_time_idx
    ON orders (customer_id, ordered_at DESC);

A concurrent build performs multiple phases and waits for transactions that could affect correctness. If it fails, it can leave an INVALID index that still consumes space and may add update overhead. Inspect the failure, then drop or rebuild the exact invalid index safely. Only one concurrent index build can run on a table at a time.

CREATE INDEX CONCURRENTLY has extra restrictions for partitioned tables. A common operational pattern is to build indexes concurrently on individual partitions and then create or attach the matching partitioned index metadata. Verify the procedure for the exact supported PostgreSQL version.

Rebuild and removal

Reduce write blocking for maintenance
REINDEX INDEX CONCURRENTLY orders_customer_time_idx;

DROP INDEX CONCURRENTLY orders_old_idx;

Concurrent variants reduce blocking but take longer and have command restrictions. A standard REINDEX or drop can be appropriate in maintenance windows. Never remove an index only because its scan counter is zero in a short observation window.

Usage and size monitoring

Read workload counters and sizes
SELECT
    s.schemaname,
    s.relname AS table_name,
    s.indexrelname AS index_name,
    s.idx_scan,
    s.idx_tup_read,
    s.idx_tup_fetch,
    pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size
FROM pg_stat_user_indexes AS s
ORDER BY pg_relation_size(s.indexrelid) DESC;

Statistics counters are cumulative since their reset and can be zero after restart, reset or failover. They also do not show every benefit: uniqueness enforcement, replica queries and rare period-end jobs can justify an index. Combine counters with query statistics, schema semantics and a full business workload cycle.

Vacuum, bloat and HOT

PostgreSQL updates create new tuple versions. Indexes accumulate references that vacuum can remove only when no relevant snapshot needs the old versions. Long transactions can delay cleanup. Repeated changes can produce table and index bloat when reusable space does not match future inserts.

A heap-only tuple, or HOT, update can avoid creating new index entries when no indexed column changes and the new tuple version fits on the same heap page. Adding an indexed or included column to a frequently updated attribute can remove this benefit. This is an important hidden cost of over-covering indexes.

  • Keep autovacuum healthy instead of scheduling reindexing as a substitute for vacuum.
  • Investigate long transactions and write patterns before treating bloat symptoms.
  • Use measured bloat tools and relation sizes, not a universal rebuild schedule.
  • Remember that VACUUM FULL rewrites and locks a table; it is not routine vacuum.

15. Design patterns, mistakes and performance review

Useful index patterns

Query shape Starting index Reason
Foreign-key child lookup (parent_id) Joins and parent changes find child rows
Tenant's newest records (tenant_id, created_at DESC, id DESC) Domain filter, order and stable pagination
Rare active subset Partial B-tree with fixed status predicate Smaller targeted structure
Case-insensitive identity Unique B-tree on lower(value) Fast lookup and normalized uniqueness
JSON containment GIN with chosen JSONB operator class Inverted lookup across components
Time-range scan on huge append table BRIN on time Tiny summaries skip old block ranges
Non-overlapping reservations GiST-backed exclusion constraint Database-enforced overlap rule

Common mistakes

Mistake Why it hurts Better action
Index every column Write, WAL, storage and planning overhead Index measured query shapes
Assume foreign keys create child indexes Child lookup and parent changes may scan Add only needed referencing-side indexes
Put range key before equality domain Later equality key may not narrow scan Align order to equality then range pattern
Use INCLUDE as extra search keys Payload does not guide navigation Use keys for predicates and order
Expect covering to guarantee heap-free reads MVCC visibility may require heap fetches Check visibility and actual heap fetches
Use one broad GIN for every JSON query Large write cost and mismatched scalar access Choose operator class or targeted expression
Force index use because it exists Sequential scan may be cheaper Compare measured total costs
Test on 100 uniform rows Plans do not represent production Use realistic volume, skew and correlation
Drop based on a short zero-scan window Rare reads or constraints may depend on it Observe a full cycle and inspect semantics

Index review checklist

  1. List the exact high-value queries, parameter types and workload frequency.
  2. Mark equality, range, join, ordering, grouping and returned columns.
  3. Inventory constraint-owned and manually created indexes.
  4. Check access method, operator class, collation, predicate and expression compatibility.
  5. Choose composite order from reusable prefixes and order requirements.
  6. Estimate index size and impact on the table's write path.
  7. Test representative data with warm and cold-cache awareness.
  8. Inspect estimated versus actual rows, buffers, heap fetches, rechecks and sort nodes.
  9. Deploy with a lock and failure plan appropriate to table size and traffic.
  10. Monitor the complete workload and remove only proven redundancy.

16. Practice problems

1. Fetch the newest 20 paid orders for one customer. What index would you test first?

Test (customer_id, status, ordered_at DESC, order_id DESC). Customer and status are equality keys, time and id provide deterministic newest-first order, and the scan can stop at 20. If paid rows are a small stable subset and the query always uses that literal condition, compare a partial index on (customer_id, ordered_at DESC, order_id DESC) where status is paid.

2. An index exists on created_at, but WHERE created_at::date = $1 does a sequential scan. Fix the query.

Use a typed half-open timestamp range from the start of the requested day to the start of the next day. This matches the plain ordered key and handles fractional seconds. Be explicit about the business time zone when converting a date to instants. If the exact date expression is the stable dominant access path, evaluate a matching immutable expression index.

3. A 2 TB append-only events table needs monthly time-range reports.

Measure physical correlation of event time. A BRIN index is a strong candidate because it is tiny and can skip old block ranges. Tune pages_per_range from measured precision, keep ranges summarized and compare buffers and recheck work. A B-tree may still be better for very narrow point lookups.

4. Search JSONB events by exact tenant id and order by creation time.

A whole-document GIN index does not provide scalar time ordering. Test a composite expression B-tree on the tenant-id extraction cast to its real type, followed by created_at DESC. Keep a GIN index only if separate containment or existence queries justify its write and storage cost.

5. A covering index still shows many Heap Fetches. Why?

The query's columns may all be stored, but pages modified recently might not have all-visible bits set. PostgreSQL must visit those heap tuples for MVCC visibility checks. Check vacuum health and update rate. Adding more payload columns cannot solve a visibility problem.

6. Is an index on active useful when nearly every row is active?

Usually not for fetching all active rows because the heap work remains large. A partial index on the rare inactive state may help queries for exceptions. A composite or partial index can still be useful if it supports another selective key, ordering, or a uniqueness rule. Measure the real query rather than judging the Boolean type alone.

7. Prevent overlapping room reservations.

Use a GiST-backed exclusion constraint combining equality on room id and overlap on a range column. The btree_gist extension can provide GiST equality semantics for the scalar room id. Define interval bounds carefully, such as half-open ranges so one booking can begin exactly when another ends.

8. Two single-column indexes exist for a common AND query. Add a composite index?

Compare actual plans. Bitmap-AND can work well for a moderate result set and independent query shapes. A composite index may win when both predicates are almost always combined, when one prefix is reusable, or when an ordered result must avoid sorting. Account for the third index's write and cache cost before keeping all three.

17. Interview Q&A

Q: Why can a sequential scan be faster than an index scan?

An index scan adds tree navigation and potentially scattered heap reads. When many rows qualify or the table is small, sequential page access plus filtering can cost less. PostgreSQL compares estimated total plan cost rather than following a rule that an index must be used.

Q: B-tree versus Hash?

Both can support equality. B-tree also supports ranges, sorted output, unique indexes, multicolumn keys and index-only scans. Hash supports equality only and is mainly worth considering after measurement of a specialized equality workload.

Q: GIN versus GiST?

GIN is an inverted index that maps component values such as lexemes or JSON keys to containing rows. GiST is an extensible tree framework for strategies such as ranges, geometry and nearest neighbor. Operator-class support and workload decide the choice. Neither is globally faster.

Q: When should BRIN be used?

On very large tables where an indexed value is well correlated with physical heap order. BRIN keeps small summaries per block range and skips ranges that cannot match. It is lossy, so candidate heap rows are rechecked. It performs poorly when values are randomly scattered.

Q: Explain composite B-tree column order.

Leading equalities and the first following range reliably restrict the scanned key region. Later conditions can be checked in the index but might not reduce how much is read. Choose order from reusable query prefixes, equality and range shape, required sorting and stable pagination. Skip scan can help selected missing-prefix cases but is not a universal replacement for correct order.

Q: Partial index versus partitioning?

A partial index omits rows outside one predicate while the table remains one relation. Partitioning divides table storage and supports pruning, lifecycle operations and per-partition structures. Do not create a large family of category partial indexes as a substitute for proper partitioning.

Q: What does INCLUDE do?

It stores non-key payload values in an index so covered queries may use index-only scans. Included columns do not guide search or ordering and do not participate in uniqueness. They increase index and write cost, and heap access can still be required for MVCC visibility.

Q: What is an expression index?

It stores the result of an immutable expression, such as lower(email). Queries using a compatible expression can search the stored result without recomputing it for each examined row. Writes pay the computation and maintenance cost. A unique expression index can enforce normalized uniqueness.

Q: Why might a partial index not be used by a prepared query?

PostgreSQL must prove that the query condition implies the index predicate at planning time. A generic parameter can represent values outside the partial subset, so the planner cannot assume all needed rows exist in the index. Keep the subset condition explicit and inspect the actual prepared plan.

Q: Index Scan versus Bitmap Index Scan?

An ordinary index scan follows matching entries and fetches heap rows, preserving useful B-tree order. A bitmap scan first builds a map of heap locations, possibly from multiple indexes, then visits heap pages together. It suits moderate result sets but loses index ordering and may require rechecks.

Q: What is an index-only scan?

It answers referenced values from an index without ordinary heap data access. The index type and stored columns must support it, and the visibility map must show relevant heap pages are all-visible or PostgreSQL still fetches heap tuples. Check Heap Fetches in the actual plan.

Q: Does PostgreSQL automatically index foreign keys?

It indexes the referenced primary or unique key, but it does not automatically create an index on the referencing columns. Add a child-side index when joins, parent updates or parent deletes need efficient lookup, considering whether an existing composite prefix already covers it.

Q: What is write amplification from indexes?

One logical row change can update several physical index structures, generate additional WAL, dirty more pages and create cleanup work. Wider and multi-valued indexes amplify this further. Indexes on changed columns can also prevent HOT updates.

Q: How do you prove an index improved a query?

Test the exact parameterized query on representative volume and distribution. Compare EXPLAIN (ANALYZE, BUFFERS), execution time across repeated runs, row-estimate accuracy, buffers, heap fetches, rechecks and sort work. Also measure index size and write impact.

18. One-screen cheat sheet

  • Start with: exact predicate, order, output and workload frequency.
  • B-tree: equality, range, null tests, ordering and uniqueness.
  • Hash: equality only; measure against B-tree.
  • GIN: component-to-row lookup for arrays, JSONB and full-text search.
  • GiST: extensible ranges, geometry, overlap and nearest neighbor.
  • SP-GiST: partitioned spaces such as points, prefixes and networks.
  • BRIN: tiny lossy summaries for huge physically correlated tables.
  • Composite B-tree: leading equalities, then main range and order keys.
  • Skip scan: possible with few distinct omitted-prefix values, not guaranteed.
  • Partial: query must imply the fixed index predicate at planning time.
  • INCLUDE: payload only; no search, ordering or uniqueness semantics.
  • Index-only: stored columns plus favorable visibility map; inspect heap fetches.
  • Expression: query must match an immutable stored expression.
  • Operator class: connects operators and data type to access method.
  • Ignored index: may be incompatible, too costly or based on bad estimates.
  • Low selectivity: many heap visits often favor a sequential scan.
  • Statistics: estimate rows; indexes provide access paths.
  • Bitmap scan: combines locations and groups heap access, but loses order.
  • CONCURRENTLY: reduces write blocking but adds time, work and restrictions.
  • Every index: costs writes, WAL, storage, cache and maintenance.

19. Official reference map

Last reviewed · July 2026 · part of knowledge-base