Snowflake World Tour hits your city

See how leading teams deploy agents at scale. Find a stop near you. Register free.

Indexes! They're the single biggest lever you have for Postgres performance. In our experience, when someone says "my queries are slow," the answer comes down to missing or unused indexes the vast majority of the time. The good news? Indexing in Postgres is straightforward once you understand a few core concepts.

This guide covers the main index types (B-tree, GIN, GiST, Hash, BRIN), how to read execution plans with EXPLAIN ANALYZE, and how to monitor index usage in production so you can find what's missing and drop what's dead weight.

How Postgres Scans Tables

Understanding scan types is essential to understanding how indexes help. When Postgres executes a query, it chooses a scan strategy based on table size, available indexes, and the selectivity of your WHERE clause.

  • Sequential Scan — reads every row in the table from start to finish. This is the default when no useful index exists or when most of the table will be returned anyway.
  • Index Scan — traverses the index tree to find matching entries, then follows pointers back to the heap (table) to fetch full rows. Best for highly selective queries returning a small percentage of rows.
  • Bitmap Index Scan — scans the index to build a bitmap of matching pages, then does a single pass over the heap reading only those pages. Efficient for medium-selectivity queries that return too many rows for a plain index scan but too few for a sequential scan.
  • Index Only Scan — satisfies the query entirely from the index without touching the heap. Requires a covering index that includes all columns the query needs.
  • Parallel Scan — distributes the scan across multiple worker processes. Postgres can parallelize sequential scans, index scans, and bitmap scans for large tables.
  • Parallel Index Scan — multiple workers traverse the index concurrently, with results gathered by the leader process.
postgres-scan-types

The query planner automatically picks the best strategy. Use EXPLAIN to see which scan type a query uses — and read on to learn how to create the indexes that unlock the faster scan types.

B-tree Indexes for Equality and Range Queries

A B-tree index is the default — when you run CREATE INDEX without specifying a type, you get a B-tree. It covers about 95% of indexing needs and supports equality (=), range (<, >, <=, >=, BETWEEN), IN, IS NULL, and sorting.

Let's start with the basics:

-- Create a simple B-tree index
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

Now let's see what this actually does for performance. Here's a query without the index:

EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 12345;
                                                    QUERY PLAN
-------------------------------------------------------------------------------------------------------------------
 Seq Scan on orders  (cost=0.00..18450.00 rows=52 width=96) (actual time=12.451..89.234 rows=48 loops=1)
   Filter: (customer_id = 12345)
   Rows Removed by Filter: 999952
 Planning Time: 0.085 ms
 Execution Time: 89.301 ms

That sequential scan is reading every single row in the table — about a million rows — just to find 48 matches. After creating the index:

EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 12345;
                                                          QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------
 Index Scan using idx_orders_customer_id on orders  (cost=0.42..56.84 rows=52 width=96) (actual time=0.028..0.142 rows=48 loops=1)
   Index Cond: (customer_id = 12345)
 Planning Time: 0.091 ms
 Execution Time: 0.168 ms

From 89ms to 0.17ms. That's a 500x improvement — and this is a relatively small table. On tables with tens of millions of rows, the difference between a seq scan and an index scan can be seconds versus microseconds.

Multi-Column Indexes

When your queries filter on multiple columns, a multi-column index can be dramatically more efficient than separate single-column indexes. But column order matters — Postgres can use the index for queries that filter on a leftmost prefix of the indexed columns.

-- Multi-column index
CREATE INDEX idx_orders_status_created ON orders(status, created_at);

This index helps these queries:

-- Uses the index (leftmost prefix: status)
SELECT * FROM orders WHERE status = 'pending';

-- Uses the index (both columns)
SELECT * FROM orders WHERE status = 'shipped' AND created_at > '2025-01-01';

-- Uses the index (leftmost prefix for filter, second column for range)
SELECT * FROM orders WHERE status = 'pending' AND created_at BETWEEN '2025-01-01' AND '2025-06-01';

But this query cannot use the index:

-- Does NOT use the index (missing leftmost column)
SELECT * FROM orders WHERE created_at > '2025-01-01';

Put the most selective column first — the column that narrows down the most rows. If status has 5 distinct values and created_at has millions, leading with status makes sense when you're always filtering by status.

UNIQUE Indexes and Covering Indexes

A UNIQUE constraint automatically creates a B-tree index — you get both data integrity and query performance:

-- This creates a unique B-tree index automatically
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);

-- Equivalent to:
CREATE UNIQUE INDEX users_email_unique ON users(email);

Covering indexes use INCLUDE to store additional columns in the index leaf pages. This enables index-only scans — Postgres can answer the query entirely from the index without touching the heap table at all:

-- Covering index: include columns needed by SELECT
CREATE INDEX idx_orders_covering ON orders(customer_id) INCLUDE (status, total_amount);
EXPLAIN ANALYZE SELECT status, total_amount FROM orders WHERE customer_id = 12345;
                                                             QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------
 Index Only Scan using idx_orders_covering on orders  (cost=0.42..52.10 rows=48 width=18) (actual time=0.024..0.089 rows=48 loops=1)
   Index Cond: (customer_id = 12345)
   Heap Fetches: 0
 Planning Time: 0.078 ms
 Execution Time: 0.112 ms

"Heap Fetches: 0" — Postgres answered the entire query from the index. This is the fastest possible path.

The Write Overhead Tradeoff

Every index adds overhead to writes. Each INSERT, UPDATE (of an indexed column), and DELETE must update every index on the table. For read-heavy workloads, more indexes are almost always worth it. For write-heavy tables, be more deliberate — each index you add slows down every write operation.

GIN, GiST, and Specialized Index Types

B-tree handles scalar comparisons beautifully, but what about searching inside complex data types — JSONB documents, arrays, full-text search vectors? That's where specialized index types come in.

GIN — Generalized Inverted Index

GIN indexes are built for values that contain multiple elements. Think of them like the index at the back of a book — they map each element to the rows that contain it.

Best for: JSONB containment (@>), key existence (?), array overlap (&&), full-text search (@@)

-- GIN index for JSONB containment queries
CREATE INDEX idx_products_metadata ON products USING gin(metadata);

Now JSONB containment queries become fast:

-- Find products with specific attributes (uses GIN index)
EXPLAIN ANALYZE
SELECT name, price FROM products
WHERE metadata @> '{"color": "blue", "size": "large"}';
                                                           QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on products  (cost=24.01..1205.42 rows=312 width=38) (actual time=0.198..1.245 rows=287 loops=1)
   Recheck Cond: (metadata @> '{"color": "blue", "size": "large"}'::jsonb)
   Heap Blocks: exact=215
   ->  Bitmap Index Scan on idx_products_metadata  (cost=0.00..23.93 rows=312 width=0) (actual time=0.152..0.152 rows=287 loops=1)
         Index Cond: (metadata @> '{"color": "blue", "size": "large"}'::jsonb)
 Planning Time: 0.112 ms
 Execution Time: 1.398 ms

For JSONB, you can also use jsonb_path_ops — a more compact operator class that only supports the @> containment operator but is smaller and faster for that specific use case:

-- Smaller, faster — but only supports @> containment
CREATE INDEX idx_products_metadata_pathops ON products USING gin(metadata jsonb_path_ops);

GIN for arrays works the same way:

-- Index an array column
CREATE INDEX idx_posts_tags ON posts USING gin(tags);

-- Query: find posts tagged with both 'postgres' and 'performance'
SELECT title FROM posts WHERE tags @> ARRAY['postgres', 'performance'];

GIN tradeoffs: GIN indexes are larger than B-tree indexes and slower to update (inserts are batched into a "pending list" and merged later). But for query speed on complex data types, they're unmatched.

GiST — Generalized Search Tree

GiST indexes handle geometric and range data — things where "overlaps" and "contains" and "nearest neighbor" are the important operations.

Best for: PostGIS spatial queries, range types (tsrange, int4range), full-text search (alternative to GIN), nearest-neighbor searches

-- GiST for range type queries
CREATE INDEX idx_reservations_during ON reservations USING gist(during);

-- Find overlapping reservations
SELECT * FROM reservations
WHERE during && tsrange('2025-03-01', '2025-03-07', '[]');

GiST supports nearest-neighbor ordering with the <-> operator, which GIN cannot do:

-- PostGIS: find the 5 nearest coffee shops
SELECT name, ST_Distance(location, ST_MakePoint(-122.4194, 37.7749)) AS dist
FROM shops
ORDER BY location <-> ST_MakePoint(-122.4194, 37.7749)
LIMIT 5;

Hash Indexes

Hash indexes support only equality (=) comparisons. Since B-tree also handles equality (plus ranges and sorting), hash indexes are rarely needed. The one niche advantage: hash indexes can be slightly smaller for very long keys (like UUIDs or long strings) where only equality matching is needed.

-- Hash index — rarely needed, B-tree usually better
CREATE INDEX idx_sessions_token ON sessions USING hash(session_token);

Choosing the Right Index Type

Index TypeBest ForOperators
B-treeEquality, range, sorting, most queries=, <, >, <=, >=, BETWEEN, IN
GINMulti-element values (JSONB, arrays, text search)@>, ?, ?&, `?
GiSTGeometric, range, nearest-neighbor&&, @>, <@, <->, <<, >>
HashEquality only (large keys)=

Using pg_stat_statements to Find Your Slowest Queries

pg_stat_statements is the best tool for figuring out where to add indexes. Instead of guessing which queries are slow, it gives you hard data — total execution time, call count, rows scanned, and buffer usage for every normalized query your application runs.

Enabling pg_stat_statements

pg_stat_statements must be loaded via shared_preload_libraries in postgresql.conf — this requires a server restart since it allocates shared memory at startup:

# postgresql.conf
shared_preload_libraries = 'pg_stat_statements'

After restarting Postgres, create the extension:

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

On Snowflake Postgres, pg_stat_statements is pre-loaded and ready to use — just run CREATE EXTENSION and you're set.

aside positive If you're running self-managed Postgres, adding to shared_preload_libraries requires a full server restart (pg_ctl restart or systemctl restart postgresql). A reload is not sufficient. Plan this during a maintenance window.

Finding Queries That Need Indexes

The highest-value queries to index are those consuming the most total server time. These are your candidates:

-- Top 10 queries by total execution time
SELECT
  left(query, 80) AS query_preview,
  calls,
  round(total_exec_time::numeric, 1) AS total_ms,
  round(mean_exec_time::numeric, 1) AS avg_ms,
  rows,
  round((shared_blks_read::numeric / nullif(shared_blks_hit + shared_blks_read, 0)) * 100, 1) AS cache_miss_pct
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

The cache_miss_pct column is key — a high value means the query is reading from disk rather than shared buffers, which is exactly what a good index fixes.

Identifying Sequential Scans in Your Heaviest Queries

Once you've found your slowest queries, run them through EXPLAIN ANALYZE to confirm they're doing sequential scans:

EXPLAIN (ANALYZE, BUFFERS) SELECT ... ; -- paste the slow query here

Look for Seq Scan nodes with high actual time and Rows Removed by Filter. A seq scan that filters out 99% of rows is screaming for an index on the filter column.

Queries With High Buffer Reads

Queries that read large amounts of data are prime indexing candidates — even if they're individually fast, they burn I/O:

-- Queries reading the most data (I/O heavy)
SELECT
  left(query, 80) AS query_preview,
  calls,
  shared_blks_read + shared_blks_hit AS total_buffers,
  round((shared_blks_read + shared_blks_hit)::numeric / nullif(calls, 0), 0) AS buffers_per_call,
  round(mean_exec_time::numeric, 1) AS avg_ms
FROM pg_stat_statements
WHERE calls > 100
ORDER BY (shared_blks_read + shared_blks_hit) DESC
LIMIT 10;

A query that reads 50,000 buffers per call is almost certainly doing a sequential scan on a large table. Add an index on the columns in its WHERE clause.

Resetting Statistics

After adding indexes, reset the statistics to measure the improvement with a clean slate:

-- Reset pg_stat_statements (requires superuser)
SELECT pg_stat_statements_reset();

Wait for normal traffic to accumulate, then check again — your previously slow queries should show dramatically lower total_exec_time and shared_blks_read values.

Partial Indexes, Expression Indexes, and BRIN

These three index variants let you build smarter, smaller, more targeted indexes. I wanted to share these because they're incredibly powerful but often overlooked.

Partial Indexes

A partial index only includes rows that match a WHERE condition. The result is a smaller, faster index that's tailored to your actual query patterns.

-- Only index active orders (not the 90% that are completed)
CREATE INDEX idx_orders_active ON orders(created_at)
WHERE status IN ('pending', 'processing', 'shipped');

This index is dramatically smaller than indexing all rows — if only 10% of orders are active, the index is roughly 10% the size. Queries that include the matching condition use it automatically:

EXPLAIN ANALYZE
SELECT * FROM orders
WHERE status = 'pending' AND created_at > '2025-06-01';
                                                            QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------
 Index Scan using idx_orders_active on orders  (cost=0.29..42.15 rows=18 width=96) (actual time=0.021..0.098 rows=15 loops=1)
   Index Cond: (created_at > '2025-06-01 00:00:00'::timestamp)
   Filter: (status = 'pending')
 Planning Time: 0.095 ms
 Execution Time: 0.128 ms

Other great use cases for partial indexes:

-- Soft deletes: only index non-deleted rows
CREATE INDEX idx_users_active_email ON users(email) WHERE deleted_at IS NULL;

-- Only index rows that need processing
CREATE INDEX idx_jobs_unprocessed ON jobs(priority, created_at) WHERE processed_at IS NULL;

Expression Indexes

When your queries apply a function to a column, a regular index on that column won't help — the planner can't match lower(email) to an index on email. You need an expression index:

-- Expression index for case-insensitive email lookups
CREATE INDEX idx_users_email_lower ON users(lower(email));

Now this query uses the index:

-- The expression in the query must match the index expression exactly
SELECT * FROM users WHERE lower(email) = 'alice@example.com';

Other practical expression indexes:

-- Index on a date extraction (for queries that group by month)
CREATE INDEX idx_orders_month ON orders(date_trunc('month', created_at));

-- Index on a JSONB field extracted as text
CREATE INDEX idx_products_brand ON products((metadata->>'brand'));

The query must use the exact same expression. WHERE lower(email) = 'x' uses idx_users_email_lower, but WHERE email = 'X' does not.

BRIN — Block Range INdex

BRIN indexes are tiny — they store summary information (min/max values) about ranges of physical table blocks rather than indexing individual rows. They work beautifully for columns whose values are naturally correlated with the physical order of the table.

The classic example: timestamps in an append-only table. Since rows are inserted chronologically, nearby rows on disk have similar timestamps.

-- BRIN index: tiny index for physically ordered data
CREATE INDEX idx_events_created_brin ON events USING brin(created_at);

How small is "tiny"? For a table with 100 million rows, a B-tree index on a timestamp might be 2 GB. The equivalent BRIN index? About 100 KB — roughly 20,000x smaller.

-- Compare index sizes
SELECT
  indexname,
  pg_size_pretty(pg_relation_size(indexname::regclass)) AS index_size
FROM pg_indexes
WHERE tablename = 'events' AND indexname LIKE '%created%';
          indexname           | index_size
-----------------------------+------------
 idx_events_created_btree    | 2142 MB
 idx_events_created_brin     | 104 kB

BRIN works well when:

  • Data is inserted in order (timestamps, auto-incrementing IDs)
  • The table is large (millions of rows)
  • Queries filter on ranges rather than exact values
  • You can tolerate slightly less precision (BRIN may scan a few extra blocks)

BRIN does not work well for columns with random physical ordering — if customer_id values are scattered randomly across the table, a BRIN index provides almost no filtering benefit.

When Postgres Won't Use Your Index

You've created the index. The query should be fast. But EXPLAIN shows a sequential scan anyway. What gives?

This is one of the most common frustrations in Postgres, and the answer is almost always rational — the query planner made a cost-based decision. Let's walk through the reasons.

The Table Is Too Small

For tables that fit in a handful of pages, a sequential scan is actually faster than an index scan. An index scan requires reading the index pages, then randomly seeking to heap pages. For a tiny table, just reading everything sequentially is cheaper.

-- A table with 200 rows: seq scan is correct behavior
EXPLAIN SELECT * FROM status_codes WHERE code = 'ACTIVE';
                          QUERY PLAN
--------------------------------------------------------------
 Seq Scan on status_codes  (cost=0.00..1.25 rows=1 width=24)
   Filter: (code = 'ACTIVE'::text)

This is fine. Don't worry about it.

Low Selectivity — Too Many Rows Match

If the planner estimates your query will return more than about 5-10% of the table, it often prefers a sequential scan. Random I/O (jumping between heap pages via index pointers) is expensive compared to sequential reads.

-- If 40% of orders have status = 'completed', the planner skips the index
EXPLAIN SELECT * FROM orders WHERE status = 'completed';
                             QUERY PLAN
---------------------------------------------------------------------
 Seq Scan on orders  (cost=0.00..18450.00 rows=412000 width=96)
   Filter: (status = 'completed'::text)

The planner is right here — reading 40% of the table via random index lookups would be slower than just scanning the whole thing.

Stale Statistics

The planner relies on table statistics (row counts, value distributions, most common values) to estimate selectivity. If those statistics are outdated, it makes bad choices.

-- After bulk loading data, update statistics
ANALYZE orders;

-- Check when statistics were last updated
SELECT
  schemaname || '.' || relname AS table_name,
  last_analyze,
  last_autoanalyze,
  n_live_tup
FROM pg_stat_user_tables
WHERE relname = 'orders';

If you've recently loaded or deleted a large portion of a table, run ANALYZE before worrying about missing index usage.

Type Mismatch

This is a subtle one. If the column is integer but you're comparing to a text value, Postgres may not be able to use the index:

-- Column is integer, but comparing to text — implicit cast may prevent index use
SELECT * FROM orders WHERE customer_id = '12345';

-- Fix: use the correct type
SELECT * FROM orders WHERE customer_id = 12345;

Functions on Indexed Columns

A plain index on email doesn't help a query that applies lower() to the column. The index stores raw email values — Postgres can't match a transformed expression against them.

-- This does NOT use an index on the email column
SELECT * FROM users WHERE lower(email) = 'alice@example.com';

-- Solution: create an expression index (covered in the previous section)
CREATE INDEX idx_users_email_lower ON users(lower(email));

Testing with enable_seqscan (Development Only)

When debugging, you can force Postgres to avoid sequential scans to see if an index could be used:

-- NEVER use this in production — testing only
SET enable_seqscan = off;
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'completed';
SET enable_seqscan = on;

If the plan changes to an index scan with this setting, it means the index exists but the planner calculated that a seq scan was cheaper for that query. The planner is usually right.

Monitoring Index Usage

Creating indexes is only half the job. In production, you need to know which indexes are being used (and earning their keep) and which are just consuming disk space and slowing down writes.

Finding Unused Indexes

Every index imposes a write penalty — it must be updated on every INSERT, UPDATE, and DELETE. An index that never gets scanned is pure overhead:

-- Find indexes that have never (or rarely) been used since last stats reset
SELECT
  schemaname || '.' || relname AS table_name,
  indexrelname AS index_name,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
  idx_scan AS times_used,
  idx_tup_read AS tuples_read,
  idx_tup_fetch AS tuples_fetched
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND indexrelname NOT LIKE '%_pkey'
  AND indexrelname NOT LIKE '%_unique%'
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 15;
     table_name     |        index_name         | index_size | times_used | tuples_read | tuples_fetched
--------------------+---------------------------+------------+------------+-------------+----------------
 public.events      | idx_events_source         | 892 MB     |          0 |           0 |              0
 public.orders      | idx_orders_old_status     | 214 MB     |          0 |           0 |              0
 public.audit_log   | idx_audit_deprecated_col  | 156 MB     |          0 |           0 |              0

Those indexes are costing you over 1 GB of storage and slowing down every write — with zero read benefit. Candidates for removal (after confirming no scheduled batch jobs use them).

Finding Missing Indexes

Postgres tracks sequential scans on tables. A table with many sequential scans and many rows is probably missing an index:

-- Tables that might benefit from additional indexes
SELECT
  schemaname || '.' || relname AS table_name,
  seq_scan,
  seq_tup_read,
  idx_scan,
  n_live_tup,
  pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
  round(seq_scan::numeric / nullif(seq_scan + idx_scan, 0) * 100, 1) AS seq_scan_pct
FROM pg_stat_user_tables
WHERE n_live_tup > 10000
  AND seq_scan > 100
ORDER BY seq_tup_read DESC
LIMIT 10;
     table_name     | seq_scan | seq_tup_read | idx_scan | n_live_tup | total_size | seq_scan_pct
--------------------+----------+--------------+----------+------------+------------+--------------
 public.events      |     8412 |  42058412000 |     1204 |    5012000 | 2.8 GB     |         87.5
 public.sessions    |     3201 |   1921600000 |    89421 |     600500 | 412 MB     |          3.5

That events table has 87.5% sequential scans — a strong signal that queries against it would benefit from better indexing. Look at pg_stat_statements or query logs to find the specific queries driving those scans.

Index Health Overview

A combined view that shows which indexes are working hard and which are dead weight:

-- Overall index health: what's being used, what's wasted
SELECT
  schemaname || '.' || relname AS table_name,
  indexrelname AS index_name,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
  idx_scan AS scans,
  CASE
    WHEN idx_scan = 0 THEN 'UNUSED'
    WHEN idx_scan < 10 THEN 'RARELY_USED'
    ELSE 'ACTIVE'
  END AS status
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20;

Remember to check when statistics were last reset — an "unused" index might just be unused since the last pg_stat_reset():

-- When were stats last reset?
SELECT stats_reset FROM pg_stat_bgwriter;

aside positive Snowflake Postgres Insights surfaces index health automatically — including unused indexes that waste write performance and missing indexes suggested by query patterns. Monitor through the Insights dashboard or query the pg_stat_user_indexes view directly.

Conclusion

Indexing is the highest-leverage optimization tool in Postgres. Start with B-tree indexes on your most-queried columns, use EXPLAIN ANALYZE to verify they're working, and monitor pg_stat_user_indexes to catch unused indexes before they accumulate. Remember: every index speeds up reads but slows down writes — the goal is having exactly the indexes your queries need, no more, no less.

Related Resources

Beginner-friendly guide to Postgres indexing fundamentals.


Deep dive into GIN indexes for JSONB data.


Strategies for indexing precomputed query results.


Complete reference for all index types and options.

Updated 2026-07-08

This content is provided as is, and is not maintained on an ongoing basis. It may be out of date with current Snowflake instances