Skip to content
Home » All Posts » How to Choose PostgreSQL Indexing Strategies: B-tree vs GIN vs BRIN

How to Choose PostgreSQL Indexing Strategies: B-tree vs GIN vs BRIN

Introduction: Why PostgreSQL Indexing Strategies Matter

Every time I tune a slow PostgreSQL query, I almost always end up looking at indexes first. The difference between a well-chosen index and a mismatched one can be the difference between millisecond responses and timeouts under real load. That’s why understanding PostgreSQL indexing strategies is not an academic exercise—it directly affects latency, hardware costs, and how far your system can scale before you need to throw more servers at it.

B-tree, GIN, and BRIN each shine in different scenarios: B-tree for classic OLTP lookups and ranges, GIN for complex search conditions (arrays, JSONB, full-text), and BRIN for massive, naturally ordered datasets like logs or events. In my experience, teams get into trouble when they default to B-tree for everything or bolt on GIN indexes without realizing the write and storage overhead. By learning when and why to choose each index type, you can design schemas and queries that stay fast as data grows instead of constantly firefighting performance issues.

Prerequisites and Setup: Environment for Testing PostgreSQL Indexes

When I compare PostgreSQL indexing strategies in real projects, I always start with a clean, reproducible environment. You don’t need anything fancy to follow along—just a recent PostgreSQL install, a basic schema, and a couple of command-line tools.

PostgreSQL Version and Tools

I recommend PostgreSQL 13 or later so you get mature BRIN and GIN behavior (I usually test on 14 or 15). Make sure you have:

  • PostgreSQL server running locally or in a dev container
  • psql for running SQL and inspecting query plans
  • EXPLAIN (ANALYZE, BUFFERS) enabled for measuring performance impact

From the terminal, I typically connect with:

psql -h localhost -U postgres -d demo_indexing

Sample Schema for B-tree, GIN, and BRIN

To make index behavior obvious, I like a single table that mixes common patterns: simple lookups, ranges, JSONB, and time-based data. Here’s a minimal schema you can create in your test database:

CREATE TABLE events (
    id           bigserial PRIMARY KEY,
    user_id      bigint NOT NULL,
    created_at   timestamptz NOT NULL,
    tags         text[],
    payload      jsonb
);

-- Seed with some test data (adjust rows as needed)
INSERT INTO events (user_id, created_at, tags, payload)
SELECT
    (random() * 100000)::bigint,
    NOW() - (random() * interval '365 days'),
    ARRAY['type_' || (1 + (random() * 10)::int)],
    jsonb_build_object('score', (random() * 1000)::int)
FROM generate_series(1, 1000000) AS s(i);

On this single table you can try B-tree on user_id, GIN on tags or payload, and BRIN on created_at, then compare query plans and timings using EXPLAIN ANALYZE

Prerequisites and Setup: Environment for Testing PostgreSQL Indexes - image 1

. This controlled setup has helped me quickly see when a given index type is a win—and when it just adds bloat and write overhead. For deeper background on interpreting query plans, see Using EXPLAIN – PostgreSQL official documentation.

Step 1: Start with B-tree as the Default PostgreSQL Index

In most applications I’ve worked on, B-tree indexes cover 80–90% of what we need. Before I even think about GIN or BRIN, I make sure my core queries are backed by the right B-tree indexes and that they’re actually being used.

Why B-tree Is the Baseline for Most Workloads

B-tree is PostgreSQL’s default index type, and it’s optimized for equality, range, and ordered queries. If you’re filtering by an ID, a foreign key, a timestamp range, or sorting on a column, a B-tree index is usually the right first choice. In my experience, developers sometimes overcomplicate PostgreSQL indexing strategies by jumping to “advanced” index types when a well-placed B-tree would solve the problem cleanly.

  • Great for: =, >, >=, <, <=, ORDER BY, LIMIT
  • Common fields: primary keys, foreign keys, timestamps, status flags, small enums
  • Downsides: not ideal for many-to-many search in arrays/JSON, or for massive sequential data when you only need coarse ranges

Creating Practical B-tree Indexes on the Sample Table

On the events table from our setup, here’s how I typically start. First, I index the columns that appear constantly in WHERE clauses and joins:

-- Equality and joins on user_id
CREATE INDEX idx_events_user_id
    ON events USING btree (user_id);

-- Time-based queries, ranges, and ORDER BY created_at
CREATE INDEX idx_events_created_at
    ON events USING btree (created_at);

-- Composite index for common patterns like
-- WHERE user_id = ? AND created_at >= ? ORDER BY created_at DESC
CREATE INDEX idx_events_user_created_at
    ON events USING btree (user_id, created_at DESC);

One thing I learned the hard way was to avoid creating redundant indexes that overlap heavily. For example, if all my queries on created_at also filter by user_id, the composite index may be enough and I can drop the single-column index after measuring.

Confirming B-tree Impact with EXPLAIN ANALYZE

I never assume an index is helping; I confirm it with EXPLAIN ANALYZE. Before creating an index, run the query and note the plan and timing. Then add the index and compare. Here’s a typical pattern I use:

-- Query we want to optimize
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM events
WHERE user_id = 12345
  AND created_at >= NOW() - interval '7 days'
ORDER BY created_at DESC
LIMIT 50;

Before the index, you’ll often see a Seq Scan over the whole table. After creating idx_events_user_created_at, rerun:

EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM events
WHERE user_id = 12345
  AND created_at >= NOW() - interval '7 days'
ORDER BY created_at DESC
LIMIT 50;

Now you should see an Index Scan on idx_events_user_created_at with far fewer rows read and a lower execution time. In my day-to-day tuning work, this tight loop—add a B-tree index, run EXPLAIN ANALYZE, compare plans—is the foundation. Only when a B-tree can’t support the access pattern efficiently do I move on to GIN or BRIN in my PostgreSQL indexing strategies.

Step 2: Use GIN Indexes for JSONB and Full-Text Search

Once I’ve squeezed as much as I can out of B-tree, the next question in my PostgreSQL indexing strategies is: do I have complex search needs—arrays, JSONB, or full-text? If the answer is yes, that’s where GIN (Generalized Inverted Index) becomes my go-to tool. Used in the right places, GIN turns otherwise painful scans into sub-second queries, but it comes with higher write and storage costs.

When GIN Outperforms B-tree

B-tree is great when each row maps to a single value per column. But as soon as I need to ask “which rows contain this element somewhere in an array or document,” B-tree falls apart. GIN shines for:

  • JSONB containment: payload -> 'key' = 'value' or payload @> '{"key":"value"}'
  • Array membership: tags @> ARRAY['type_3'] or 'type_3' = ANY(tags)
  • Full-text search: to_tsvector(... ) @@ to_tsquery(...)

In my experience, if you try to handle these patterns with plain B-tree or no index at all, PostgreSQL ends up scanning huge portions of the table. GIN, on the other hand, indexes the individual keys or tokens, so lookups become much more selective.

Creating GIN Indexes for JSONB and Text Search

On our events table, we can index both tags (an array) and payload (JSONB). I also often add a dedicated column for full-text search if the app needs it.

-- GIN index for array membership queries on tags
CREATE INDEX idx_events_tags_gin
    ON events USING gin (tags);

-- GIN index for JSONB containment queries on payload
CREATE INDEX idx_events_payload_gin
    ON events USING gin (payload jsonb_path_ops);

-- Optional: add a text column and GIN index for full-text search
ALTER TABLE events
    ADD COLUMN search_text tsvector GENERATED ALWAYS AS (
        to_tsvector('english', coalesce(payload->>'description', ''))
    ) STORED;

CREATE INDEX idx_events_search_text_gin
    ON events USING gin (search_text);

Here are some typical queries that benefit immediately from these GIN indexes:

-- Find events that contain a specific tag
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM events
WHERE tags @> ARRAY['type_3']
LIMIT 50;

-- JSONB containment: payload has {"score": 500}
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM events
WHERE payload @> '{"score": 500}'::jsonb
LIMIT 50;

-- Full-text search on derived tsvector
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at
FROM events
WHERE search_text @@ to_tsquery('english', 'error & timeout')
ORDER BY created_at DESC
LIMIT 20;

When I first introduced GIN in a JSON-heavy app, the performance jump on containment queries was dramatic—but so was the index size. Since then, I always keep an eye on pg_indexes_size and vacuum behavior when adding new GIN indexes

Step 2: Use GIN Indexes for JSONB and Full-Text Search - image 1

.

Comparing GIN and B-tree with EXPLAIN ANALYZE

To really understand whether GIN is pulling its weight, I compare query plans before and after adding the index, just like with B-tree. The key differences I look for in EXPLAIN ANALYZE are:

  • The node type (e.g., Bitmap Index Scan using idx_events_tags_gin instead of Seq Scan)
  • Estimated vs. actual row counts—GIN often cuts rows down massively for containment queries
  • Total execution time and buffer hits, to see the real-world impact

Here’s a simple pattern I use to compare:

-- Before GIN index on tags
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM events
WHERE tags @> ARRAY['type_3'];

-- After creating idx_events_tags_gin
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM events
WHERE tags @> ARRAY['type_3'];

You should see PostgreSQL switch to a Bitmap Index Scan on the GIN index with far fewer heap blocks read. That said, I treat GIN as a specialized tool: it’s incredible for JSONB and full-text, but I avoid using it where a simpler B-tree works. For more nuance on GIN behavior and tuning, including how it stores keys and handles updates, it’s worth diving into 65.4. GIN Indexes – PostgreSQL Official Documentation.

Step 3: Apply BRIN Indexes to Large Append-Only Tables

When tables start hitting tens or hundreds of millions of rows, my PostgreSQL indexing strategies change. At that scale, even well-designed B-tree indexes can become huge and expensive to maintain. That’s where BRIN (Block Range INdex) comes in—especially for append-only, time-series, or log-style data.

When BRIN Beats B-tree on Big Data

BRIN indexes don’t index every row; they index ranges of blocks and store summary information (like min/max values). This makes them tiny compared to B-tree, and surprisingly powerful when your data is naturally ordered. In my experience, BRIN is a great fit when:

  • The table is append-only or mostly insert-only (e.g., logs, events, metrics).
  • A column is correlated with physical storage order, usually a monotonically increasing timestamp or ID.
  • You run range queries like “last hour/day/week” over huge datasets.

On the flip side, BRIN is not ideal if the data is heavily updated, randomly distributed, or you need highly selective point lookups—B-tree still wins there.

Creating BRIN Indexes on Time-Series Columns

On our events table, created_at is a classic BRIN candidate: rows are inserted over time, so the table is roughly ordered by that column. Instead of a large B-tree index, I often use a BRIN for broad time filters:

-- Basic BRIN index on created_at
CREATE INDEX idx_events_created_at_brin
    ON events USING brin (created_at);

-- Optionally customize pages_per_range for very large tables
-- (smaller value = more precise, larger index; larger value = smaller index, less precise)
CREATE INDEX idx_events_created_at_brin_tuned
    ON events USING brin (created_at)
    WITH (pages_per_range = 64);

One thing I like about BRIN is how tiny the index remains even as the table explodes in size. On real log tables with billions of rows, I’ve seen BRIN stay small enough to fit comfortably in memory, while an equivalent B-tree would have been completely impractical.

Validating BRIN Performance with EXPLAIN ANALYZE

To see whether BRIN is helping, I run the same kind of EXPLAIN ANALYZE comparisons I use for B-tree and GIN, but I pay attention to slightly different cues. Here’s a typical query pattern for recent data:

-- Time-range query over a huge events table
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM events
WHERE created_at >= NOW() - interval '1 day';

Without any index, PostgreSQL will do a Seq Scan over the whole table. After creating the BRIN index:

CREATE INDEX idx_events_created_at_brin
    ON events USING brin (created_at);

EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM events
WHERE created_at >= NOW() - interval '1 day';

Now you should see a Bitmap Index Scan or Index Only Scan using idx_events_created_at_brin plus a significantly reduced number of heap blocks read. The scan will still touch ranges of blocks, but far fewer than a full table scan. In my own monitoring setups, switching hot time-range queries from B-tree to BRIN has cut index size by orders of magnitude while keeping performance good enough for dashboards and analytics. For deeper details on tuning BRIN parameters and use cases, it’s worth exploring PostgreSQL Official Documentation – Index Types including BRIN.

Step 4: Choose the Right PostgreSQL Indexing Strategy from Query Patterns

By this point, the real question in PostgreSQL indexing strategies is no longer “which index type is best in general,” but “which index type matches this specific query pattern?” When I review schemas with teams, we walk through the queries first, then map them to B-tree, GIN, or BRIN based on what the planner actually needs to do.

Mapping Common Query Patterns to Index Types

Here’s the mental checklist I use when I’m deciding what to create

Step 4: Choose the Right PostgreSQL Indexing Strategy from Query Patterns - image 1

:

  • Equality and joins (e.g., WHERE id = ?, WHERE user_id = ?): use B-tree on the key columns, often composite if multiple predicates appear together.
  • Range and sorting (e.g., WHERE created_at >= ?, ORDER BY created_at DESC LIMIT 50): use B-tree on the sort/filter column, possibly with direction and included columns via composite indexes.
  • Array/JSONB containment (e.g., tags @> ARRAY['foo'], payload @> '{"type":"error"}'): use GIN on the array/JSONB column, sometimes with jsonb_path_ops or dedicated tsvector columns.
  • Full-text search (e.g., to_tsvector(...) @@ to_tsquery(...)): use a GIN index on a tsvector column (generated or manually updated).
  • Huge append-only, time-based analytics (e.g., WHERE created_at BETWEEN ... on log/metrics tables): use BRIN on the time or ID column that correlates with insertion order.

One thing I’ve learned is to start with the simplest option that fits the pattern (usually B-tree), measure, and only move up to GIN or BRIN when the workload clearly justifies it.

Putting It Together in a Simple Decision Flow

In practice, I often sketch a quick flow like this in my notes before creating indexes:

1. Is the column a primary/foreign key, or used in equality joins/filters?
   - Yes → B-tree.

2. Is the query mostly range-based or sorted (timestamps, numeric ranges)?
   - Yes → B-tree (or BRIN if the table is huge and append-only).

3. Does the query search inside arrays, JSONB, or documents?
   - Yes → GIN.

4. Is the table very large, mostly append-only, and filtered by a column
   that tracks insertion order (e.g., created_at)?
   - Yes → consider BRIN alongside or instead of B-tree.

5. Still unsure?
   - Start with B-tree, then use EXPLAIN ANALYZE to see if it’s effective.

This lightweight process has helped me keep schemas lean while still hitting performance targets. Instead of sprinkling every index type everywhere, I match each index to a clear query pattern, verify with EXPLAIN ANALYZE, and then iterate only where the data and workload demand it.

Step 5: Verify, Benchmark, and Troubleshoot Your Indexes

Designing PostgreSQL indexing strategies on paper is the easy part; the real work is checking that PostgreSQL actually uses those indexes and that they deliver meaningful gains. I always treat this as a feedback loop: measure, compare, then adjust.

Verifying and Benchmarking Index Usage

My first step after creating any index is to confirm that the planner picks it. I use EXPLAIN (ANALYZE, BUFFERS) before and after adding the index and compare plans, timings, and buffer usage.

-- Baseline: run without index (or with it disabled, if needed)
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM events
WHERE user_id = 12345
  AND created_at >= NOW() - interval '7 days'
ORDER BY created_at DESC
LIMIT 50;

-- After adding or adjusting the index
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM events
WHERE user_id = 12345
  AND created_at >= NOW() - interval '7 days'
ORDER BY created_at DESC
LIMIT 50;

In my experience, a successful index change usually shows:

  • A switch from Seq Scan to Index Scan or Bitmap Index Scan.
  • Fewer rows/blocks read and lower total execution time.
  • Reasonable planning time compared with execution time (especially for complex GIN queries).

For more realistic benchmarking, I’ll run the same query multiple times, sometimes with different parameter values, to account for caching and planner variability. I also keep an eye on index bloat and size using views like pg_class and pg_indexes_size when introducing large B-tree or GIN indexes.

Troubleshooting Common Index Problems

When an index doesn’t behave as expected, I usually see the same patterns. Here are the issues I run into most often and how I tackle them:

  • Index not used at all: The query shows a Seq Scan despite a seemingly appropriate index. I check for mismatched data types, functions that hide the indexed column (e.g., WHERE date(created_at) = ...), or low selectivity making a full scan cheaper. Sometimes a better composite B-tree or a different operator class for GIN/BRIN is needed.
  • Planner prefers a bad index: PostgreSQL may choose a suboptimal index if statistics are stale. In that case I run ANALYZE on the table, or even bump default_statistics_target for tricky columns, then re-check the plan.
  • Index is huge or slows writes: This bites especially with GIN and large composite B-tree indexes. I evaluate whether every index aligns with a real, critical query, and I’m not shy about dropping rarely used or redundant ones. Partial or smaller BRIN indexes can help on massive tables.
  • BRIN results too broad: If BRIN scans more data than I’d like, I adjust pages_per_range or add a better-correlated column. Periodic VACUUM and REINDEX can also improve summary accuracy.

One habit that’s saved me many times is documenting which query each index is meant to support and periodically revisiting them as the application evolves. That way, my PostgreSQL indexing strategies stay lean, understandable, and grounded in real workload needs rather than guesswork. If I see unexplained slowdowns, I always circle back to plans, statistics, and index size before making new changes PostgreSQL Documentation on Indexes.

Conclusion: A Practical Checklist for PostgreSQL Indexing Strategies

When I’m designing PostgreSQL indexing strategies, I keep a simple checklist on hand so I don’t overcomplicate things. The goal is to start with the basics, then only reach for advanced index types when the workload really calls for it

Conclusion: A Practical Checklist for PostgreSQL Indexing Strategies - image 1

.

  • Step 1 – Capture real queries: Look at actual SELECT, JOIN, and WHERE patterns from logs or monitoring; don’t index based on guesses.
  • Step 2 – Try B-tree first: For equality, joins, ranges, and sorting, use B-tree (often composite) on the columns in your most important predicates.
  • Step 3 – Add GIN where you search inside structures: For arrays, JSONB containment, and full-text search, add focused GIN indexes on the specific columns you query.
  • Step 4 – Use BRIN on huge append-only tables: For time-series or log-style data with natural ordering, prefer BRIN on timestamp/ID to keep indexes tiny and scans efficient.
  • Step 5 – Verify with EXPLAIN ANALYZE: After creating any index, compare plans and timings before/after; if it doesn’t help, adjust or drop it.
  • Step 6 – Revisit periodically: As queries and data evolve, clean up unused or redundant indexes and retune B-tree/GIN/BRIN choices.

If you follow this checklist—query-first design, B-tree by default, GIN and BRIN for the right patterns, and continuous verification—you’ll avoid most index pitfalls I’ve seen in production and keep PostgreSQL both fast and maintainable.

Join the conversation

Your email address will not be published. Required fields are marked *