PostgreSQL Indexing for Web Workloads
From the Database System curriculum
TL;DR
PostgreSQL indexes significantly speed up database queries by organizing data to quickly locate rows, crucial for high-performance web applications. Choosing the right index type and columns is key to optimizing read-heavy web workloads, avoiding common performance bottlenecks. Regularly analyze query patterns and index usage to maintain optimal database performance.
1. The Mental Model
Think of an index like a book's table of contents or an alphabetical directory. Instead of scanning every page (row) to find information, you quickly jump to the right section (data block) using the index. This makes retrieving specific data much faster, which is vital when many users are constantly asking your web app for information.
2. The Core Material
For web workloads, you're primarily dealing with many concurrent read operations (SELECTs) and fewer, but still important, write operations (INSERTs, UPDATEs, DELETEs). Indexes are a game-changer for reads.
How Indexes Work

Photo by Yan Krukau on Pexels
When you create an index on one or more columns of a table, PostgreSQL builds a separate data structure (usually a B-tree) that stores the indexed column values along with pointers to the actual table rows. When a query uses these indexed columns in its WHERE, ORDER BY, GROUP BY, or JOIN clauses, the database can use the index to find the data much faster than scanning the entire table.
Common Index Types for Web Workloads

Photo by Bibek ghosh on Pexels
-
B-Tree Indexes (default and most common):
- Use case: Ideal for equality (
=), range (>,<,>=,<=,BETWEEN),LIKE(for non-leading wildcard patterns, e.g.,'%suffix'), andORDER BYclauses. They're balanced trees, meaning access time is logarithmic to the number of entries. - Example: Indexing
user_idon anorderstable to quickly find all orders for a specific user.
- Use case: Ideal for equality (
-
Hash Indexes:
- Use case: Only for equality (
=) lookups. They can be faster than B-trees for simple equality checks but are less robust (e.g., not crash-safe before PostgreSQL 10, no range scans). - Recommendation: Generally avoid in favor of B-Tree indexes unless you have a very specific, equality-only use case and understand the limitations.
- Use case: Only for equality (
-
GIN Indexes (Generalized Inverted Index):
- Use case: For indexing values that contain multiple discrete items, like arrays (
ARRAY), JSONB documents (JSONB), or full-text search (TSVECTOR). They store an entry for each item within the value. - Example: Indexing a
tagsarray column in apoststable to find posts with specific tags.
- Use case: For indexing values that contain multiple discrete items, like arrays (
-
BRIN Indexes (Block Range Index):
- Use case: For very large tables where data is naturally ordered (e.g.,
created_atin a logging table where newer entries are always appended). They store metadata about value ranges within physical blocks. - Example: Indexing a
timestampcolumn on a largeactivity_logstable where new logs are always added to the end.
- Use case: For very large tables where data is naturally ordered (e.g.,
When to Index

Photo by Ann H on Pexels
- Columns in
WHEREclauses: If you frequently filter by a column. - Columns in
ORDER BYorGROUP BYclauses: Helps avoid sorting the entire result set. - Columns in
JOINconditions: Speeds up joining tables. - Foreign key columns: Almost always a good idea to index foreign keys as they are frequently used in joins.
- High cardinality columns: Columns with many unique values generally benefit more from indexes.
When NOT to Index (or be cautious)

Photo by Nothing Ahead on Pexels
- Low cardinality columns: Columns with very few unique values (e.g., a boolean
is_activecolumn). The database might find it faster to scan the whole table than use an index that points to almost every row. - Columns frequently updated: Indexes add overhead to write operations. Each
INSERT,UPDATE, orDELETEon an indexed column requires updating the index as well. - Small tables: For tables with only a few thousand rows, a full table scan is often faster or just as fast as using an index due to the overhead of traversing the index.
graph TD
A["Query Arrives (e.g., SELECT * FROM users WHERE email = '...' )"] --> B{Does a suitable index exist?};
B -- Yes --> C["Database uses index to find row pointers"];
C --> D["Database fetches actual rows using pointers"];
D --> E["Returns result to web app"];
B -- No --> F["Database performs full table scan"];
F --> E;
A --> G["Insert/Update/Delete (DML)"];
G --> H["Modify table data"];
H --> I{"Are affected columns indexed?"};
I -- Yes --> J["Update associated indexes"];
I -- No --> K["No index update needed"];
J --> E;
K --> E;
Partial Indexes
You can create indexes that only include a subset of a table's rows using a WHERE clause in the index definition. This can make the index smaller and faster to maintain.
* Example: CREATE INDEX active_users_idx ON users (email) WHERE is_active = TRUE;
Multi-Column Indexes
An index can span multiple columns. The order of columns in a multi-column index matters. It's useful when queries frequently filter on multiple columns together.
* Example: CREATE INDEX user_login_idx ON users (email, password);
This index is useful for queries like WHERE email = '...' AND password = '...' or WHERE email = '...'. It's NOT useful for WHERE password = '...' alone.
3. Worked Example
Let's say you have a products table for an e-commerce web app:
CREATE TABLE products (
product_id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
price NUMERIC(10, 2) NOT NULL,
category VARCHAR(100),
is_available BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
Your web app frequently performs these queries:
- Finding products by category:
SELECT * FROM products WHERE category = 'Electronics'; - Searching products by name:
SELECT * FROM products WHERE name ILIKE '%laptop%'; - Listing available products by category, ordered by price:
SELECT * FROM products WHERE category = 'Books' AND is_available = TRUE ORDER BY price DESC;
Here's how you'd apply indexing:
-
For Query 1 (
categoryequality):
sql CREATE INDEX idx_products_category ON products (category);
This B-tree index will speed up category lookups. -
For Query 2 (
name ILIKE '%laptop%'):
A standard B-tree index onnamewon't help with leading wildcards. You'd typically use a GIN index with thepg_trgmextension for efficient similarity searches:
sql CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_products_name_trgm ON products USING GIN (name gin_trgm_ops);
This allowsILIKE '%pattern%'to use the index. -
For Query 3 (multi-column filter and order):
A multi-column index is best here. The order should match the most restrictive filter first, then the ordering columns.
sql CREATE INDEX idx_products_category_available_price ON products (category, is_available, price DESC);
This index can fulfill theWHERE category = 'Books' AND is_available = TRUEpart and also help with theORDER BY price DESC, potentially avoiding a sort. Note theDESCin the index definition to match the query's order.
You might also consider a partial index for is_available if unavailable products are rarely queried:
CREATE INDEX idx_products_available ON products (is_available) WHERE is_available = TRUE;
However, in Query 3, the category filter is likely more selective, so combining it into the multi-column index is often more effective.
4. Key Takeaways
- Indexes primarily boost read performance, crucial for web applications.
- B-tree indexes are your default for equality, range, and sorting.
- GIN indexes are powerful for complex data types like JSONB and full-text search.
- Always index foreign key columns to speed up joins.
- Analyze your queries with
EXPLAIN ANALYZEto see if your indexes are actually being used and if they're effective. - Understand that indexes add overhead to write operations (INSERT, UPDATE, DELETE).
Common Mistakes to Avoid
- Over-indexing: Creating too many indexes can slow down writes and consume excessive disk space without providing corresponding read benefits.
- Indexing low-cardinality columns: An index on a column with few unique values (e.g., a boolean) is often useless as a full table scan might be faster.
- Not using
EXPLAIN ANALYZE: Guessing about index effectiveness without inspecting the query plan is a common pitfall. - Ignoring multi-column index order: The order of columns in a composite index matters greatly for its utility.
- Forgetting to maintain statistics: Stale database statistics can lead the query planner to make bad decisions about index usage. Run
ANALYZEregularly.
5. Now Try It
Choose one of your web application's main database tables (e.g., users, posts, orders). Identify two to three of the most frequently executed SELECT queries against that table. For each query, analyze which columns are used in WHERE, ORDER BY, GROUP BY, or JOIN clauses. Based on this, propose a set of indexes you would create on that table. Write down the CREATE INDEX statements. Think about why each index is useful and what type of index you're choosing.
Frequently asked about PostgreSQL Indexing for Web Workloads
Study this next
Get the full Database System curriculum
Clone the complete plan to your dashboard for unlimited AI-generated notes, practice quizzes, and a personalised revision schedule.
Save this course free