Chapter sebelumnya melatih kita membaca execution plan sebagai pohon: node paling dalam dieksekusi lebih dulu, baris mengalir dari dalam ke luar. Sekarang kita bedah perintah yang mencetak pohon itu — EXPLAIN — sampai ke detail angkanya. Perintah ini adalah stetoskop sehari-hari: murah dipakai, aman untuk query baca, dan menjadi gerbang sebelum setiap keputusan optimasi. Tiga pertanyaan akan terjawab di chapter ini: bagaimana bentuk sintaksnya, apa arti satuan biaya yang bukan milidetik, dan bagaimana membedakan baris Filter: dari Index Cond: — dua baris yang sering salah dibaca pemula.

sql buat-tabel.sql
-- Tabel tunggal 50.000 order penjualan dengan dua index:
-- satu pada kolom pelanggan_id, satu pada kolom total.
CREATE TABLE order_penjualan (
    id           bigint PRIMARY KEY,
    pelanggan_id integer NOT NULL,
    tanggal      date NOT NULL,
    total        numeric(12,2) NOT NULL,
    status       text NOT NULL
);

INSERT INTO order_penjualan (id, pelanggan_id, tanggal, total, status)
SELECT g,
       1 + (g * 37) % 5000,
       DATE '2026-01-01' + (g % 180),
       ((g % 900) * 1000 + 25000)::numeric(12,2),
       (ARRAY['lunas','diproses','menunggu','batal'])[1 + g % 4]
FROM generate_series(1, 50000) AS g;

CREATE INDEX idx_order_pelanggan ON order_penjualan (pelanggan_id);
CREATE INDEX idx_order_total ON order_penjualan (total);

ANALYZE order_penjualan;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 50000
CREATE INDEX
CREATE INDEX
ANALYZE

Sintaks Dasar #

Bentuknya sederhana: tulis EXPLAIN di depan query apa pun, lalu jalankan. EXPLAIN tidak mengeksekusi query — ia hanya meminta planner menyusun rencana lalu mencetaknya. Itulah sebabnya menjalankan EXPLAIN pada SELECT raksasa tidak memakan waktu: yang dikerjakan hanya perencanaan, bukan eksekusi. Perintah ini juga berlaku untuk INSERT, UPDATE, dan DELETE, sehingga kita bisa mengintip rencana tulis yang mahal tanpa menulis satu baris pun. Contoh paling sederhana adalah menghitung seluruh isi tabel:

-- Rencana paling sederhana: membaca seluruh tabel.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT count(*) FROM order_penjualan;

Output yang Dihasilkan:

text
                                QUERY PLAN                                 
---------------------------------------------------------------------------
 Aggregate  (cost=993.00..993.01 rows=1 width=8)
   ->  Seq Scan on order_penjualan  (cost=0.00..868.00 rows=50000 width=0)
(2 rows)

Rencana dua node ini layak dibedah habis karena pola angkanya berulang di semua rencana lain:

  1. cost=0.00..868.00 pada Seq Scan: biaya startup 0 dan biaya total 868. Seq scan bisa mengeluarkan baris pertama seketika, makanya startup-nya nol.
  2. Angka 868 bisa dihitung dari parameter planner: tabel ini memuat ±368 halaman dan 50.000 baris. Biaya membaca satu halaman secara sekuensial (seq_page_cost) bernilai 1, dan memproses satu baris (cpu_tuple_cost) bernilai 0,01 — sehingga 368 × 1 + 50.000 × 0,01 = 868. Satuan biaya bukan milidetik, melainkan unit arbitrer yang berjangkar ke biaya halaman.
  3. rows=50000: perkiraan jumlah baris yang dihasilkan node — tepat, karena tanpa WHERE semua baris keluar.
  4. width=0: perkiraan lebar rata-rata satu baris keluaran dalam byte. Nol karena count(*) tidak membaca kolom apa pun.
  5. cost=993.00..993.01 pada Aggregate: startup cost-nya sama dengan total cost anaknya (868 + sedikit biaya CPU) karena agregat harus menelan seluruh masukan sebelum menghasilkan satu baris hasil.

Filter versus Index Cond #

Sekarang tambahkan WHERE pada kolom yang tidak punya index. Perhatikan baris Filter: yang muncul:

-- Filter pada kolom tanpa index: semua baris dibaca, lalu disaring.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT count(*) FROM order_penjualan WHERE status = 'lunas';

Output yang Dihasilkan:

text
                                QUERY PLAN                                 
---------------------------------------------------------------------------
 Aggregate  (cost=1024.28..1024.29 rows=1 width=8)
   ->  Seq Scan on order_penjualan  (cost=0.00..993.00 rows=12513 width=0)
         Filter: (status = 'lunas'::text)
(3 rows)

Baris Filter: (status = 'lunas') berarti: node scan membaca setiap baris tabel, lalu menguji kondisinya satu per satu. Biaya totalnya naik dari 868 menjadi 993 — selisih 125 kira-kira adalah 50.000 evaluasi kondisi (cpu_operator_cost 0,0025 per evaluasi). Perkiraan rows=12587 berasal dari statistik kolom status: seperempat dari 50.000 adalah 12.500, dan sampel statistik menghasilkan angka yang sedikit menyimpang di sekitarnya.

Bandingkan dengan WHERE pada kolom yang punya index:

-- Filter pada kolom ber-index: kondisi menjadi Index Cond,
-- kunjungan ke halaman tabel jauh berkurang.
-- Untuk query ini planner memilih Bitmap Scan; kita matikan dulu agar
-- bentuk dasar Index Scan terlihat (Bitmap Scan dibahas khusus
-- pada chapter Sequential Scan vs Index Scan).
-- Angka biaya dapat sedikit berbeda di mesin Anda.
SET enable_bitmapscan = off;

EXPLAIN
SELECT id, tanggal, total
FROM order_penjualan
WHERE pelanggan_id = 777;

Output yang Dihasilkan:

text
SET
                                          QUERY PLAN                                          
----------------------------------------------------------------------------------------------
 Index Scan using idx_order_pelanggan on order_penjualan  (cost=0.29..44.46 rows=10 width=18)
   Index Cond: (pelanggan_id = 777)
(2 rows)

Perbedaannya tegas. Index Scan using idx_order_pelanggan mengumumkan index yang dipakai, dan Index Cond: (pelanggan_id = 777) menyatakan kondisi itu menjadi kondisi pencarian: PostgreSQL menuruni struktur index, mendarat hanya pada 10 entri yang cocok, lalu melompat ke 10 baris tabel terkait — bukan membaca 368 halamen penuh. Perkiraan rows=10 juga jauh lebih presisi: 50.000 order tersebar atas 5.000 pelanggan, rata-rata tepat 10 order per pelanggan.

Aturan bacanya: Filter: menandakan kondisi yang diterapkan setelah baris dibaca; Index Cond: menandakan kondisi yang memangkas pembacaan itu sendiri. Sebuah rencana bisa memuat keduanya sekaligus — misalnya index memangkas rentang tanggal, lalu Filter: menyaring kolom lain yang tidak terindeks. Yang perlu diwaspadai adalah Filter: yang berisi kondisi yang seharusnya bisa dilayani index — itu pertanda index tidak terpakai.

EXPLAIN untuk Query Agregat #

Agregat dengan GROUP BY menambahkan node HashAggregate — tabel hash kecil yang menyatukan baris per kelompok:

-- Rencana agregat dengan GROUP BY: HashAggregate menyatukan baris per kelompok.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT status, count(*) AS jumlah
FROM order_penjualan
GROUP BY status;

Output yang Dihasilkan:

text
                                QUERY PLAN                                 
---------------------------------------------------------------------------
 HashAggregate  (cost=1118.00..1118.04 rows=4 width=15)
   Group Key: status
   ->  Seq Scan on order_penjualan  (cost=0.00..868.00 rows=50000 width=7)
(3 rows)

Perhatikan rows=4 pada HashAggregate: planner tahu hanya ada 4 nilai status berbeda karena statistik kolom mencatat jumlah nilai unik (n_distinct). Perkiraan sekecil ini membuat node berikutnya — kalau ada — dihitung dengan akurat. Chapter Statistik dan ANALYZE akan menunjukkan dari mana angka-angka semacam ini berasal.

Format JSON untuk Alat Bantu #

Teks berindentasi nyaman dibaca manusia, tetapi alat visual (penampil rencana di pgAdmin atau situs pembaca plan) lebih suka struktur. Opsi FORMAT JSON mencetak rencana sebagai dokumen JSON dengan nama field yang tetap:

sql format-json.sql
-- FORMAT JSON: rencana sebagai dokumen terstruktur untuk alat visual.
-- COSTS OFF dipakai agar output stabil; tanpa opsi itu, angka biaya
-- ikut tercetak dalam bentuk "Total Cost" dan kawan-kawannya.
EXPLAIN (FORMAT JSON, COSTS OFF)
SELECT count(*)
FROM order_penjualan
WHERE pelanggan_id = 777;

Output yang Dihasilkan:

text
                     QUERY PLAN                     
----------------------------------------------------
 [                                                 +
   {                                               +
     "Plan": {                                     +
       "Node Type": "Aggregate",                   +
       "Strategy": "Plain",                        +
       "Partial Mode": "Simple",                   +
       "Parallel Aware": false,                    +
       "Async Capable": false,                     +
       "Plans": [                                  +
         {                                         +
           "Node Type": "Bitmap Heap Scan",        +
           "Parent Relationship": "Outer",         +
           "Parallel Aware": false,                +
           "Async Capable": false,                 +
           "Relation Name": "order_penjualan",     +
           "Alias": "order_penjualan",             +
           "Recheck Cond": "(pelanggan_id = 777)", +
           "Plans": [                              +
             {                                     +
               "Node Type": "Bitmap Index Scan",   +
               "Parent Relationship": "Outer",     +
               "Parallel Aware": false,            +
               "Async Capable": false,             +
               "Index Name": "idx_order_pelanggan",+
               "Index Cond": "(pelanggan_id = 777)"+
             }                                     +
           ]                                       +
         }                                         +
       ]                                           +
     }                                             +
   }                                               +
 ]
(1 row)

Struktur JSON memetakan pohon dengan jelas: objek "Plan" berisi node teratas, larik "Plans" berisi anak-anaknya, dan setiap node membawa field seperti "Node Type", "Relation Name", serta kondisi recheck dan index cond. Alat visual biasanya memakai format ini untuk menggambar pohon yang bisa dilipat-lipat dan dihitung total biayanya secara otomatis.

\timing Bukan Alat Planner #

Meta-perintah psql \timing on sering disalahpahami sebagai pengukur performa rencana. Kenyataannya \timing mengukur waktu dinding di sisi client: termasuk perjalanan jaringan, parsing, perencanaan, hingga eksekusi. Satuan biaya EXPLAIN dan milidetik \timing tinggal di dua dunia berbeda — yang pertama model perencanaan, yang kedua pengukuran end-to-end. Keduanya berguna untuk tujuan berbeda; cara mengukur yang benar dibahas pada chapter Mengukur Performa Query di ujung section ini.

Yang belum bisa dilakukan EXPLAIN adalah membenarkan perkiraannya: apakah rows=10 benar-benar 10 di lapangan? Jawaban itu hanya dimiliki query yang benar-benar dieksekusi — tugas EXPLAIN ANALYZE, chapter berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

buat-tabel.sql

Unduh berkas
sql
-- Tabel tunggal 50.000 order penjualan dengan dua index:
-- satu pada kolom pelanggan_id, satu pada kolom total.
CREATE TABLE order_penjualan (
    id           bigint PRIMARY KEY,
    pelanggan_id integer NOT NULL,
    tanggal      date NOT NULL,
    total        numeric(12,2) NOT NULL,
    status       text NOT NULL
);

INSERT INTO order_penjualan (id, pelanggan_id, tanggal, total, status)
SELECT g,
       1 + (g * 37) % 5000,
       DATE '2026-01-01' + (g % 180),
       ((g % 900) * 1000 + 25000)::numeric(12,2),
       (ARRAY['lunas','diproses','menunggu','batal'])[1 + g % 4]
FROM generate_series(1, 50000) AS g;

CREATE INDEX idx_order_pelanggan ON order_penjualan (pelanggan_id);
CREATE INDEX idx_order_total ON order_penjualan (total);

ANALYZE order_penjualan;

format-json.sql

Unduh berkas
sql
-- FORMAT JSON: rencana sebagai dokumen terstruktur untuk alat visual.
-- COSTS OFF dipakai agar output stabil; tanpa opsi itu, angka biaya
-- ikut tercetak dalam bentuk "Total Cost" dan kawan-kawannya.
EXPLAIN (FORMAT JSON, COSTS OFF)
SELECT count(*)
FROM order_penjualan
WHERE pelanggan_id = 777;

rencana-agregat.sql

Unduh berkas
sql
-- Rencana agregat dengan GROUP BY: HashAggregate menyatukan baris per kelompok.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT status, count(*) AS jumlah
FROM order_penjualan
GROUP BY status;

rencana-filter.sql

Unduh berkas
sql
-- Filter pada kolom tanpa index: semua baris dibaca, lalu disaring.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT count(*) FROM order_penjualan WHERE status = 'lunas';

rencana-index.sql

Unduh berkas
sql
-- Filter pada kolom ber-index: kondisi menjadi Index Cond,
-- kunjungan ke halaman tabel jauh berkurang.
-- Untuk query ini planner memilih Bitmap Scan; kita matikan dulu agar
-- bentuk dasar Index Scan terlihat (Bitmap Scan dibahas khusus
-- pada chapter Sequential Scan vs Index Scan).
-- Angka biaya dapat sedikit berbeda di mesin Anda.
SET enable_bitmapscan = off;

EXPLAIN
SELECT id, tanggal, total
FROM order_penjualan
WHERE pelanggan_id = 777;

rencana-sekuensial.sql

Unduh berkas
sql
-- Rencana paling sederhana: membaca seluruh tabel.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT count(*) FROM order_penjualan;