← PostgreSQL DBA← PostgreSQL DBA
PostgreSQL DBAPostgreSQL DBA19 Th7, 2026Jul 19, 202622 phút đọc18 min read

Chiến lược IndexingIndexing Strategies

Thuộc bộ kiến thức PostgreSQL DBA Roadmap.

Tổng quan

Index là một cấu trúc dữ liệu riêng, được sắp xếp theo thứ tự, giúp PostgreSQL tìm ra các row mà không cần quét toàn bộ table. Nếu không có index, mọi truy vấn vào table đều là sequential scan: PostgreSQL đọc từng block, kiểm tra từng row với điều kiện truy vấn, rồi loại bỏ những row không khớp. Điều này không thành vấn đề với table vài nghìn row, nhưng sẽ là thảm họa với table hàng trăm triệu row.

Index giải quyết vấn đề này bằng cách đánh đổi thêm dung lượng lưu trữ và thêm chi phí ghi để đổi lấy tốc độ đọc nhanh hơn đáng kể. Mỗi lệnh INSERT, UPDATE, hoặc DELETE trên table có index đều phải cập nhật index đó — đôi khi là nhiều index cùng lúc — nên table càng nhiều index thì chi phí ghi càng cao. Đây chính là mâu thuẫn trung tâm của việc đánh index: index quá ít thì đọc chậm; index quá nhiều (hoặc index sai chỗ) thì việc ghi bị chậm lại trong khi ổ đĩa bị lấp đầy bởi những cấu trúc chẳng ai truy vấn tới. Chiến lược indexing tốt nghĩa là hiểu rõ PostgreSQL cung cấp những loại index nào, mỗi loại tăng tốc kiểu truy cập nào, và áp dụng chúng một cách có chủ đích dựa trên truy vấn thực tế mà table đó phục vụ — chứ không phải đánh index phòng thủ trên mọi cột.

Khác với nhiều hệ quản trị cơ sở dữ liệu quan hệ chỉ có B-tree, PostgreSQL cung cấp sẵn sáu loại index (access method) — B-tree, Hash, GIN, GiST, SP-GiST, và BRIN — mỗi loại được tối ưu cho một dạng dữ liệu và một kiểu truy vấn khác nhau. Chọn đúng loại index quan trọng không kém việc quyết định có nên đánh index hay không.

Kiến thức nền tảng

B-tree hoạt động như thế nào, về mặt khái niệm

Loại index mặc định và phổ biến nhất là B-tree (balanced tree — cây cân bằng). Về mặt khái niệm, nó tổ chức các giá trị được index thành một cây gồm các page: root page trỏ tới các intermediate page, các page này lại trỏ tới leaf page chứa giá trị index thực tế theo thứ tự sắp xếp, cùng với con trỏ (tuple ID) trỏ ngược về row tương ứng trong heap. Vì cây được giữ cân bằng và có chiều cao thấp — chiều cao tăng theo logarit so với số row — việc tìm một giá trị chỉ mất khoảng O(log n) lần đọc page bất kể table lớn cỡ nào. Một table tỷ row có thể chỉ cần 4-5 lần đọc page để tìm ra kết quả khớp, so với việc quét toàn bộ table.

Vì các entry được lưu theo thứ tự sắp xếp, B-tree không chỉ hỗ trợ so sánh bằng (=) mà còn hỗ trợ so sánh thứ tự (<, <=, >, >=), BETWEEN, IN, IS NULL, so khớp pattern ở đầu chuỗi (LIKE 'abc%' với locale C hoặc operator class phù hợp), và ORDER BY/MIN/MAX mà không cần bước sort riêng. Tính linh hoạt này là lý do B-tree là access method mặc định của CREATE INDEX và là lựa chọn đầu tiên hợp lý cho hầu hết các cột dạng scalar — primary key, foreign key, timestamp, mã trạng thái, bất cứ thứ gì bạn filter hoặc sort bằng toán tử so sánh thông thường.

-- Index B-tree cơ bản (access method mặc định, nên USING btree là ngầm định)
CREATE INDEX idx_orders_customer_id ON orders (customer_id);

-- Hỗ trợ cả equality, range, và sorting trên cùng một cột
SELECT * FROM orders WHERE customer_id = 42;
SELECT * FROM orders WHERE customer_id BETWEEN 100 AND 200;
SELECT * FROM orders ORDER BY customer_id;

Hash index

Hash index lưu giá trị hash của giá trị được index thay vì lưu giá trị thực, và chỉ hỗ trợ so sánh bằng (=) — không hỗ trợ range, không hỗ trợ sort. Trước đây hash index có tiếng xấu: trước PostgreSQL 10, hash index không được WAL-log, nghĩa là không an toàn khi crash và phải REINDEX lại sau khi crash hoặc trên standby server, đồng thời không được replicate. Từ PostgreSQL 10 trở đi, hash index đã được WAL-log đầy đủ, an toàn khi crash, và replicate bình thường, nên lo ngại lịch sử đó không còn đúng nữa.

Tuy vậy, hash index ngày nay hiếm khi là lựa chọn đúng đắn. B-tree xử lý truy vấn equality hiệu quả không kém gì hash index, mà còn hỗ trợ thêm range query, sorting, và composite index prefix — nên gần như không có kiểu truy vấn nào mà hash thắng còn B-tree thua, trong khi có rất nhiều trường hợp B-tree thắng còn hash thua (bất kỳ range query nào trong tương lai, bất kỳ ORDER BY nào, bất kỳ nhu cầu composite index nào). Về lý thuyết, hash index có thể nhỏ hơn và nhanh hơn một chút cho các truy vấn equality thuần túy trên cột có cardinality cao trong một số workload, nhưng điều này hiếm khi đáng để đánh đổi tính linh hoạt của B-tree. Trong thực tế, chỉ nên dùng hash index trong những trường hợp hẹp, đã được đo đạc cụ thể — còn lại, với đại đa số nhu cầu equality lookup, hãy dùng B-tree.

-- Hiếm khi cần trong thực tế — nêu ra để đầy đủ
CREATE INDEX idx_sessions_token_hash ON sessions USING hash (token);

SELECT * FROM sessions WHERE token = 'abc123...';  -- chỉ equality, không hỗ trợ range

Khái niệm chính

GIN — Generalized Inverted Index

GIN được thiết kế cho các cột lưu giá trị composite — giá trị chứa nhiều “key” thành phần mà bạn muốn tìm kiếm bên trong, như các phần tử của array, các key/value của JSONB document, hoặc các lexeme của tsvector. Về bản chất, GIN đảo ngược mối quan hệ: thay vì một entry index cho mỗi row, nó tạo một entry index cho mỗi giá trị thành phần, mỗi entry trỏ tới tất cả row chứa giá trị đó. Điều này khiến GIN cực kỳ nhanh cho các truy vấn kiểu “row này có chứa X không” — containment (@>), overlap (&&), full-text search (@@) — đổi lại kích thước index lớn hơn và ghi chậm hơn so với B-tree (GIN dùng “pending list” để gom nhóm insert và giảm write amplification, được flush bởi autovacuum hoặc theo gin_pending_list_limit).

Các use case GIN phổ biến nhất trong thực tế là full-text search trên cột tsvector và truy vấn containment trên JSONB:

-- Đánh index một cột JSONB để truy vấn containment
CREATE TABLE products (
    id serial PRIMARY KEY,
    name text NOT NULL,
    attributes jsonb NOT NULL
);

CREATE INDEX idx_products_attributes_gin ON products USING gin (attributes);

-- Index GIN tăng tốc toán tử @> (contains)
SELECT * FROM products
WHERE attributes @> '{"color": "red", "in_stock": true}';

-- Full-text search: đánh index một cột tsvector được generate
ALTER TABLE products ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (to_tsvector('english', name)) STORED;

CREATE INDEX idx_products_search_gin ON products USING gin (search_vector);

SELECT * FROM products
WHERE search_vector @@ to_tsquery('english', 'wireless & mouse');

Riêng với JSONB, PostgreSQL cung cấp hai operator class cho GIN: jsonb_ops mặc định, index toàn bộ key lẫn value và hỗ trợ @>, ?, ?&, ?|; và jsonb_path_ops, chỉ index hash của giá trị dẫn xuất từ path, cho ra index nhỏ hơn và nhanh hơn riêng với @> nhưng không hỗ trợ nhóm toán tử ?. Hãy chọn jsonb_path_ops khi containment là toán tử JSONB duy nhất bạn cần:

CREATE INDEX idx_products_attributes_pathops
    ON products USING gin (attributes jsonb_path_ops);

GiST — Generalized Search Tree

GiST là access method dạng cây cân bằng — không phải một thuật toán duy nhất mà là một framework cho phép nhiều kiểu dữ liệu khác nhau tự cắm vào khái niệm “gần nhau” hay “chồng lấn” của riêng chúng, đó là lý do nó hỗ trợ kiểu geometric, range type, full-text search (như một lựa chọn thay thế GIN, update nhanh hơn nhưng lookup chậm hơn), và tìm kiếm nearest-neighbor (ORDER BY <distance> LIMIT n) qua k-NN.

Ứng dụng vận hành quan trọng nhất của GiST đối với một DBA là exclusion constraint, dùng để ngăn các giá trị chồng lấn giữa các row — ví dụ đảm bảo hai lượt đặt phòng cho cùng một phòng không bao giờ có khoảng ngày chồng lấn nhau. Exclusion constraint được xây dựng trên GiST vì GiST hiểu sẵn khái niệm “A có chồng lấn B hay không” đối với range type và kiểu geometric. Xem ./03-data-types-and-schema-objects.md để biết thêm về range type và cách khai báo EXCLUDE USING gist.

-- Truy vấn geometric về vị trí lân cận / containment
CREATE INDEX idx_locations_point_gist ON locations USING gist (coordinates);

SELECT * FROM locations
ORDER BY coordinates <-> point(10.775, 106.700)
LIMIT 5;  -- tìm kiếm nearest-neighbor, được tăng tốc bởi index GiST

-- Đánh index range type làm nền cho exclusion constraint
CREATE TABLE room_bookings (
    room_id int NOT NULL,
    during tsrange NOT NULL,
    EXCLUDE USING gist (room_id WITH =, during WITH &&)
);

SP-GiST — Space-Partitioned GiST

SP-GiST hỗ trợ các cây tìm kiếm được phân vùng nhưng không nhất thiết cân bằng, phù hợp với dữ liệu có tính phân cụm tự nhiên, không đồng đều — có thể hình dung như trie, quad-tree, hoặc k-d tree. Nó phù hợp với các kiểu dữ liệu mà giá trị tự nhiên chia thành các phân vùng tách biệt thay vì một khoảng liên tục: prefix của địa chỉ IP/khối CIDR, prefix số điện thoại, điểm 2D phù hợp với quad-tree, hoặc chuỗi text có prefix chung. SP-GiST ít được dùng hơn nhiều so với B-tree, GIN, hay GiST — đa số DBA sẽ đi rất lâu mà không cần dùng tới — nhưng đây là công cụ đúng đắn khi dữ liệu của bạn có cấu trúc phân vùng tự nhiên, không cân bằng kiểu này.

CREATE INDEX idx_devices_ip_spgist ON devices USING spgist (ip_address inet_ops);

SELECT * FROM devices WHERE ip_address <<= '10.0.0.0/8';

BRIN — Block Range Index

BRIN tiếp cận theo hướng hoàn toàn khác so với mọi loại index còn lại: thay vì lưu một entry cho mỗi row (hoặc mỗi giá trị), nó lưu thông tin tóm tắt — thường chỉ là giá trị min và max — cho mỗi dải block vật lý của table (kích thước dải có thể cấu hình qua pages_per_range, mặc định là 128). Vì nó tóm tắt theo dải block thay vì theo từng row, index BRIN rất nhỏ: thường chỉ vài trăm kilobyte đến vài megabyte cho một table mà B-tree tương ứng cần tới nhiều gigabyte. Chi phí bảo trì cũng nhỏ tương ứng, vì cập nhật một entry BRIN chỉ cần chạm vào phần tóm tắt của một dải thay vì phải cân bằng lại cả cây.

Điểm cần lưu ý — và rất quan trọng — là BRIN chỉ có ích khi cột được index có tương quan vật lý với thứ tự row trên đĩa của table. Trường hợp điển hình là table append-only, được sắp theo thời gian: nếu row được insert theo thứ tự created_at và không bao giờ bị sắp xếp lại, thì mỗi dải block sẽ tự nhiên chứa một khoảng timestamp hẹp, nên “min/max theo dải” là một bộ lọc rất hiệu quả (PostgreSQL có thể bỏ qua hoàn toàn những dải không thể chứa row khớp). Nếu cột được index không có tương quan gì với thứ tự vật lý (ví dụ customer_id nằm rải rác ngẫu nhiên trong table theo thứ tự insert), thì min/max theo dải của BRIN gần như bao phủ toàn bộ khoảng giá trị trong mọi block, nên không thể loại trừ dải nào cả, và hiệu quả gần như suy biến thành quét toàn bộ table — lúc đó BRIN không mang lại lợi ích gì và nên dùng B-tree thay thế. Kiểm tra mức độ tương quan bằng SELECT correlation FROM pg_stats WHERE tablename = '...' AND attname = '...' — giá trị gần 1 hoặc -1 cho thấy tương quan vật lý mạnh, gần 0 cho thấy BRIN sẽ không giúp ích gì.

-- Ứng viên lý tưởng cho BRIN: table sự kiện append-only, sắp theo thời gian
CREATE TABLE events (
    id bigserial PRIMARY KEY,
    created_at timestamptz NOT NULL,
    payload jsonb
);

CREATE INDEX idx_events_created_at_brin ON events USING brin (created_at);

-- Truy vấn range trên created_at hưởng lợi rất lớn, với kích thước chỉ bằng một phần nhỏ của B-tree
SELECT * FROM events
WHERE created_at >= '2026-07-01' AND created_at < '2026-07-08';

Với một table sự kiện append-only dung lượng 500 GB, B-tree trên created_at có thể chiếm hàng chục gigabyte; index BRIN tương đương có thể chỉ chiếm vài megabyte, trong khi vẫn giúp planner bỏ qua phần lớn block cho một truy vấn khoảng ngày hẹp.

Composite (multi-column) index và leftmost-prefix matching

Composite index trải trên nhiều cột, và thứ tự cột không chỉ mang tính hình thức — nó quyết định index có thể phục vụ dạng truy vấn nào. Index trên (a, b) được lưu theo thứ tự sắp xếp theo a trước, rồi theo b trong mỗi giá trị a. Điều đó có nghĩa là nó phục vụ hiệu quả các truy vấn filter trên a riêng lẻ, hoặc trên a AND b cùng lúc, vì trong cả hai trường hợp index đều có thể nhảy thẳng tới (các) giá trị a liên quan rồi, nếu cần, thu hẹp thêm bằng b. Nó không phục vụ hiệu quả truy vấn chỉ filter trên b riêng lẻ, vì không biết a thì index không có cách nào thu hẹp về phần cây đáng quan tâm — kiểu truy cập đó sẽ suy biến thành quét toàn bộ index.

CREATE INDEX idx_orders_customer_status ON orders (customer_id, status);

-- Dùng index hiệu quả: filter trên cột dẫn đầu
SELECT * FROM orders WHERE customer_id = 42;

-- Dùng index hiệu quả: filter trên cả hai cột, vẫn giữ nguyên leftmost prefix
SELECT * FROM orders WHERE customer_id = 42 AND status = 'shipped';

-- KHÔNG dùng index này hiệu quả: bỏ qua cột dẫn đầu
SELECT * FROM orders WHERE status = 'shipped';

Đây gọi là leftmost-prefix matching: index trên (a, b, c) có thể phục vụ truy vấn trên (a), (a, b), hoặc (a, b, c), nhưng không phục vụ được truy vấn chỉ trên (b), (c), hoặc (b, c). Khi thiết kế composite index, hãy đặt cột có tần suất filter đơn lẻ cao nhất (hoặc độ chọn lọc cao nhất) lên đầu, và sắp xếp các cột tiếp theo sao cho khớp với các dạng truy vấn multi-column phổ biến nhất của bạn. Nếu cả WHERE customer_id = ? lẫn WHERE status = ? đều cần nhanh một cách độc lập, thông thường bạn cần hai index riêng biệt thay vì một composite index cố gắng phục vụ cả hai.

Partial index

Partial index chỉ bao gồm các row khớp với mệnh đề WHERE được chỉ định khi tạo index, thay vì mọi row trong table. Điều này rất có giá trị khi truy vấn liên tục nhắm tới một tập con row đã biết trước, thường là nhỏ — user đang hoạt động trong một cơ sở user phần lớn không hoạt động, đơn hàng đang chờ xử lý trong một lịch sử đơn hàng phần lớn đã hoàn tất, hoặc ticket chưa lưu trữ trong một table không bao giờ xóa dữ liệu cũ. Index kết quả nhỏ hơn (quét nhanh hơn, bảo trì rẻ hơn, cần ít RAM hơn để cache), và chi phí ghi cũng giảm vì các row nằm ngoài predicate hoàn toàn không chạm vào index.

CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';

-- Planner sẽ dùng index hẹp, rẻ này cho các truy vấn có predicate khớp
-- (hoặc có thể chứng minh là tương thích)
SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at;

Partial index cũng là cách chuẩn để áp đặt ràng buộc unique trên một tập con row — ví dụ chỉ cho phép một row đang hoạt động cho mỗi key, trong khi các row lịch sử/không hoạt động được phép lặp lại:

CREATE UNIQUE INDEX idx_users_active_email ON users (email)
WHERE deleted_at IS NULL;

Expression index (functional index)

Expression index lưu kết quả của một biểu thức thay vì giá trị cột thô, giúp PostgreSQL dùng được index cho các truy vấn filter trên giá trị đã tính toán hoặc biến đổi. Ví dụ kinh điển là tra cứu email không phân biệt hoa thường:

CREATE INDEX idx_users_email_lower ON users (lower(email));

-- Hưởng lợi từ index vì biểu thức khớp chính xác
SELECT * FROM users WHERE lower(email) = 'jane@example.com';

-- KHÔNG hưởng lợi — biểu thức khác, planner sẽ không khớp truy vấn này với index trên
SELECT * FROM users WHERE email = 'jane@example.com';

Quy tắc then chốt là truy vấn phải dùng đúng biểu thức mà index đã được tạo dựa trên (trừ một số trường hợp planner có thể tự suy ra tương đương về immutability và ép kiểu) thì planner mới cân nhắc dùng index. Đây cũng là lý do hàm dùng trong expression index phải là IMMUTABLE — PostgreSQL cần đảm bảo cùng một input luôn cho ra cùng một output, vì giá trị đã tính toán được lưu vật lý trong index và không được tính lại khi đọc.

Covering index với INCLUDE

Thông thường, ngay cả khi index có thể xác định vị trí các row khớp qua cột key của nó, PostgreSQL vẫn phải ghé qua heap (nơi lưu trữ row thực tế của table) để lấy bất kỳ cột nào truy vấn cần mà không nằm trong index key — trừ khi visibility map xác nhận page hoàn toàn “all-visible”, lúc đó index-only scan có thể bỏ qua hoàn toàn việc ghé heap cho các cột key. Mệnh đề INCLUDE, được thêm từ PostgreSQL 11, cho phép bạn gắn thêm các cột “payload” vào một index B-tree mà không thuộc search key (nên không ảnh hưởng đến thứ tự sắp xếp hay các toán tử mà index hỗ trợ) nhưng vẫn được lưu trong index và do đó có sẵn cho index-only scan.

CREATE INDEX idx_orders_customer_covering
    ON orders (customer_id)
    INCLUDE (status, total_amount);

-- Nếu visibility map được cập nhật đầy đủ, truy vấn này có thể được thỏa mãn
-- hoàn toàn từ index — không cần ghé heap
SELECT customer_id, status, total_amount
FROM orders
WHERE customer_id = 42;

Cột INCLUDE hữu ích khi danh sách SELECT của một truy vấn cần thêm một vài cột ngoài các cột filter, và bạn muốn có index-only scan thực sự mà không làm phình to search key (điều này cũng sẽ thay đổi những toán tử/truy vấn mà index hỗ trợ và có thể thêm ràng buộc unique không mong muốn trong một unique index). Lưu ý rằng cột INCLUDE vẫn chiếm không gian trong index và vẫn cần cập nhật khi ghi vào các cột đó, nên đây vẫn là một đánh đổi về kích thước/chi phí ghi như bất kỳ quyết định index nào khác, không phải một lợi ích miễn phí.

Best Practices

Đánh index có chủ đích, không phải phòng thủ

Best practice quan trọng nhất là đánh index dựa trên cách table thực sự được truy vấn, chứ không phải dựa trên linh cảm rằng “cột này có thể sẽ được tìm kiếm một ngày nào đó”. Mỗi index đều thêm chi phí ghi và dung lượng lưu trữ; một table có mười index mà chỉ năm truy vấn thực sự dùng tới đang phải trả một chi phí thực, liên tục mà không nhận lại lợi ích gì. Hãy dùng EXPLAIN (ANALYZE, BUFFERS) trên các truy vấn tiêu biểu để xem planner thực sự đang làm gì và liệu sequential scan có thực sự là vấn đề hay không trước khi thêm index để khắc phục — xem ./10-query-planning-and-performance-tuning.md để biết cách đọc query plan và ra quyết định về index dựa trên bằng chứng thay vì phỏng đoán. pg_stat_user_indexespg_stat_user_tables cũng hữu ích để tìm những index không bao giờ được scan (idx_scan = 0 sau một khoảng thời gian tiêu biểu) và là ứng viên để loại bỏ.

Khớp loại index với kiểu truy vấn

Mặc định dùng B-tree; nó bao phủ đại đa số nhu cầu equality/range/sort trên dữ liệu scalar. Chuyển sang GIN khi một cột chứa giá trị composite mà bạn cần tìm kiếm bên trong — array, JSONB, full-text. Chuyển sang GiST cho dữ liệu geometric, overlap range, k-NN, hoặc exclusion constraint. Chỉ cân nhắc BRIN khi một table cực lớn có một cột tương quan vật lý tự nhiên với thứ tự insert — xác nhận bằng pg_stats.correlation trước khi quyết định, vì BRIN có thể gây hại thực sự (chỉ có mặt xấu, không có lợi ích) trên dữ liệu không tương quan. Tránh hash index trừ khi bạn có lý do cụ thể, đã được đo đạc, cho thấy B-tree không đáp ứng được — điều này rất hiếm.

Thiết kế composite index dựa trên dạng truy vấn thực tế

Hãy xem xét các mệnh đề WHERE mà ứng dụng của bạn thực sự phát ra, và sắp xếp thứ tự cột trong composite index sao cho cột có độ chọn lọc cao nhất, được filter riêng lẻ thường xuyên nhất đứng đầu, tuân theo leftmost-prefix matching. Đừng xây dựng một composite index rộng bao phủ nhiều cột một cách phỏng đoán “phòng khi cần” — nó sẽ lớn, chậm bảo trì, và vẫn chỉ phục vụ được các truy vấn khớp prefix.

Dùng partial index và expression index để thu nhỏ trường hợp phổ biến

Nếu phần lớn truy vấn filter về một tập con row đã biết, ổn định (status = 'active', deleted_at IS NULL), partial index trên tập con đó thường tốt hơn index đầy đủ — nhỏ hơn, nhanh hơn, bảo trì rẻ hơn. Nếu truy vấn liên tục bọc một cột trong một hàm (lower(), date_trunc(), trích xuất JSON), một expression index nhắm đúng biểu thức đó sẽ được sử dụng; một index thông thường trên cột thô thì không.

Chú ý write amplification trên table nhiều index

Mỗi index bổ sung trên một table đều thêm việc cho mỗi INSERT/UPDATE/DELETE, và UPDATE đặc biệt có thể tốn kém nếu nó chạm vào cột được index và ngăn cản Heap-Only Tuple (HOT) update — xem ./17-schema-design-patterns-and-antipatterns.md để biết các pattern ở tầng schema ảnh hưởng đến chi phí update, khả năng HOT, và bloat. Trên table nhiều ghi, hãy định kỳ rà soát index để tìm sự trùng lặp (ví dụ một index đơn cột trở nên dư thừa vì đã có composite index cùng cột dẫn đầu) và loại bỏ những index không xứng đáng với chi phí duy trì.

Tạo index mà không chặn ghi

Trên một table production đang hoạt động, hãy dùng CREATE INDEX CONCURRENTLY để tránh giữ một lock chặn ghi trong suốt quá trình build index. Cách này mất nhiều thời gian hơn và không thể chạy trong một transaction block, nhưng đây là cách chuẩn để thêm hoặc build lại index trên table không thể chấp nhận downtime.

CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders (customer_id);

Bảng so sánh

Loại indexPhù hợp nhất choUse case điển hình
B-treeEquality, so sánh range, sorting trên giá trị dạng scalarPrimary/foreign key, timestamp, mã trạng thái, WHERE/ORDER BY thông thường
HashTruy vấn equality thuần túyHiếm khi là lựa chọn đúng — B-tree bao phủ equality và hơn thế nữa; chỉ dùng khi có lý do cụ thể, đã đo đạc
GINDữ liệu composite/multi-valued, “row này có chứa X không”Full-text search (tsvector), JSONB containment (@>), array containment
GiSTDữ liệu geometric, overlap/containment, nearest-neighbor, exclusion constraintTruy vấn geospatial, exclusion constraint dựa trên range-overlap, tìm kiếm k-NN
SP-GiSTDữ liệu phân vùng tự nhiên, không đồng đềuPrefix IP/CIDR, prefix số điện thoại, dữ liệu không gian kiểu quad-tree
BRINTable cực lớn có cột tương quan vật lý với thứ tự rowTable sự kiện/log append-only, sắp theo thời gian, được filter theo khoảng timestamp

Tài liệu tham khảo

Part of the PostgreSQL DBA Roadmap knowledge base.

Overview

An index is a separate, ordered data structure that lets PostgreSQL find rows without scanning the entire table. Without one, every query against a table is a sequential scan: PostgreSQL reads every block, checks every row against the query’s conditions, and discards the ones that don’t match. That’s fine for a table with a thousand rows; it’s ruinous for a table with a hundred million.

Indexes fix this by trading extra storage and extra write work for dramatically cheaper reads. Every INSERT, UPDATE, or DELETE on an indexed table must also update the index — sometimes several indexes — so the more indexes a table carries, the more expensive writes become. This is the central tension of indexing: index too little and reads are slow; index too much (or the wrong thing) and writes suffer while disk fills up with structures nobody queries. Good indexing strategy means understanding what index types PostgreSQL offers, what access pattern each one accelerates, and applying them deliberately based on the queries a table actually serves — not defensively on every column.

Unlike many relational databases that offer only B-tree, PostgreSQL ships six index access methods out of the box — B-tree, Hash, GIN, GiST, SP-GiST, and BRIN — each optimized for a different shape of data and a different kind of query. Picking the right one is as important as deciding whether to index at all.

Fundamentals

How a B-tree works, conceptually

The default and by far most common index type is the B-tree (balanced tree). Conceptually, it organizes indexed values into a tree of pages: a root page points to intermediate pages, which point to leaf pages that hold the actual indexed values in sorted order along with pointers (tuple IDs) back to the corresponding heap rows. Because the tree is kept balanced and shallow — height grows logarithmically with the number of rows — looking up a value takes roughly O(log n) page reads regardless of table size. A table with a billion rows might only need 4-5 page reads to find a match, versus scanning the whole table.

Because entries are stored in sorted order, B-tree naturally supports not just equality (=) but ordering comparisons (<, <=, >, >=), BETWEEN, IN, IS NULL, pattern matching anchored at the start of a string (LIKE 'abc%' under the C locale or with the right operator class), and ORDER BY/MIN/MAX without a separate sort step. This versatility is why B-tree is the default for CREATE INDEX and the right first choice for most scalar columns — primary keys, foreign keys, timestamps, status codes, anything you filter or sort on with standard comparison operators.

-- Basic B-tree index (the default access method, so USING btree is implicit)
CREATE INDEX idx_orders_customer_id ON orders (customer_id);

-- Supports equality, range, and sorting on the same column
SELECT * FROM orders WHERE customer_id = 42;
SELECT * FROM orders WHERE customer_id BETWEEN 100 AND 200;
SELECT * FROM orders ORDER BY customer_id;

Hash indexes

Hash indexes store a hash of the indexed value rather than the value itself, and support only equality comparisons (=) — no ranges, no sorting. Historically they had a bad reputation: before PostgreSQL 10, hash indexes were not WAL-logged, meaning they weren’t crash-safe and had to be rebuilt (REINDEX) after a crash or on standby servers, and they weren’t replicated. Since PostgreSQL 10, hash indexes are fully WAL-logged, crash-safe, and replicate normally, so that historical objection no longer applies.

Even so, hash indexes are rarely the right choice today. A B-tree index handles equality lookups just as efficiently as a hash index while also supporting range queries, sorting, and multi-column prefixes — so there is essentially no query pattern where hash wins and B-tree loses, but plenty where B-tree wins and hash loses (any future range query, any ORDER BY, any composite index need). Hash indexes can theoretically be marginally smaller and faster for pure high-cardinality equality lookups in some workloads, but this is rarely worth losing B-tree’s flexibility. In practice, reach for hash indexes only in narrow, measured cases — for the vast majority of equality-lookup needs, use B-tree.

-- Rarely needed in practice — shown for completeness
CREATE INDEX idx_sessions_token_hash ON sessions USING hash (token);

SELECT * FROM sessions WHERE token = 'abc123...';  -- equality only, no range support

Key Concepts

GIN — Generalized Inverted Index

GIN is built for columns that store composite values — values containing multiple component “keys” that you want to search into, such as elements of an array, keys/values of a JSONB document, or lexemes of a tsvector. Internally, GIN inverts the relationship: instead of one index entry per row, it creates one index entry per component value, each pointing to all the rows containing it. This makes GIN extremely fast for “does this row contain X” style queries — containment (@>), overlap (&&), full-text search (@@) — at the cost of larger index size and slower writes compared to B-tree (GIN uses a “pending list” to batch inserts and reduce write amplification, flushed by autovacuum or gin_pending_list_limit).

The most common real-world GIN use cases are full-text search over tsvector columns and containment queries over JSONB:

-- Indexing a JSONB column for containment queries
CREATE TABLE products (
    id serial PRIMARY KEY,
    name text NOT NULL,
    attributes jsonb NOT NULL
);

CREATE INDEX idx_products_attributes_gin ON products USING gin (attributes);

-- The GIN index accelerates the @> (contains) operator
SELECT * FROM products
WHERE attributes @> '{"color": "red", "in_stock": true}';

-- Full-text search: index a generated tsvector column
ALTER TABLE products ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (to_tsvector('english', name)) STORED;

CREATE INDEX idx_products_search_gin ON products USING gin (search_vector);

SELECT * FROM products
WHERE search_vector @@ to_tsquery('english', 'wireless & mouse');

For JSONB specifically, PostgreSQL offers two GIN operator classes: the default jsonb_ops, which indexes every key and value and supports @>, ?, ?&, ?|; and jsonb_path_ops, which indexes only value hashes derived from paths, produces a smaller index, and is faster for @> specifically but doesn’t support the ? family of operators. Choose jsonb_path_ops when containment is the only JSONB operator you need:

CREATE INDEX idx_products_attributes_pathops
    ON products USING gin (attributes jsonb_path_ops);

GiST — Generalized Search Tree

GiST is a balanced, tree-based access method — not a single algorithm but a framework that lets many different data types plug in their own notion of “nearby” or “overlapping,” which is why it powers geometric types, range types, full-text search (as an alternative to GIN, with faster updates but slower lookups), and nearest-neighbor (ORDER BY <distance> LIMIT n) queries via k-NN support.

The most important operational use of GiST for a working DBA is exclusion constraints, which prevent overlapping values across a set of rows — for example, ensuring two bookings for the same room can never have overlapping date ranges. Exclusion constraints are built on top of GiST because GiST natively understands “does A overlap B” for range and geometric types. See ./03-data-types-and-schema-objects.md for more on range types and how EXCLUDE USING gist is declared.

-- Geometric proximity / containment queries
CREATE INDEX idx_locations_point_gist ON locations USING gist (coordinates);

SELECT * FROM locations
ORDER BY coordinates <-> point(10.775, 106.700)
LIMIT 5;  -- nearest-neighbor search, accelerated by the GiST index

-- Range type indexing backing an exclusion constraint
CREATE TABLE room_bookings (
    room_id int NOT NULL,
    during tsrange NOT NULL,
    EXCLUDE USING gist (room_id WITH =, during WITH &&)
);

SP-GiST — Space-Partitioned GiST

SP-GiST supports search trees that are partitioned but not necessarily balanced, which suits data with a natural, non-uniform clustering — think tries, quad-trees, or k-d trees. It works well for data types where values naturally split into disjoint partitions rather than a continuous range: IP address prefixes/CIDR blocks, phone number prefixes, quad-tree-friendly 2D points, or text with common prefixes. It’s used far less often than B-tree, GIN, or GiST — most DBAs will go long stretches without needing it — but it’s the right tool when your data has this kind of natural, unbalanced partitioning structure.

CREATE INDEX idx_devices_ip_spgist ON devices USING spgist (ip_address inet_ops);

SELECT * FROM devices WHERE ip_address <<= '10.0.0.0/8';

BRIN — Block Range Index

BRIN takes a fundamentally different approach from every other index type: instead of storing an entry per row (or per value), it stores summary information — typically just the min and max value — per range of physical table blocks (the range size is configurable via pages_per_range, default 128). Because it summarizes ranges of blocks rather than individual rows, a BRIN index is tiny: often a few hundred kilobytes to a few megabytes for a table that would need gigabytes of B-tree. Maintenance cost is correspondingly tiny too, since updating a BRIN entry only requires touching one range’s summary rather than rebalancing a tree.

The catch — and it’s an important one — is that BRIN only helps when the indexed column is physically correlated with the table’s on-disk row order. The canonical case is an append-only, time-ordered table: if rows are inserted in created_at order and never reordered, then each block range naturally holds a narrow band of timestamps, so “min/max per range” is a highly effective filter (PostgreSQL can skip entire ranges that can’t possibly contain matching rows). If the indexed column has no correlation with physical order (e.g., a customer_id scattered randomly across the table by insert order), BRIN’s min/max per range covers almost the whole value range in every block, so it can’t eliminate any ranges and effectively degenerates into a full scan of the table — at that point BRIN provides no benefit and a B-tree should be used instead. Check correlation via SELECT correlation FROM pg_stats WHERE tablename = '...' AND attname = '...' — values near 1 or -1 indicate strong physical correlation, near 0 indicates BRIN won’t help.

-- Ideal BRIN candidate: an append-only, time-ordered events table
CREATE TABLE events (
    id bigserial PRIMARY KEY,
    created_at timestamptz NOT NULL,
    payload jsonb
);

CREATE INDEX idx_events_created_at_brin ON events USING brin (created_at);

-- Range queries on created_at benefit hugely, at a fraction of a B-tree's size
SELECT * FROM events
WHERE created_at >= '2026-07-01' AND created_at < '2026-07-08';

For a 500 GB append-only events table, a B-tree on created_at might occupy tens of gigabytes; the equivalent BRIN index might occupy a few megabytes, while still letting the planner skip the vast majority of blocks for a narrow date-range query.

Composite (multi-column) indexes and leftmost-prefix matching

A composite index spans multiple columns, and column order is not cosmetic — it determines which query shapes the index can serve. An index on (a, b) is stored sorted by a first, then by b within each value of a. That means it efficiently serves queries that filter on a alone, or on a AND b together, because in both cases the index can jump straight to the relevant a value(s) and then, if needed, narrow further by b. It does not efficiently serve a query that filters on b alone, because without knowing a the index has no way to narrow down to the interesting portion of the tree — that access pattern degrades to a full index scan.

CREATE INDEX idx_orders_customer_status ON orders (customer_id, status);

-- Uses the index efficiently: filters on the leading column
SELECT * FROM orders WHERE customer_id = 42;

-- Uses the index efficiently: filters on both columns, leftmost prefix intact
SELECT * FROM orders WHERE customer_id = 42 AND status = 'shipped';

-- Does NOT use this index efficiently: skips the leading column
SELECT * FROM orders WHERE status = 'shipped';

This is called leftmost-prefix matching: an index on (a, b, c) can serve queries on (a), (a, b), or (a, b, c), but not on (b), (c), or (b, c) alone. When designing composite indexes, put the column with the most common single-column filter (or the highest selectivity) first, and order subsequent columns to match your most frequent multi-column query shapes. If both WHERE customer_id = ? and WHERE status = ? need to be fast independently, you generally need two separate indexes rather than one composite index serving both.

Partial indexes

A partial index only includes rows matching a WHERE clause specified at index-creation time, rather than every row in the table. This is valuable when queries consistently target a known, often small, subset of rows — active users out of a mostly-inactive user base, pending orders out of a mostly-completed order history, or unarchived tickets out of a table that never deletes old ones. The resulting index is smaller (faster to scan, cheaper to maintain, less RAM needed to keep it cached) and write overhead is reduced because rows outside the predicate never touch the index at all.

CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';

-- The planner will use this narrow, cheap index for queries with a matching
-- (or provably compatible) predicate
SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at;

Partial indexes are also the standard way to enforce a uniqueness constraint over a subset of rows — e.g., only one active row per key, while historical/inactive rows are allowed to repeat:

CREATE UNIQUE INDEX idx_users_active_email ON users (email)
WHERE deleted_at IS NULL;

Expression (functional) indexes

An expression index stores the result of an expression rather than a raw column value, which lets PostgreSQL use an index for queries that filter on a computed or transformed value. The classic example is case-insensitive email lookup:

CREATE INDEX idx_users_email_lower ON users (lower(email));

-- Benefits from the index because the expression matches exactly
SELECT * FROM users WHERE lower(email) = 'jane@example.com';

-- Does NOT benefit — different expression, planner won't match it to the index above
SELECT * FROM users WHERE email = 'jane@example.com';

The crucial rule is that the query must use the exact same expression the index was built on (modulo some immutability and casting equivalences the planner can resolve) for the planner to consider the index. This is also why the function used in an expression index must be IMMUTABLE — PostgreSQL needs a guarantee that the same input always produces the same output, since the computed value is physically stored in the index and never re-evaluated at read time.

Covering indexes with INCLUDE

Normally, even when an index can locate matching rows via its key columns, PostgreSQL still has to visit the heap (the table’s actual row storage) to fetch any columns the query needs that aren’t part of the index key — unless the visibility map confirms the page is all-visible, in which case an index-only scan can skip the heap fetch entirely for the key columns. The INCLUDE clause, added in PostgreSQL 11, lets you attach extra “payload” columns to a B-tree index that aren’t part of the search key (so they don’t affect sort order or which operators the index supports) but are stored in the index and thus available for index-only scans.

CREATE INDEX idx_orders_customer_covering
    ON orders (customer_id)
    INCLUDE (status, total_amount);

-- If the visibility map is up to date, this can be satisfied entirely from
-- the index — no heap fetch needed
SELECT customer_id, status, total_amount
FROM orders
WHERE customer_id = 42;

INCLUDE columns are useful when a query’s SELECT list needs a small number of extra columns beyond the filter columns, and you want to enable a true index-only scan without bloating the sort key (which would also change what operators/queries the index can serve and could add unwanted uniqueness constraints in a unique index). Note that INCLUDE columns still take up index space and still need updating on writes to those columns, so this is a size/write tradeoff like any other index decision, not a free win.

Best Practices

Index deliberately, not defensively

The single most important best practice is to index based on how a table is actually queried, not on a hunch that “this column might be searched on someday.” Every index adds write overhead and storage; a table with ten indexes that only five queries actually use is paying a real, ongoing cost for no benefit. Use EXPLAIN (ANALYZE, BUFFERS) on representative queries to see what the planner is actually doing and whether a sequential scan is genuinely a problem before adding an index to fix it — see ./10-query-planning-and-performance-tuning.md for how to read query plans and make index decisions from evidence rather than guesswork. pg_stat_user_indexes and pg_stat_user_tables are also useful for finding indexes that are never scanned (idx_scan = 0 after a representative period) and are candidates for removal.

Match the index type to the query pattern

Reach for B-tree by default; it covers the overwhelming majority of scalar equality/range/sort needs. Move to GIN when a column holds composite values you need to search into — arrays, JSONB, full-text. Move to GiST for geometric data, range overlap, k-NN, or exclusion constraints. Consider BRIN only when a huge table has a column that is naturally, physically correlated with insertion order — verify with pg_stats.correlation before committing to it, since BRIN is actively harmful (all downside, no benefit) on uncorrelated data. Avoid hash indexes unless you have a specific, measured reason B-tree doesn’t meet — which is rare.

Design composite indexes around real query shapes

Look at the actual WHERE clauses your application issues and order composite index columns so the most selective, most commonly-filtered-alone column comes first, respecting leftmost-prefix matching. Don’t build a wide composite index speculatively covering many columns “just in case” — it will be large, slow to maintain, and still only serve prefix-matching queries.

Use partial and expression indexes to shrink the common case

If most queries filter to a known, stable subset of rows (status = 'active', deleted_at IS NULL), a partial index on that subset is usually better than a full index — smaller, faster, cheaper to maintain. If queries consistently wrap a column in a function (lower(), date_trunc(), JSON extraction), an expression index targeting exactly that expression will be used; a plain index on the raw column will not.

Watch write amplification on heavily-indexed tables

Every additional index on a table adds work to every INSERT/UPDATE/DELETE, and UPDATE in particular can be expensive if it touches indexed columns and prevents a Heap-Only Tuple (HOT) update — see ./17-schema-design-patterns-and-antipatterns.md for schema-level patterns that affect update cost, HOT eligibility, and bloat. On write-heavy tables, periodically audit indexes for redundancy (e.g., a single-column index made redundant by a composite index sharing the same leading column) and drop what isn’t earning its keep.

Build indexes without blocking writes

On a live production table, use CREATE INDEX CONCURRENTLY to avoid holding a lock that blocks writes for the duration of the build. It takes longer and can’t run inside a transaction block, but it’s the standard way to add or rebuild an index on a table that can’t afford downtime.

CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders (customer_id);

Comparison table

Index typeBest forTypical use case
B-treeEquality, range comparisons, sorting on scalar-ish valuesPrimary/foreign keys, timestamps, status codes, general WHERE/ORDER BY
HashPure equality lookupsRarely the right choice — B-tree covers equality plus more; use only with a specific measured reason
GINComposite/multi-valued data, “does this contain X”Full-text search (tsvector), JSONB containment (@>), array containment
GiSTGeometric data, overlap/containment, nearest-neighbor, exclusion constraintsGeospatial queries, range-overlap exclusion constraints, k-NN search
SP-GiSTNaturally partitioned, non-uniform dataIP/CIDR prefixes, phone number prefixes, quad-tree-style spatial data
BRINHuge tables with a column physically correlated to row orderAppend-only, time-ordered event/log tables filtered by timestamp range

References