PostgreSQL Index Types and Strategy

PostgreSQL index design starts with access patterns, not with a universal “add an index” instinct. The engine gives you several real index families, each built for a different shape of predicate or data distribution. The hardest mistake is importing SQL Server habits too literally: PostgreSQL tables are heaps, so indexes are always separate structures, and specialized families such as GIN, GiST, and BRIN matter much earlier than they do on many SQL Server estates.

Current Baseline In stoxx

PostgreSQL | current index surface | inspect what the database already uses

Read the live index inventory before expanding it

Run this at the start of any index design review. It is typically triggered by slow-query analysis or migration work. The query is read-only. Its purpose is to show the current index family choices already present in the database.

SELECT schemaname,
       tablename,
       indexname,
       indexdef
FROM pg_indexes
WHERE schemaname IN ('bronze','silver','gold')
ORDER BY schemaname, tablename, indexname
LIMIT 20;
schemanametablenameindexnameindexdef
bronzedim_countrydim_country_pkeyCREATE UNIQUE INDEX dim_country_pkey ON bronze.dim_country USING btree (country_name)
bronzetrading_calendartrading_calendar_pkeyCREATE UNIQUE INDEX trading_calendar_pkey ON bronze.trading_calendar USING btree (date, exchange_code)
silvereurostoxx50_ohlcveurostoxx50_ohlcv_pkeyCREATE UNIQUE INDEX eurostoxx50_ohlcv_pkey ON silver.eurostoxx50_ohlcv USING btree (id)

The live baseline is straightforward: the current design leans almost entirely on B-tree primary keys. That is normal for a small lab. It also means the chapter needs to show the other families explicitly rather than pretending the existing schema already uses them.

Choose The Right Index Family

PostgreSQL | family demo | create one table that can host multiple index families

Use a disposable table to compare definitions, not just theory

Use this when teaching or reviewing which family belongs to which predicate class. It is typically triggered by design discussions that use vague terms like “advanced index.” The demo is state-changing but isolated to temporary objects inside one transaction and rolled back. Its purpose is to show the real DDL shape for each family.

BEGIN;
 
CREATE TEMP TABLE note06_index_demo (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  symbol text NOT NULL,
  trade_date date NOT NULL,
  active boolean NOT NULL DEFAULT true,
  payload jsonb NOT NULL,
  valid_window daterange NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
 
CREATE INDEX note06_btree_idx
  ON note06_index_demo (symbol, trade_date DESC);
 
CREATE INDEX note06_partial_idx
  ON note06_index_demo (trade_date)
  WHERE active;
 
CREATE INDEX note06_expr_idx
  ON note06_index_demo ((lower(symbol)));
 
CREATE INDEX note06_gin_idx
  ON note06_index_demo USING gin (payload);
 
CREATE INDEX note06_gist_idx
  ON note06_index_demo USING gist (valid_window);
 
CREATE INDEX note06_brin_idx
  ON note06_index_demo USING brin (created_at);
 
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname LIKE 'pg_temp_%'
  AND tablename = 'note06_index_demo'
ORDER BY indexname;
 
ROLLBACK;
indexnameindexdef
note06_brin_idxCREATE INDEX note06_brin_idx ON pg_temp.note06_index_demo USING brin (created_at)
note06_btree_idxCREATE INDEX note06_btree_idx ON pg_temp.note06_index_demo USING btree (symbol, trade_date DESC)
note06_expr_idxCREATE INDEX note06_expr_idx ON pg_temp.note06_index_demo USING btree (lower(symbol))
note06_gin_idxCREATE INDEX note06_gin_idx ON pg_temp.note06_index_demo USING gin (payload)
note06_gist_idxCREATE INDEX note06_gist_idx ON pg_temp.note06_index_demo USING gist (valid_window)
note06_partial_idxCREATE INDEX note06_partial_idx ON pg_temp.note06_index_demo USING btree (trade_date) WHERE active

That one result set is the practical PostgreSQL index map:

FamilyBest fit
B-treeequality, range, order-by, and most standard OLTP predicates
Partialfiltered hot subset such as WHERE active
Expressionqueries that repeatedly apply the same deterministic function
GINcontainment and membership on documents, arrays, and full-text search
GiSTranges, geometric or proximity-style searches, and extensible operator classes
BRINvery large append-friendly tables where physical order tracks the predicate column

Design Rules

PostgreSQL | B-tree first, specialization second | keep the choice tied to predicates

Solve the query shape you actually have

Use these rules in design review:

If the query pattern is…Prefer…
equality and ordered range scansB-tree
mostly one hot subset of rowspartial B-tree
repeated function-wrapped predicate such as lower(email)expression index
JSONB containment or array membershipGIN
range overlap or exclusion semanticsGiST
huge time-ordered tables with coarse pruning opportunitiesBRIN

Strategy Warnings

PostgreSQL | indexing anti-patterns | avoid expensive structures without a query-shaped reason

Every index taxes writes, WAL, vacuum, and backups

Anti-patternWhy it hurts
adding indexes only because idx_scan = 0 feels suspiciousquiet tables can still need their primary keys and constraints
using GIN or GiST without the matching operator class needspecialized indexes are not prestige features
indexing function-wrapped predicates without stabilizing the exact expressionthe planner only uses the expression index when the predicate matches
choosing BRIN on data with poor physical localitya bad BRIN is cheap, but not useful

Practical Recommendation For stoxx

PostgreSQL | current recommendation | keep the baseline simple and add specialization deliberately

Expand from B-tree only where the workload proves the need

The current lab is still appropriately simple: mostly primary-key B-trees, very small tables, and modest data size. The next index work should be workload-shaped, not taxonomy-shaped:

Potential needCandidate family
case-insensitive lookups on text keysexpression B-tree
JSONB attribute containmentGIN
large date-ordered append tables at much bigger scaleBRIN
heavily queried active subsetpartial B-tree

Next: 07-postgresql-index-maintenance turns design into lifecycle operations: bloat detection, REINDEX, VACUUM, fillfactor, statistics refresh, and how PostgreSQL maintenance differs from SQL Server rebuild/reorganize habits.