Referensi cepat database indexing. B-tree, Hash, GIN, GiST, BRIN, partial index, composite index, EXPLAIN ANALYZE. Perfect buat developer yang mau optimalkan query PostgreSQL.
Login atau daftar akun gratis untuk membaca cheat sheet ini.
Cheat sheet ini membahas segala hal tentang database indexing di PostgreSQL, mulai dari tipe index, kapan pakai, sampai cara debugging dengan EXPLAIN ANALYZE. Index yang tepat bisa membuat query kamu cepat puluhan kali lipat.
Index adalah struktur data terpisah dari tabel yang mempercepat pencarian baris. Tanpa index, database harus melakukan sequential scan (membaca seluruh tabel). Dengan index, database bisa langsung loncat ke baris yang relevan.
Setiap index di PostgreSQL punya:
-- Index pada satu kolom
CREATE INDEX idx_users_email ON users(email);
-- Index unik (mencegah duplikat)
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);
-- Index dengan nama custom
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);
-- Index dengan expression
CREATE INDEX idx_users_lower_email ON users(LOWER(email));
-- Index dengan operator class
CREATE INDEX idx_users_email_ci ON users(LOWER
Secara default, CREATE INDEX mengunci tabel untuk write. Pakai CONCURRENTLY untuk membuat index tanpa blocking write, tapi lebih lambat dan tidak bisa dijalankan dalam transaction block.
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);B-tree adalah tipe index default di PostgreSQL. Cocok untuk query dengan operator =, <, >, BETWEEN, IN, IS NULL, dan LIKE dengan prefix.
-- B-tree adalah default, tidak perlu specify
CREATE INDEX idx_users_age ON users(age);
-- Explicit B-tree
CREATE INDEX idx_users_age ON users USING btree(age);Kapan pakai B-tree:
ORDER BY)LIKE 'prefix%' (hanya prefix)Hash index hanya mendukung equality (=) comparison. Sebelum PostgreSQL 10, hash index tidak crash-safe. Sekarang sudah aman dipakai.
CREATE INDEX idx_sessions_token ON sessions USING hash(token);Kapan pakai Hash:
GIN dirancang untuk data yang berisi multiple komponen, seperti array dan full-text search. Satu baris bisa punya banyak index entries.
-- Full-text search
CREATE INDEX idx_articles_fts ON articles USING gin(to_tsvector('english', title || ' ' || body));
-- Array contains
CREATE INDEX idx_posts_tags ON posts USING gin(tags);
-- JSONB
CREATE INDEXKapan pakai GIN:
tsvector)@>, &&)@>, ?, ?|)GiST adalah infrastruktur untuk index tipe data kompleks seperti geometri, range, dan full-text search dengan ranking.
-- Geospatial (PostGIS)
CREATE INDEX idx_locations_geom ON locations USING gist(geom);
-- Range type
CREATE INDEX idx_events_period ON events USING gist(during);
-- Exclusion constraint
CREATE TABLE reservations (
id serial
Kapan pakai GiST:
<->, &&, <<)&&)LIKE dan ILIKEBRIN menyimpan ringkasan (min, max) untuk setiap block range tabel. Sangat kecil (kilobyte vs megabyte untuk B-tree) dan cepat dibuat.
CREATE INDEX idx_logs_timestamp ON logs USING brin(timestamp);Kapan pakai BRIN:
SP-GiST untuk data yang secara natural berbentuk tree atau partisi non-seimbang, seperti kd-tree, trie, atau quadtree.
CREATE INDEX idx_words ON dictionary USING spgist(word);| Tipe | Operator | Ukuran | Cocok Untuk |
|---|---|---|---|
| B-tree | =, <, >, BETWEEN, LIKE 'prefix%' | Sedang | General purpose, range, sort |
| Hash | = | Kecil | Equality saja, key panjang |
| GIN | @>, ?, tsvector | Besar | Array, JSONB, FTS |
| GiST | &&, <->, << | Sedang | Geometri, range, trigram |
| BRIN | =, <, > | Sangat kecil | Log append-only, tabel besar |
| SP-GiST | Tergantung | Sedang | Tree-like, trie |
Composite index adalah index pada multiple kolom. Urutan kolom sangat penting karena menentukan kapan index bisa dipakai.
-- Index pada dua kolom
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- Index ini optimal untuk:
SELECT * FROM orders WHERE user_id = 123 AND status = 'paid';
SELECT * FROM orders
= condition di paling depan<, >, BETWEEN di akhirORDER BY dan WHERE yang sering dipakaiPostgreSQL mendukung sampai 32 kolom dalam satu index, tapi lebih dari 3-4 kolom jarang efisien. Pertimbangkan index terpisah.
Partial index hanya mengindex subset baris yang memenuhi kondisi WHERE. Lebih kecil dan cepat untuk query spesifik.
-- Index hanya untuk order yang belum lunas
CREATE INDEX idx_orders_unpaid ON orders(user_id) WHERE status = 'pending';
-- Index hanya untuk user aktif
CREATE INDEX idx_users_active ON users(email) WHERE deleted_at IS NULL;
-- Index untuk high-value orders
Partial index akan dipakai otomatis jika query kamu menyertakan kondisi yang sama atau lebih spesifik.
Covering index menyertakan kolom tambahan dengan INCLUDE clause sehingga query bisa dijawab hanya dari index tanpa mengakses tabel (index-only scan).
-- Index covering untuk query yang butuh user_id dan amount
CREATE INDEX idx_orders_covering ON orders(user_id) INCLUDE (amount, status);
-- Query ini bisa pakai index-only scan
SELECT amount, status FROM orders WHERE user_id = 123;| Aspek | Composite ((a, b, c)) | Covering ((a) INCLUDE (b, c)) |
|---|---|---|
| Bisa filter? | Ya, semua kolom | Hanya kolom key (a) |
| Bisa sort? | Ya, sesuai urutan | Tidak, kolom INCLUDE tidak sort |
| Bisa SELECT? | Ya | Ya |
| Ukuran | Lebih besar | Sedikit lebih besar dari key-only |
Index-only scan terjadi saat query bisa dijawab sepenuhnya dari index tanpa membaca tabel. Ini sangat cepat karena menghindari disk I/O ke tabel.
Syarat index-only scan:
-- Pastikan visibility map up to date
VACUUM users;EXPLAIN ANALYZE menjalankan query dan menampilkan rencana eksekusi beserta waktu aktual. Ini tool paling penting untuk debugging performance.
-- Query plan tanpa eksekusi
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';
-- Query plan dengan eksekusi dan statistik waktu
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com'
Plan dibaca dari dalam ke luar (indentasi terdalam dieksekusi duluan).
EXPLAIN ANALYZE SELECT u.name, COUNT(o.id)
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
Output yang umum:
HashAggregate (cost=12.50..15.00 rows=100 width=40) (actual time=2.1..2.5 rows=85 loops=1)
Hash Join (cost=8.0..12.0 rows=200 width=40) (actual time=1.0..1.8 rows=200 loops=1)
Hash Cond: (o.user_id = u.id)
-> Seq Scan on orders o (cost=0..5.0 rows=200 width=8) (actual time=0.01..0.3 rows=200 loops=1)
-> Hash (cost=6.0..6.0 rows=100 width=36) (actual time=0.5..0.5 rows=100 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 12kB
-> Seq Scan on users u (cost=0..6.0 rows=100 width=36) (actual time=0.01..0.3 rows=100 loops=1)
Filter: (status = 'active')| Node Type | Arti |
|---|---|
Seq Scan | Sequential scan, baca seluruh tabel |
Index Scan | Baca index lalu akses tabel |
Index Only Scan | Hanya baca index, tanpa akses tabel |
Bitmap Index Scan | Bitmap dari index, lalu akses tabel |
Bitmap Heap Scan | Akses tabel berdasarkan bitmap |
Hash Join | Hash table untuk join |
Nested Loop | Loop nested untuk join |
Merge Join | Merge untuk join (butuh data terurut) |
Sort | Pengurutan data |
-- Index yang tidak pernah dipakai
SELECT schemaname, relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
-- Index duplikat atau redundant
SELECT pg_size_pretty(sum(pg_relation_size(idx))::
Index bisa bloat (membengkak) seiring waktu karena update dan delete. REINDEX membangun ulang index.
-- Reindex satu index
REINDEX INDEX idx_users_email;
-- Reindex seluruh tabel
REINDEX TABLE users;
-- Reindex seluruh database
REINDEX DATABASE mydb;
-- Reindex tanpa blocking (PostgreSQL 12+)
REINDEX INDEX CONCURRENTLY idx_users_email;-- Vacuum biasa
VACUUM users;
-- Vacuum dengan analisis statistik
VACUUM ANALYZE users;
-- Vacuum penuh (rebuild tabel, mengunci tabel)
VACUUM FULL users;
-- Autovacuum setting
ALTER TABLE users SET (autovacuum_vacuum_scale_factor = 0.1);
ALTER TABLE users SETSELECT schemaname, relname, indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) as index_size,
idx_scan as index_scans
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;-- LIKE dengan prefix: pakai text_pattern_ops
CREATE INDEX idx_users_name ON users(name varchar_pattern_ops);
-- Sekarang LIKE 'John%' bisa pakai index
-- ILIKE case-insensitive: pakai LOWER
CREATE INDEX idx_users_name_ci ON users(LOWER(name) varchar_pattern_ops);
-- Sekarang LOWER(name) LIKE 'john%' bisa pakai index
-- GIN index untuk JSONB
CREATE INDEX idx_events_data ON events USING gin(data);
-- jsonb_path_ops lebih kecil tapi hanya dukung @>
CREATE INDEX idx_events_data_path ON events USING gin(data jsonb_path_ops);
-- B-tree index untuk key spesifik
CREATE INDEX idx_events_type-- GIN index untuk array containment
CREATE INDEX idx_posts_tags ON posts USING gin(tags);
SELECT * FROM posts WHERE 'javascript' = ANY(tags);
SELECT * FROM posts WHERE tags && ARRAY['javascript', 'python'];
SELECT *CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- B-tree index untuk UUID
CREATE INDEX idx_sessions_uuid ON sessions USING btree(session_id);
-- Untuk UUID v7 (sortable) atau UUID v4 (random)
-- UUID v7 lebih baik untuk index locality karena sequentialUntuk join yang cepat, pastikan kolom join punya index.
-- Index pada foreign key
CREATE INDEX idx_orders_user_id ON orders(user_id);
SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE uHashAggregate | Agregasi dengan hash |
Gather | Parallel worker |