All Articles
Understanding PostgreSQL Indexes
Backend Engineering

Understanding PostgreSQL Indexes

Learn how PostgreSQL indexes accelerate queries using B-Tree, GIN, GiST, and BRIN indexes, and discover when each indexing strategy is the right choice for scalable database performance.

#PostgreSQL#Databases#Performance
D
Dipankar Ghosh
10 min read

Understanding PostgreSQL Indexes: The Difference Between Milliseconds and Minutes

"Premature optimization is the root of all evil."

— Donald Knuth

While this quote is often repeated, one optimization is almost never premature: proper indexing.

Many developers encounter a performance issue, increase server resources, add caching layers, or optimize application code, only to discover that the real bottleneck was a missing database index.

An index can transform a query from scanning millions of rows into locating the required data in milliseconds.

Let's explore how PostgreSQL indexes work, the different index types available, and when to use each one.


Why Do We Need Indexes?

Imagine a table containing one million users.

SELECT * FROM users
WHERE email = 'john@example.com';

Without an index, PostgreSQL has only one option:

Check Row 1
Check Row 2
Check Row 3
...
Check Row 1,000,000

This is called a Sequential Scan.

┌──────────────┐
│ PostgreSQL   │
└──────┬───────┘
       │
       ▼
┌──────────────┐
│ Row 1        │
├──────────────┤
│ Row 2        │
├──────────────┤
│ Row 3        │
├──────────────┤
│ ...          │
├──────────────┤
│ Row 1,000,000│
└──────────────┘

As the dataset grows, query performance degrades significantly.

Now imagine adding an index.

CREATE INDEX idx_users_email
ON users(email);

PostgreSQL can now jump directly to the desired record.

Search Request
      │
      ▼
┌───────────┐
│  INDEX    │
└─────┬─────┘
      │
      ▼
 Target Row

Instead of examining every row, PostgreSQL navigates a data structure specifically optimized for searching.


What Is an Index Internally?

An index is essentially a separate data structure that stores:

Indexed Value
      │
      ▼
Row Location

Example:

john@example.com  ─────► Row 15
alice@example.com ─────► Row 91
bob@example.com   ─────► Row 202

Think of it as the index section at the back of a textbook.

You don't read all 800 pages to find "Binary Trees."

You look up:

Binary Trees → Page 217

Databases do exactly the same thing.


B-Tree Index

The default PostgreSQL index type.

CREATE INDEX idx_users_email
ON users(email);

Equivalent to:

CREATE INDEX idx_users_email
ON users USING BTREE(email);

Why B-Trees Are Fast

B-Trees keep data sorted.

                [M]
               /   \
              /     \
             /       \
          [F]         [T]
         /  \         /  \
       [C] [J]     [P] [Y]

Searching for "P":

P > M
 ↓
Go Right

P < T
 ↓
Go Left

Found P

Instead of checking every value, PostgreSQL traverses a small portion of the tree.


Best Use Cases

Equality Queries

SELECT *
FROM users
WHERE email = 'john@example.com';

Range Queries

SELECT *
FROM products
WHERE price BETWEEN 100 AND 500;

Sorting

SELECT *
FROM users
ORDER BY created_at DESC;

Prefix Matching

SELECT *
FROM users
WHERE username LIKE 'dip%';

Complexity

Without index:

O(n)

With B-Tree:

O(log n)

For millions of rows, the difference is enormous.


Hash Index

Hash indexes are optimized purely for equality comparisons.

CREATE INDEX idx_hash_email
ON users USING HASH(email);

Useful for:

WHERE email = 'john@example.com'

Not useful for:

WHERE email > 'john@example.com'

or

ORDER BY email

In practice, B-Trees usually outperform Hash indexes because they're more versatile.


GIN (Generalized Inverted Index)

One of PostgreSQL's most powerful index types.


Why GIN Exists

Consider storing permissions:

{
  "roles": ["admin", "editor"]
}

Query:

SELECT *
FROM users
WHERE permissions ? 'admin';

A B-Tree struggles because the data isn't a simple scalar value.

GIN solves this by indexing individual elements.


Internal Structure

Instead of:

Row → Values

GIN stores:

Value → Rows

Example:

admin  → [1, 5, 12]
editor → [1, 9]
user   → [2, 3, 4]

This makes membership checks extremely fast.


JSONB Queries

Table:

CREATE TABLE events (
    id SERIAL,
    metadata JSONB
);

Data:

{
  "country": "India",
  "device": "Mobile"
}

Index:

CREATE INDEX idx_events_metadata
ON events
USING GIN(metadata);

Query:

SELECT *
FROM events
WHERE metadata @> '{"country":"India"}';

Without GIN:

Scan every JSON document

With GIN:

country=India
        │
        ▼
Matching Rows

Full Text Search

GIN is heavily used for search engines.

CREATE INDEX idx_articles_search
ON articles
USING GIN(to_tsvector('english', content));

Query:

SELECT *
FROM articles
WHERE to_tsvector('english', content)
@@ to_tsquery('postgresql');

This enables search performance across millions of documents.


GiST (Generalized Search Tree)

GiST is a framework for advanced search structures.


Think Beyond Simple Data

Imagine storing locations.

Latitude
Longitude

Query:

Find all restaurants within 2km

A B-Tree cannot efficiently answer this.


GiST Solves Spatial Problems

            Earth
              │
      ┌───────┴───────┐
      ▼               ▼
  Region A       Region B
      │               │
      ▼               ▼
 Coordinates    Coordinates

PostgreSQL can quickly eliminate large areas of the search space.


PostGIS Example

CREATE INDEX idx_location
ON places
USING GIST(location);

Query:

SELECT *
FROM places
WHERE ST_DWithin(
    location,
    target_location,
    2000
);

Results are returned in milliseconds even for large datasets.


BRIN (Block Range Index)

One of PostgreSQL's most underrated index types.


Problem

Imagine:

500 million log records

Creating a B-Tree index may consume gigabytes of storage.


BRIN Approach

Instead of indexing every row:

Block 1 → Min / Max
Block 2 → Min / Max
Block 3 → Min / Max

Example:

Rows 1-1000     : Jan
Rows 1001-2000  : Feb
Rows 2001-3000  : Mar

Searching for March:

Skip Jan
Skip Feb
Read Mar

Very small index size.

Excellent for:

  • Time-series data
  • Logs
  • Events
  • Metrics

Composite Indexes

Indexes can contain multiple columns.

CREATE INDEX idx_user_status
ON users(country, status);

Query:

SELECT *
FROM users
WHERE country = 'India'
AND status = 'active';

PostgreSQL can use a single lookup.


Leftmost Prefix Rule

This index:

(country, status)

Supports:

WHERE country = 'India'

Supports:

WHERE country='India'
AND status='active'

Does NOT efficiently support:

WHERE status='active'

Understanding this rule is crucial.


How to Know If an Index Is Being Used

Use:

EXPLAIN ANALYZE
SELECT *
FROM users
WHERE email='john@example.com';

Without index:

Seq Scan on users

With index:

Index Scan using idx_users_email

This should become part of your regular workflow.


The Cost of Indexes

Indexes are not free.

Every INSERT, UPDATE, and DELETE must also update the index.

INSERT
   │
   ├── Update Table
   │
   └── Update Index

More indexes:

Faster Reads
Slower Writes
More Storage

The goal is balance.


Practical Rules

Index columns used in WHERE clauses.

Index foreign keys.

Index frequently sorted columns.

Use GIN for JSONB and full-text search.

Use GiST for geospatial data.

Use BRIN for massive append-only tables.

Always verify with EXPLAIN ANALYZE.


Final Thoughts

Indexes are one of the highest-leverage optimizations available in PostgreSQL. A single well-designed index can reduce query execution time from seconds to milliseconds without changing a single line of application code.

The best database engineers don't memorize index types—they understand the shape of their data, the access patterns of their queries, and choose the indexing strategy that aligns with both.

When performance problems appear, don't immediately reach for bigger servers.

Run:

EXPLAIN ANALYZE

The answer is often hiding in plain sight.

Tags
#PostgreSQL#Databases#Performance