Is your RDS bill bloated? Cut up to 75% in compute costs with autoscaling and scale-to-zero

Postgres indexes

Optimize query performance with indexes in Postgres

Indexes are a powerful tool to optimize query performance in relational databases like Neon Postgres. They allow the database engine to quickly locate and retrieve specific rows, significantly speeding up data access. In the absence of an index, Postgres must scan the entire table to find the rows that satisfy the query conditions.

Try it on Neon!

Neon is Serverless Postgres built for the cloud. Explore Postgres features and functions in our user-friendly SQL editor. Sign up for a free account to get started.

Sign Up

This guide explores the most common index types in Postgres, including B-tree, Hash, GiST, GIN, and BRIN indexes. You'll learn how to create these indexes, understand the trade-offs involved with each, and how to use them effectively.

note

While indexes can dramatically improve query performance, they consume additional storage and also add overhead to write operations (since Postgres needs to keep them synchronized with the table). It's important to use indexes judiciously and monitor their impact on your database's overall performance.

B-tree Indexes

B-tree (Balanced Tree) is the default index type in Postgres and is suitable for most common scenarios. B-tree indexes organize data in a tree structure, allowing for efficient searching, insertion, and deletion. The tree is kept balanced, so all reads need to traverse a similar number of rows, providing consistent performance.

Create a B-tree Index in Postgres

Consider a simple example using a users table, which includes a username column that is unique and sortable. We'll create a B-tree index on this column.

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO users (username, email) VALUES
    ('john_doe', 'john@example.com'),
    ('jane_smith', 'jane@example.com'),
    ('bob_johnson', 'bob@example.com');

CREATE INDEX idx_users_username ON users USING btree (username);

Note that the USING btree clause is optional. If you omit it, Postgres will use the default index type, which is B-tree. For example, the following query creates a B-tree index on the created_at column:

CREATE INDEX idx_users_timestamp ON users (created_at);

Use B-tree Indexes in Postgres

B-tree indexes are efficient for both equality and range queries on sortable data. They are particularly useful for columns frequently used in WHERE clauses, JOIN conditions, and ORDER BY clauses.

-- Equality search
SELECT * FROM users WHERE username = 'john_doe';

-- Range query
SELECT * FROM users WHERE username > 'j' AND username < 'k';

-- Prefix search
SELECT * FROM users WHERE username LIKE 'john%';

-- Sorting
SELECT * FROM users ORDER BY username;

For columns with a large number of distinct values, and where queries typically filter for a small set of values, hash indexes can be more efficient than B-tree indexes. Additionally, for tables with a small number of rows, the Postgres query planner may choose to do a sequential scan instead of using the index.

Hash Indexes

Hash indexes compute a hash value for each row value in the indexed column, and store the hash along with the value in a hash table. This provides constant-time lookup for equality comparisons.

Create a Hash Index in Postgres

We can create a hash index on the email column of our users table by running the following query:

CREATE INDEX idx_users_email_hash ON users USING hash (email);

Use Hash Indexes in Postgres

Hash indexes are most effective for exact match queries on columns with a large number of distinct values:

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

This is specifically useful for columns that store attributes like a username or email address. However, hash indexes don't support range queries or sorting like B-tree indexes.

GiST Indexes

GiST (Generalized Search Tree) indexes provide a flexible framework for implementing various indexing strategies. They work by recursively dividing data into nested subsets. While a B-tree index divides data based on comparison semantics (equal-to, less-than, greater-than), the nodes of a GiST tree each define a general boolean predicate, that all entries in its subtree must satisfy.

This makes it useful for complex data types and queries, such as geometric data or full-text search where the regular comparison operators might not make sense. For example, a GiST index can be used to find all locations within a certain distance of a point, or to do a word proximity search over full-text documents.

Create a GiST Index in Postgres

The following query creates a table for storing geographical locations and indexes it using GiST:

CREATE EXTENSION IF NOT EXISTS postgis;

CREATE TABLE locations (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    location GEOGRAPHY(POINT, 4326)
);

INSERT INTO locations (name, location) VALUES
    ('Eiffel Tower', ST_MakePoint(2.2945, 48.8584)),
    ('Statue of Liberty', ST_MakePoint(-74.0445, 40.6892)),
    ('Sydney Opera House', ST_MakePoint(151.2153, -33.8568));

CREATE INDEX idx_locations_gist ON locations USING gist (location);

Use GiST Indexes in Postgres

GiST indexes can significantly speed up spatial queries. For example, the following query finds all locations within 5000 meters of a point:

-- Find locations within 5000 meters of a point
SELECT name, ST_AsText(location)
FROM locations
WHERE ST_DWithin(location, ST_MakePoint(2.3522, 48.8566)::geography, 5000);

While highly versatile, especially for spatial and full-text search data, GiST indexes can be slower to build and update compared to more specialized index types.

GIN Indexes

Generalized Inverted Indexes (GIN) are useful for indexing composite values, such as arrays or full-text search documents. GIN indexes store a separate entry for each component value (e.g., each array element or each word in a text document). This is similar to an inverted index typically used in text search engines, except that it can be extended to handle data types other than text.

Create a GIN Index in Postgres

The following query creates a table with an array column and indexes it using GIN:

CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    tags TEXT[]
);

INSERT INTO products (name, tags) VALUES
    ('Smartphone', ARRAY['electronics', 'mobile', 'communication']),
    ('Laptop', ARRAY['electronics', 'computer', 'portable']),
    ('Headphones', ARRAY['electronics', 'audio', 'accessories']);

CREATE INDEX idx_products_tags ON products USING gin (tags);

Use GIN Indexes in Postgres

GIN indexes are particularly effective for contains queries on array and full-text data:

-- Find products with specific tags
SELECT * FROM products WHERE tags @> ARRAY['electronics', 'portable'];

-- Find products with any of the given tags
SELECT * FROM products WHERE tags && ARRAY['audio', 'mobile'];

However, GIN indexes can be slower to build/update and require more storage space compared to more specialized index types.

BRIN Indexes

Block Range Indexes (BRIN) are designed for very large tables where values in a column has some natural ordering. These indexes store summaries for ranges of data blocks, making them extremely compact. At query time, BRIN indexes can be used to quickly locate the blocks containing the values you are looking for.

Create a BRIN Index in Postgres

The following query creates a table for storing temperature readings and indexes it using BRIN:

CREATE TABLE temperature_readings (
    id SERIAL PRIMARY KEY,
    sensor_id INT NOT NULL,
    temperature DECIMAL(5,2) NOT NULL,
    timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO temperature_readings (sensor_id, temperature, timestamp)
SELECT
    (random() * 100)::int,
    (random() * 50 - 10)::decimal(5,2),
    timestamp '2024-01-01 00:00:00' + (random() * (interval '365 days'))
FROM generate_series(1, 100000);

CREATE INDEX idx_temperature_brin ON temperature_readings USING brin (timestamp);

Use BRIN Indexes in Postgres

BRIN indexes are especially effective for range queries on large datasets, such as the following example:

-- Find temperature readings within a specific date range
SELECT *
FROM temperature_readings
WHERE timestamp BETWEEN '2024-03-01' AND '2024-03-31';

While a BRIN index offers significant space savings and fast index creation, it provides less precise results and may require more disk access during queries compared to other index types.

Advanced Indexing Strategies

We covered the most common index types in Postgres above, where each index was created on a specific column. Postgres also supports some advanced indexing techniques that can be applied to most of the fundamental index types (primarily B-tree) to further optimize query performance, for specific data access patterns.

Multicolumn Indexes

Multicolumn indexes can be useful when queries frequently filter or sort by multiple columns together. For example, the following query creates a multicolumn index on the temperature_readings table:

CREATE INDEX idx_temp_sensor_timestamp ON temperature_readings (sensor_id, timestamp);

Multicolumn indexes can improve performance for queries that filter on multiple columns:

-- Find temperature readings from sensor 42 within a specific date range
SELECT *
FROM temperature_readings
WHERE sensor_id = 42
  AND timestamp BETWEEN '2024-03-01' AND '2024-03-31'
ORDER BY timestamp;

Note that a multicolumn index is also helpful for queries that filter on a subset of the indexed columns, as long as it is in the same order as the index. For example, the multicolumn index we created above accelerates both queries that filter on sensor_id alone, and those that filter on sensor_id and timestamp together:

-- Find maximum temperature readings from sensor 42
SELECT MAX(temperature)
FROM temperature_readings
WHERE sensor_id = 42;

However, a query that only filters on the timestamp column will not benefit from the index. Separate indexes on each column might be more efficient, depending on which queries are more frequent.

Partial Indexes

Partial indexes cover only a subset of a table's data, which can be useful for frequently queried subsets of data. For example, the following query creates a partial index on the temperature_readings table for high temperatures:

CREATE INDEX idx_high_temp ON temperature_readings (temperature)
WHERE temperature > 30;

Partial indexes can significantly speed up queries on the indexed subset:

SELECT *
FROM temperature_readings
WHERE temperature > 35;

This can be useful when creating an index on the full column is too expensive due to the size of the data, and most queries only need to access a subset of it.

Indexes on Expressions

Postgres also supports creating indexes on expressions, not just raw column values. For example, the following query creates an index on the lowercase version of the username in our users table:

CREATE INDEX idx_lower_username ON users (LOWER(username));

This index can improve performance for case-insensitive searches:

SELECT * FROM users WHERE LOWER(username) = 'john_doe';

This is useful when you frequently query based on some computation or function of a column. It saves the database engine from having to perform the computation for each row in the table at query time, which can be expensive for large tables.

Conclusion

Indexes are powerful tools for optimizing query performance in Postgres. By understanding the different types of indexes and their appropriate use cases, you can significantly enhance the efficiency of your database queries. However, remember to monitor the impact of indexes on your overall database performance, as they do introduce some overhead for write operations and storage.

Resources

Need help?

Join our Discord Server to ask questions or see what others are doing with Neon. Users on paid plans can open a support ticket from the console. For more details, see Getting Support.

Last updated on

Was this page helpful?