Reading EXPLAIN ANALYZE: how to find the actual slow part of a query
Everyone runs EXPLAIN. Fewer people read the row estimates, which is where the actual answer usually is.
PostgreSQL's default B-tree index handles equality and range queries on ordered data. Use GIN for arrays, JSONB and full-text search, BRIN for very large naturally-ordered tables, and partial indexes when queries only ever touch a subset of rows. Column order in a composite index matters: put equality predicates first, then the range or sort column.
| Type | Use for | Example |
|---|---|---|
| B-tree (default) | Equality, ranges, ORDER BY | WHERE created_at > $1 |
| GIN | Arrays, JSONB containment, full-text | WHERE tags @> ARRAY['seo'] |
| GiST | Geometric, ranges, nearest-neighbour | Overlapping time ranges |
| BRIN | Huge tables with correlated physical order | Append-only event logs |
| Hash | Equality only | Rarely worth it over B-tree |
In practice, 90% of the indexes on a typical product database are B-tree, and the interesting decisions are about which columns and in what order rather than which type.
PostgreSQL can use a composite index for a query only if the query constrains a leading prefix of its columns.
CREATE INDEX idx_orders ON orders (customer_id, status, created_at);
-- Uses the index fully
SELECT * FROM orders WHERE customer_id = 42 AND status = 'open' ORDER BY created_at DESC;
-- Uses only the customer_id portion
SELECT * FROM orders WHERE customer_id = 42 AND created_at > now() - interval '7 days';
-- Cannot use this index at all
SELECT * FROM orders WHERE status = 'open';The rule: equality predicates first, in order of selectivity, then the column you range over or sort by last. Getting this wrong is the difference between an index scan and a sequential scan over ten million rows.
When your queries consistently filter to a small subset. If 98% of orders are completed and every dashboard query looks at open ones, indexing only the open rows produces an index a fiftieth of the size that fits comfortably in memory.
-- Only index the rows the application actually queries
CREATE INDEX idx_open_orders ON orders (created_at)
WHERE status IN ('open', 'processing');
-- Enforce uniqueness only among live records
CREATE UNIQUE INDEX idx_active_email ON users (lower(email))
WHERE deleted_at IS NULL;-- Indexes that have never been scanned
SELECT relname, indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND indexrelid NOT IN (SELECT conindid FROM pg_constraint)
ORDER BY pg_relation_size(indexrelid) DESC;One useful discipline: before adding an index, capture the query's EXPLAIN plan and timing. After adding it, capture them again. If the plan did not change, remove the index — you have added write cost for nothing.
There is no fixed number, but past roughly five or six on a write-heavy table the insert cost becomes noticeable. Every index must be justified by a query you actually run.
PostgreSQL indexes the referenced primary key but not the referencing column. Missing indexes on foreign key columns are a very common cause of slow deletes and joins.
An index that contains every column a query needs, using INCLUDE, so PostgreSQL can answer from the index alone without touching the table. Useful for hot read paths.
Use a GIN index for containment queries across the whole document, or a B-tree expression index on a specific extracted path if you always query the same key. The latter is much smaller.
Harshal Patel
Founder & Lead Engineer, ROVQIX
Harshal leads engineering at ROVQIX, where he has shipped production Next.js, Node.js and PostgreSQL systems for startups, SaaS teams and ecommerce brands. He writes about the trade-offs behind architecture decisions rather than the framework of the week.
ROVQIXdesigns and builds production web platforms — Next.js front ends, Node.js APIs and the infrastructure behind them. Tell us what you're building and we'll scope it with you.
Everyone runs EXPLAIN. Fewer people read the row estimates, which is where the actual answer usually is.
Every PostgreSQL connection is a process. Ten containers with a pool of twenty is two hundred processes, and your database was configured for one hundred.
The migration that takes your site down is almost never the complicated one. It is the ALTER TABLE that took a lock nobody expected.
No spam. Just the occasional case study and craft breakdown.