Loading…
Loading…
Learn which PostgreSQL index types to use and when, from B-tree and GIN to partial and covering indexes, with real query performance benchmarks.
Tags
This is part of the AI Automation Engineer Roadmap series.
Use B-tree for equality/range queries, GIN for full-text and JSONB, and partial indexes when you only query a subset of rows.
You've added indexes to your PostgreSQL tables, but queries are still slow. The default B-tree index doesn't help with JSONB searches or full-text queries, and indexing every column wastes disk space and slows down writes. You need to match your index type to your actual query patterns.
Indexes are one of the highest-leverage performance tools in PostgreSQL, but only when they match how the application actually queries data.
The wrong indexing strategy creates two problems at once:
That is why "add an index" is not enough. The real question is: which index, on which columns, for which query pattern?
Before choosing an index, look at the actual workload:
The index should support the query shape you already have. If you choose the type first and the workload second, you usually end up with waste.
B-tree -- the default, ideal for equality and range queries:
-- Perfect for WHERE, ORDER BY, and JOIN conditions
CREATE INDEX idx_orders_created_at ON orders (created_at);
-- Composite index for multi-column queries (column order matters!)
CREATE INDEX idx_orders_user_status ON orders (user_id, status);B-tree is usually the right choice for:
WHERE email = ?WHERE created_at > ?ORDER BY created_at DESCIt is also where composite index design matters most.
PostgreSQL can only use a composite index efficiently when the leftmost columns align with the query pattern.
For example, this index:
CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);works well for queries like:
SELECT *
FROM orders
WHERE user_id = 42 AND status = 'paid'
ORDER BY created_at DESC;But it is much less useful for queries that only filter by status without user_id.
GIN -- designed for composite values like JSONB, arrays, and full-text:
-- Index JSONB columns for @>, ?, and ?| operators
CREATE INDEX idx_products_metadata ON products USING GIN (metadata);
-- Full-text search index
CREATE INDEX idx_articles_search ON articles USING GIN (
to_tsvector('english', title || ' ' || body)
);GIN is useful when one row contains many searchable terms or keys. Common examples:
It is not a universal replacement for B-tree. Use it when your query pattern is "contains" or "matches tokens," not when you just need normal equality lookups.
Partial indexes -- index only the rows you actually query:
-- Only index active orders (skip 90% of historical rows)
CREATE INDEX idx_active_orders ON orders (created_at)
WHERE status = 'active';
-- Only index non-null values
CREATE INDEX idx_users_email_verified ON users (email)
WHERE verified_at IS NOT NULL;Partial indexes are excellent when most rows are irrelevant to the application path that needs speed.
Good examples:
These indexes are smaller, faster to scan, and cheaper to maintain than indexing the entire table.
For some read-heavy queries, an index can do more than locate rows. It can also supply the selected columns so PostgreSQL avoids extra heap lookups.
CREATE INDEX idx_orders_user_status_covering
ON orders (user_id, status)
INCLUDE (total_amount, created_at);This can help when a query frequently selects a small set of non-filter columns along with its filter conditions.
EXPLAIN ANALYZE RelentlesslyCheck which indexes are actually being used:
EXPLAIN ANALYZE SELECT * FROM orders
WHERE user_id = 42 AND status = 'active';This is non-negotiable. PostgreSQL's planner may ignore an index if:
Never assume an index helps just because it exists.
This is a common overreaction to slow queries. It increases write cost and often still fails to support the actual query plan you need.
If the query filters by user_id, status, and sorts by created_at, a single well-ordered composite index is often better than several unrelated single-column indexes.
GIN is great for JSONB and search-like workloads. It is not the right default for ordinary relational filters.
Every insert, update, and delete has to maintain indexes. A heavily indexed table can become slower on writes even if reads improve.
The safest performance workflow looks like this:
EXPLAIN ANALYZEThat is much better than adding indexes based on intuition alone.
If you want a pragmatic default:
EXPLAIN ANALYZE before and afterB-tree indexes store sorted data in a balanced tree, making equality lookups and range scans O(log n). GIN (Generalized Inverted Index) builds an inverted index mapping each key or lexeme to the rows containing it, which is why it excels at "contains" operations on composite types. Partial indexes only store entries for rows matching the WHERE clause, so they're smaller, faster to scan, and cheaper to maintain on writes. Always use EXPLAIN ANALYZE to verify PostgreSQL is actually using your index -- the query planner may choose a sequential scan if the table is small or the query returns too many rows.
Good PostgreSQL indexing is not about collecting index types. It is about matching real query patterns with the cheapest structure that improves them. Start with the workload, not the feature list, and let EXPLAIN ANALYZE keep you honest.
Collaboration
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI: let's bring your vision to life.
Five essential built-in generic utility types in TypeScript that will save you hundreds of lines of code.
Generate dynamic Open Graph images in Next.js using the ImageResponse API with custom fonts, gradients, and data-driven content for social sharing.
Create reusable GitHub Actions workflows with inputs, secrets, and outputs to eliminate YAML duplication across repositories and teams efficiently.