Delapan chapter section ini membangun seperangkat alat yang kuat: menyimpan dokumen, membacanya, mengubahnya, merakitnya, bahkan mengindeksnya. Sekarang saatnya pertanyaan yang paling menentukan: perlukah data ini disimpan sebagai jsonb sama sekali? Godaan “schemaless” itu nyata — tanpa ALTER TABLE, semua atribut tinggal ditulis ke dokumen. Godaan itu juga yang melahirkan tabel monster: kolom detail yang diam-diam menjadi satu-satunya tempat hidup bagi harga dan kategori, dua field yang paling sengit dikueri. Chapter penutup ini menimbang keduanya lewat percobaan adil — data yang sama disimpan dengan dua skema — lalu menutup dengan pola produksi yang paling sering benar: kolom inti untuk jalur panas, jsonb untuk ekor panjang atribut.
Dua Meja yang Sama, Dua Skema Berbeda #
Agar perbandingannya jujur, kita isi dua tabel dengan 20.000 baris data yang identik. produk_rel memakai skema relational klasik: kategori sebagai foreign key ke tabel kategori, harga numeric dengan CHECK (harga > 0), berat sebagai integer. produk_json menampung semua atribut itu dalam satu kolom detail jsonb:
-- Percobaan adil: data yang SAMA disimpan dengan dua skema berbeda.
-- produk_rel: kolom relational dengan FK, CHECK, dan tipe yang tepat.
-- produk_json: satu kolom detail jsonb menampung semua atribut.
CREATE TABLE kategori (
id integer PRIMARY KEY,
nama text NOT NULL UNIQUE
);
INSERT INTO kategori (id, nama) VALUES
(1, 'elektronik'),
(2, 'pakaian'),
(3, 'makanan'),
(4, 'olahraga');
CREATE TABLE produk_rel (
id integer PRIMARY KEY,
nama text NOT NULL,
kategori_id integer NOT NULL REFERENCES kategori(id),
merek text NOT NULL,
harga numeric(10,2) NOT NULL CHECK (harga > 0),
berat_gram integer NOT NULL
);
CREATE TABLE produk_json (
id integer PRIMARY KEY,
nama text NOT NULL,
detail jsonb NOT NULL
);
INSERT INTO produk_rel (id, nama, kategori_id, merek, harga, berat_gram)
SELECT g,
'Produk ' || lpad(g::text, 5, '0'),
1 + (g % 4),
(ARRAY['Elang','Nusantara','Gunung Jaya'])[1 + (g % 3)],
((g % 40) + 1) * 2500,
(g % 500) + 100
FROM generate_series(1, 20000) AS g;
INSERT INTO produk_json (id, nama, detail)
SELECT g,
'Produk ' || lpad(g::text, 5, '0'),
jsonb_build_object(
'kategori', (ARRAY['elektronik','pakaian','makanan','olahraga'])[1 + (g % 4)],
'merek', (ARRAY['Elang','Nusantara','Gunung Jaya'])[1 + (g % 3)],
'harga', ((g % 40) + 1) * 2500,
'berat_gram', (g % 500) + 100)
FROM generate_series(1, 20000) AS g;
ANALYZE produk_rel;
ANALYZE produk_json;
-- Jumlah baris keduanya identik.
SELECT count(*) AS baris_rel,
(SELECT count(*) FROM produk_json) AS baris_json
FROM produk_rel;
-- Wujud data yang sama di dua skema.
SELECT id, nama, kategori_id, merek, harga, berat_gram
FROM produk_rel
WHERE id IN (1, 2)
ORDER BY id;
SELECT id, nama, detail
FROM produk_json
WHERE id IN (1, 2)
ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 4
CREATE TABLE
CREATE TABLE
INSERT 0 20000
INSERT 0 20000
ANALYZE
ANALYZE
baris_rel | baris_json
-----------+------------
20000 | 20000
(1 row)
id | nama | kategori_id | merek | harga | berat_gram
----+--------------+-------------+-------------+---------+------------
1 | Produk 00001 | 2 | Nusantara | 5000.00 | 101
2 | Produk 00002 | 3 | Gunung Jaya | 7500.00 | 102
(2 rows)
id | nama | detail
----+--------------+-----------------------------------------------------------------------------------
1 | Produk 00001 | {"harga": 5000, "merek": "Nusantara", "kategori": "pakaian", "berat_gram": 101}
2 | Produk 00002 | {"harga": 7500, "merek": "Gunung Jaya", "kategori": "makanan", "berat_gram": 102}
(2 rows)Dua baris contoh memperlihatkan data yang sama: kategori_id = 2 lawan "kategori": "pakaian", kolom numeric(10,2) lawan angka JSON 5000. Informasinya sama; yang berbeda adalah jaminan yang menyertainya — dan jaminan itu akan kita uji satu per satu.
Hasil yang Sama, Ergonomi yang Berbeda #
Query laporan berikut ditulis dua kali: versi kolom memakai JOIN dan agregat kolom biasa, versi jsonb mengekstrak kategori dan harga dari dokumen. Hasilnya empat baris yang sama:
-- Laporan yang sama, dua penulisan: kolom vs operator jsonb.
-- Versi kolom: JOIN ke tabel kategori, agregat kolom biasa.
SELECT k.nama AS kategori,
count(*) AS jumlah,
sum(r.harga) AS total_nilai
FROM produk_rel r
JOIN kategori k ON k.id = r.kategori_id
GROUP BY k.nama
ORDER BY k.nama;
-- Versi jsonb: kategori dan harga diekstrak dari dokumen.
SELECT detail ->> 'kategori' AS kategori,
count(*) AS jumlah,
sum((detail ->> 'harga')::numeric) AS total_nilai
FROM produk_json
GROUP BY detail ->> 'kategori'
ORDER BY detail ->> 'kategori';Output yang Dihasilkan:
kategori | jumlah | total_nilai
------------+--------+--------------
elektronik | 5000 | 237500000.00
makanan | 5000 | 262500000.00
olahraga | 5000 | 275000000.00
pakaian | 5000 | 250000000.00
(4 rows)
kategori | jumlah | total_nilai
------------+--------+-------------
elektronik | 5000 | 237500000
makanan | 5000 | 262500000
olahraga | 5000 | 275000000
pakaian | 5000 | 250000000
(4 rows)Kedua hasil identis kecuali satu detail: versi kolom menampilkan 237500000.00 karena numeric(10,2) menyimpan skala desimalnya, angka JSON kehilangan skala (237500000). Biaya ergonomisnya lebih terasa: versi jsonb menuntut GROUP BY pada ekspresi yang dihitung ulang per baris, plus sum((detail ->> 'harga')::numeric) yang harus mem-parse dokumen, mengekstrak teks, lalu me-cast-nya — per baris, setiap eksekusi. File berikut mengukur dampaknya dengan filter yang sama pada kedua tabel:
-- Filter yang sama pada dua skema. EXPLAIN ANALYZE memperlihatkan
-- bedanya: bentuk rencana sama (Seq Scan), tetapi setiap baris versi
-- jsonb menanggung biaya parse dokumen + ekstraksi teks + cast.
-- Angka waktu bervariasi antar mesin; perbandingan biaya dan
-- kecenderungannya yang stabil.
EXPLAIN ANALYZE SELECT count(*)
FROM produk_rel
WHERE kategori_id = 2 AND harga > 50000;
EXPLAIN ANALYZE SELECT count(*)
FROM produk_json
WHERE detail ->> 'kategori' = 'pakaian'
AND (detail ->> 'harga')::numeric > 50000;Output yang Dihasilkan:
QUERY PLAN
------------------------------------------------------------------------------------------------------------------
Aggregate (cost=492.25..492.26 rows=1 width=8) (actual time=2.425..2.425 rows=1 loops=1)
-> Seq Scan on produk_rel (cost=0.00..486.00 rows=2500 width=0) (actual time=0.016..2.312 rows=2500 loops=1)
Filter: ((harga > '50000'::numeric) AND (kategori_id = 2))
Rows Removed by Filter: 17500
Planning Time: 0.227 ms
Execution Time: 2.454 ms
(6 rows)
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=1256.11..1256.12 rows=1 width=8) (actual time=5.121..5.122 rows=1 loops=1)
-> Seq Scan on produk_json (cost=0.00..1256.03 rows=33 width=0) (actual time=0.454..5.003 rows=2500 loops=1)
Filter: (((detail ->> 'kategori'::text) = 'pakaian'::text) AND (((detail ->> 'harga'::text))::numeric > '50000'::numeric))
Rows Removed by Filter: 17501
Planning Time: 0.127 ms
Execution Time: 5.140 ms
(6 rows)Bentuk rencananya sama — Seq Scan di kedua tabel karena filter menyentuh mayoritas data — tetapi biayanya hampir tiga kali lipat: 486 lawan 1256 unit untuk 2.500 baris hasil yang sama, dan waktu eksekusi versi jsonb konsisten sekitar dua kali lebih lambat di lingkungan uji. Filter kolom membaca nilai yang sudah bertipe; filter jsonb mem-parse dokumen lalu mengevaluasi dua ekspresi per baris. Chapter Indexing JSONB
menunjukkan ekspresi semacam ini bisa diindeks — tetapi expression index dibuat per path, dan kolom asli tetap lebih murah dibaca.
Yang Hanya Bisa Diberikan Kolom: Constraint dan Relasi #
Perbedaan performa tadi bisa dibeli dengan index; yang tidak bisa dibeli adalah jaminan integritas. Cobalah memasukkan harga negatif ke kedua tabel:
-- Kolom menolak data yang melanggar constraint sejak pintu masuk.
INSERT INTO produk_rel (id, nama, kategori_id, merek, harga, berat_gram)
VALUES (20001, 'Produk Rusak', 1, 'Elang', -500, 200);Output yang Dihasilkan:
psql:harga-invalid.sql:3: ERROR: new row for relation "produk_rel" violates check constraint "produk_rel_harga_check"
DETAIL: Failing row contains (20001, Produk Rusak, 1, Elang, -500.00, 200).produk_rel menolak baris itu sejak INSERT: constraint produk_rel_harga_check menyala lengkap dengan baris yang gagal di DETAIL. Sekarang dokumen yang sama ke produk_json:
-- Dokumen jsonb menerima nilai yang sama tanpa protes --
-- harga negatif dan kategori yang salah ketik sama-sama lolos.
INSERT INTO produk_json (id, nama, detail)
VALUES (20001, 'Produk Rusak',
'{"kategori": "elektronikk", "merek": "Elang",
"harga": -500, "berat_gram": 200}');
SELECT id, nama,
detail ->> 'kategori' AS kategori,
(detail ->> 'harga')::numeric AS harga
FROM produk_json
WHERE id = 20001;
-- Fragmentasi senyap: "elektronikk" kini menjadi kategori baru
-- yang tidak akan pernah cocok dengan laporan "elektronik".
SELECT detail ->> 'kategori' AS kategori, count(*) AS jumlah
FROM produk_json
GROUP BY detail ->> 'kategori'
ORDER BY detail ->> 'kategori';Output yang Dihasilkan:
INSERT 0 1
id | nama | kategori | harga
-------+--------------+-------------+-------
20001 | Produk Rusak | elektronikk | -500
(1 row)
kategori | jumlah
-------------+--------
elektronik | 5000
elektronikk | 1
makanan | 5000
olahraga | 5000
pakaian | 5000
(5 rows)INSERT 0 1 — diterima tanpa protes. Harga -500 tersimpan, dan salah ketik "elektronikk" ikut lolos karena tidak ada foreign key yang menguji string di dalam dokumen. Query terakhir memperlihatkan akibatnya: tabel kini punya lima kategori — "elektronikk" beranggota satu yang tidak akan pernah muncul di laporan "elektronik". Inilah sifat dasarnya: jsonb memvalidasi sintaks dokumen (JSON rusak ditolak, seperti diuji di chapter JSON vs JSONB
), tetapi bukan semantik isinya — tipe nilai, rentang angka, keberadaan kunci, relasi antar tabel. Semua jaminan itu adalah wilayah kolom, CHECK, NOT NULL, UNIQUE, dan REFERENCES.
Pola Hibrida: Kolom Inti + atribut jsonb #
Untuk katalog produk nyata, pertanyaannya jarang hitam-putih. Harga, kategori, dan nama adalah jalur panas yang rutin difilter, diurutkan, dijoin, dan diagregasi — mereka ingin menjadi kolom. Sementara ukuran sepatu, bahan kemeja, kapasitas tas adalah ekor panjang yang berbeda bentuk tiap jenis produk — mereka ingin menjadi dokumen. Pola hibrida melayani keduanya:
-- Pola produksi: kolom inti yang dijamin + atribut jsonb untuk ekor panjang.
CREATE TABLE produk_toko (
id integer PRIMARY KEY,
nama text NOT NULL,
kategori text NOT NULL,
harga numeric NOT NULL CHECK (harga > 0),
atribut jsonb NOT NULL DEFAULT '{}'::jsonb
);
-- Tiap produk membawa atribut ekor panjang yang berbeda-beda;
-- produk tanpa atribut cukup membawa {}.
INSERT INTO produk_toko (id, nama, kategori, harga, atribut) VALUES
(1, 'Sepatu Lari Pagi', 'olahraga', 750000,
'{"ukuran": [40, 41, 42], "warna": "merah"}'),
(2, 'Kemeja Batik Kasih', 'pakaian', 320000,
'{"bahan": "katun", "ukuran": ["S", "M", "L"]}'),
(3, 'Tas Ransel Gunung', 'olahraga', 450000,
'{"kapasitas_liter": 30, "tahan_air": true}'),
(4, 'Madu Hutan 500ml', 'makanan', 25000, '{}');
-- Jalur panas: filter dan sort pada kolom inti,
-- plus CHECK yang menjaga harga tetap positif.
SELECT id, nama, harga
FROM produk_toko
WHERE kategori = 'olahraga' AND harga <= 500000
ORDER BY id;
-- Ekor panjang: dibaca hanya bila relevan;
-- produk tanpa atribut itu mendapat NULL dengan tenang.
SELECT id, nama,
atribut ->> 'kapasitas_liter' AS kapasitas,
atribut -> 'ukuran' AS ukuran
FROM produk_toko
WHERE kategori = 'olahraga'
ORDER BY id;
-- Atribut tetap bisa disaring dengan operator jsonb bila perlu,
-- dan siap diindeks GIN seperti chapter sebelumnya.
SELECT id, nama
FROM produk_toko
WHERE atribut @> '{"tahan_air": true}'
ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 4
id | nama | harga
----+-------------------+--------
3 | Tas Ransel Gunung | 450000
(1 row)
id | nama | kapasitas | ukuran
----+-------------------+-----------+--------------
1 | Sepatu Lari Pagi | | [40, 41, 42]
3 | Tas Ransel Gunung | 30 |
(2 rows)
id | nama
----+-------------------
3 | Tas Ransel Gunung
(1 row)Tabel produk_toko menyimpan field inti sebagai kolom — CHECK (harga > 0) aktif — dan ekor panjang di atribut jsonb yang default-nya dokumen kosong. Tiga query berikutnya memperlihatkan dua dunia itu bekerja: filter dan sort jalur panas pada kolom bertipe; pembacaan atribut lewat ->> yang tenang mengembalikan NULL untuk produk tanpa atribut; dan penyaringan atribut dengan @> yang siap diindeks GIN bila tabel membesar. Tidak ada ALTER TABLE saat jenis produk baru datang membawa atribut baru — fleksibilitas jsonb utuh persis di tempat ia dibutuhkan.
Kapan JSONB Menang #
Ada kalanya jsonb justru menang dominan:
- Struktur benar-benar bervariasi antar baris — atribut per jenis produk berbentuk beda, tidak ada skema bersama yang masuk akal.
- Payload pihak ketiga yang diarsipkan apa adanya — respons webhook, log event, konfigurasi yang bentuknya dikendalikan pihak lain.
- Atribut jarang yang hanya dimiliki sebagian kecil baris — ratusan kolom
NULLdemi satu field yang relevan bagi 2% produk adalah skema yang buruk dibaca. - Dokumen yang dibaca-tulis sebagai satu kesatuan — cache objek, session, template; pola aksesnya whole-document, bukan per-field.
- Perakitan respons API bersarang — chapter JSON Aggregation
menunjukkan tabel yang sepenuhnya relasional pun memanfaatkan
jsonb_agg.
Benang merahnya: jsonb menang ketika data memang dokumen — variabel bentuknya, utuh cara aksesnya, atau sekadar arsip. Begitu field mulai difilter, dijoin, atau harus konsisten lintas baris, ia pindah kewargaan ke dunia kolom.
Anti-Pola yang Mahal #
Tiga godaan yang paling sering muncul di kode nyata:
- EAV-dalam-jsonb: memecah setiap atribut menjadi baris
"nama": "harga", "nilai": 750000di satu tabel atribut seragam — pola entity-attribute-value klasik yang bermasalah, sekadar berganti kostum; tiap pertanyaan sederhana berubah menjadi query pivot yang berat. - Everything-jsonb: memindahkan seluruh tabel ke satu kolom
detaildemi “fleksibel”. Yang hilang: constraint, foreign key, tipe kolom, statistik planner — yang didapat: semua query menjadi ekspresi->>, seperti percobaan dua meja di atas. - Jalur panas lewat rantai
->>: laporan inti yang menyaringWHERE detail ->> 'status' = 'aktif'pada jutaan baris. Expression index bisa menolong, tetapi itu infrastruktur tambahan untuk sesuatu yang kolom lakukan gratis — danstatusyang tak divalidasi tetap bisa berisi"Aktif"dan"AKTIF"tanpa ada yang menolak.
Jalur Migrasi: Promosi Path ke Kolom #
Sistem hidup: atribut yang tadinya jarang dipakai berubah menjadi penting. Mempromosikan path jsonb menjadi kolom tidak memerlukan migrasi besar-besaran — empat langkah ini berjalan selagi tabel tetap melayani:
-- Jalur migrasi: promosi path jsonb menjadi kolom asli.
-- Dipicu saat sebuah path berubah status -- dari atribut sesekali
-- menjadi field yang rutin difilter, diurutkan, atau dijoin.
-- 1. Tambahkan kolom baru (tabel tetap bisa dipakai selama proses).
ALTER TABLE produk_json ADD COLUMN harga numeric;
-- 2. Isi (backfill) dari dokumen; WHERE menjaga baris tanpa kunci harga
-- tetap NULL alih-alih error cast.
UPDATE produk_json
SET harga = (detail ->> 'harga')::numeric
WHERE detail ? 'harga';
-- 3. Beri index seperti kolom biasa, lalu perbarui statistik.
CREATE INDEX idx_json_harga ON produk_json (harga);
ANALYZE produk_json;
-- 4. Query harga kini menulis nama kolomnya langsung.
SELECT count(*) AS di_atas_50rb
FROM produk_json
WHERE harga > 50000;
-- Dokumen lama tetap utuh: dua sumber hidup berdampingan
-- sampai aplikasi bermigrasi dan path lama dibuang.
SELECT id,
detail ->> 'harga' AS harga_dokumen,
harga AS harga_kolom
FROM produk_json
WHERE id IN (1, 20001)
ORDER BY id;Output yang Dihasilkan:
ALTER TABLE
UPDATE 20001
CREATE INDEX
ANALYZE
di_atas_50rb
--------------
10000
(1 row)
id | harga_dokumen | harga_kolom
-------+---------------+-------------
1 | 5000 | 5000
20001 | -500 | -500
(2 rows)Empat langkahnya: (1) ALTER TABLE ... ADD COLUMN menambahkan kolom kosong tanpa mengunci akses; (2) UPDATE mengisi ulang (backfill) dari dokumen, dengan WHERE detail ? 'harga' menjaga baris tanpa kunci itu tetap NULL alih-alih gagal cast; (3) CREATE INDEX dan ANALYZE memberi kolom baru perlengkapan kolom asli; (4) query mulai menulis harga alih-alih ekspresi ->>. Verifikasi terakhir memperlihatkan masa transisinya: dokumen lama tetap utuh, dua sumber hidup berdampingan sampai aplikasi bermigrasi dan path lama dibuang. Perhatikan baris Produk Rusak yang diselundupkan di bagian constraint: promosi menyalin apa adanya — harga_kolom kini -500, bukti bahwa backfill sebaiknya diikuti pembersihan data yang dulu lolos tanpa validasi.
Jadikan query yang lambat sebagai pemicu promosi: path yang sama yang muncul terus di daftar EXPLAIN termahal aplikasi Anda sudah bukan ekor panjang — dia layak menjadi kolom dengan segala jaminannya.
Daftar Keputusan #
| Pertanyaan | Kalau YA | Kalau TIDAK |
|---|---|---|
| Field ini rutin difilter, diurutkan, atau dijoin? | kolom | lanjut ke pertanyaan berikut |
| Nilainya harus divalidasi (rentang, referensi, unik)? | kolom (CHECK, REFERENCES, UNIQUE) | lanjut |
| Strukturnya sama untuk semua baris? | condong kolom | condong jsonb |
| Hanya sebagian kecil baris yang memilikinya? | condong jsonb (atribut sparse) | condong kolom |
| Diakses selalu sebagai dokumen utuh? | jsonb | kolom |
| Bentuknya dikendalikan pihak ketiga / berubah di luar kendali? | jsonb | kolom |
Dengan kerangka ini, jsonb berhenti menjadi mode bawaan atau terlarang dan menjadi keputusan yang bisa dipertanggungjawabkan per field. PostgreSQL sendiri memberi contoh pola pikir itu pada tipe datanya: array untuk nilai jamak dalam satu kolom, custom type untuk komposit yang dipakai berulang — keduanya kandidat alami sebelum data dijatuhkan ke jsonb, dan keduanya dibahas di section berikutnya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-dua-tabel.sql
Unduh berkas-- Percobaan adil: data yang SAMA disimpan dengan dua skema berbeda.
-- produk_rel: kolom relational dengan FK, CHECK, dan tipe yang tepat.
-- produk_json: satu kolom detail jsonb menampung semua atribut.
CREATE TABLE kategori (
id integer PRIMARY KEY,
nama text NOT NULL UNIQUE
);
INSERT INTO kategori (id, nama) VALUES
(1, 'elektronik'),
(2, 'pakaian'),
(3, 'makanan'),
(4, 'olahraga');
CREATE TABLE produk_rel (
id integer PRIMARY KEY,
nama text NOT NULL,
kategori_id integer NOT NULL REFERENCES kategori(id),
merek text NOT NULL,
harga numeric(10,2) NOT NULL CHECK (harga > 0),
berat_gram integer NOT NULL
);
CREATE TABLE produk_json (
id integer PRIMARY KEY,
nama text NOT NULL,
detail jsonb NOT NULL
);
INSERT INTO produk_rel (id, nama, kategori_id, merek, harga, berat_gram)
SELECT g,
'Produk ' || lpad(g::text, 5, '0'),
1 + (g % 4),
(ARRAY['Elang','Nusantara','Gunung Jaya'])[1 + (g % 3)],
((g % 40) + 1) * 2500,
(g % 500) + 100
FROM generate_series(1, 20000) AS g;
INSERT INTO produk_json (id, nama, detail)
SELECT g,
'Produk ' || lpad(g::text, 5, '0'),
jsonb_build_object(
'kategori', (ARRAY['elektronik','pakaian','makanan','olahraga'])[1 + (g % 4)],
'merek', (ARRAY['Elang','Nusantara','Gunung Jaya'])[1 + (g % 3)],
'harga', ((g % 40) + 1) * 2500,
'berat_gram', (g % 500) + 100)
FROM generate_series(1, 20000) AS g;
ANALYZE produk_rel;
ANALYZE produk_json;
-- Jumlah baris keduanya identik.
SELECT count(*) AS baris_rel,
(SELECT count(*) FROM produk_json) AS baris_json
FROM produk_rel;
-- Wujud data yang sama di dua skema.
SELECT id, nama, kategori_id, merek, harga, berat_gram
FROM produk_rel
WHERE id IN (1, 2)
ORDER BY id;
SELECT id, nama, detail
FROM produk_json
WHERE id IN (1, 2)
ORDER BY id;
explain-vs.sql
Unduh berkas-- Filter yang sama pada dua skema. EXPLAIN ANALYZE memperlihatkan
-- bedanya: bentuk rencana sama (Seq Scan), tetapi setiap baris versi
-- jsonb menanggung biaya parse dokumen + ekstraksi teks + cast.
-- Angka waktu bervariasi antar mesin; perbandingan biaya dan
-- kecenderungannya yang stabil.
EXPLAIN ANALYZE SELECT count(*)
FROM produk_rel
WHERE kategori_id = 2 AND harga > 50000;
EXPLAIN ANALYZE SELECT count(*)
FROM produk_json
WHERE detail ->> 'kategori' = 'pakaian'
AND (detail ->> 'harga')::numeric > 50000;
harga-invalid.sql
Unduh berkas-- Kolom menolak data yang melanggar constraint sejak pintu masuk.
INSERT INTO produk_rel (id, nama, kategori_id, merek, harga, berat_gram)
VALUES (20001, 'Produk Rusak', 1, 'Elang', -500, 200);
jsonb-terima.sql
Unduh berkas-- Dokumen jsonb menerima nilai yang sama tanpa protes --
-- harga negatif dan kategori yang salah ketik sama-sama lolos.
INSERT INTO produk_json (id, nama, detail)
VALUES (20001, 'Produk Rusak',
'{"kategori": "elektronikk", "merek": "Elang",
"harga": -500, "berat_gram": 200}');
SELECT id, nama,
detail ->> 'kategori' AS kategori,
(detail ->> 'harga')::numeric AS harga
FROM produk_json
WHERE id = 20001;
-- Fragmentasi senyap: "elektronikk" kini menjadi kategori baru
-- yang tidak akan pernah cocok dengan laporan "elektronik".
SELECT detail ->> 'kategori' AS kategori, count(*) AS jumlah
FROM produk_json
GROUP BY detail ->> 'kategori'
ORDER BY detail ->> 'kategori';
migrasi-kolom.sql
Unduh berkas-- Jalur migrasi: promosi path jsonb menjadi kolom asli.
-- Dipicu saat sebuah path berubah status -- dari atribut sesekali
-- menjadi field yang rutin difilter, diurutkan, atau dijoin.
-- 1. Tambahkan kolom baru (tabel tetap bisa dipakai selama proses).
ALTER TABLE produk_json ADD COLUMN harga numeric;
-- 2. Isi (backfill) dari dokumen; WHERE menjaga baris tanpa kunci harga
-- tetap NULL alih-alih error cast.
UPDATE produk_json
SET harga = (detail ->> 'harga')::numeric
WHERE detail ? 'harga';
-- 3. Beri index seperti kolom biasa, lalu perbarui statistik.
CREATE INDEX idx_json_harga ON produk_json (harga);
ANALYZE produk_json;
-- 4. Query harga kini menulis nama kolomnya langsung.
SELECT count(*) AS di_atas_50rb
FROM produk_json
WHERE harga > 50000;
-- Dokumen lama tetap utuh: dua sumber hidup berdampingan
-- sampai aplikasi bermigrasi dan path lama dibuang.
SELECT id,
detail ->> 'harga' AS harga_dokumen,
harga AS harga_kolom
FROM produk_json
WHERE id IN (1, 20001)
ORDER BY id;
pola-hybrid.sql
Unduh berkas-- Pola produksi: kolom inti yang dijamin + atribut jsonb untuk ekor panjang.
CREATE TABLE produk_toko (
id integer PRIMARY KEY,
nama text NOT NULL,
kategori text NOT NULL,
harga numeric NOT NULL CHECK (harga > 0),
atribut jsonb NOT NULL DEFAULT '{}'::jsonb
);
-- Tiap produk membawa atribut ekor panjang yang berbeda-beda;
-- produk tanpa atribut cukup membawa {}.
INSERT INTO produk_toko (id, nama, kategori, harga, atribut) VALUES
(1, 'Sepatu Lari Pagi', 'olahraga', 750000,
'{"ukuran": [40, 41, 42], "warna": "merah"}'),
(2, 'Kemeja Batik Kasih', 'pakaian', 320000,
'{"bahan": "katun", "ukuran": ["S", "M", "L"]}'),
(3, 'Tas Ransel Gunung', 'olahraga', 450000,
'{"kapasitas_liter": 30, "tahan_air": true}'),
(4, 'Madu Hutan 500ml', 'makanan', 25000, '{}');
-- Jalur panas: filter dan sort pada kolom inti,
-- plus CHECK yang menjaga harga tetap positif.
SELECT id, nama, harga
FROM produk_toko
WHERE kategori = 'olahraga' AND harga <= 500000
ORDER BY id;
-- Ekor panjang: dibaca hanya bila relevan;
-- produk tanpa atribut itu mendapat NULL dengan tenang.
SELECT id, nama,
atribut ->> 'kapasitas_liter' AS kapasitas,
atribut -> 'ukuran' AS ukuran
FROM produk_toko
WHERE kategori = 'olahraga'
ORDER BY id;
-- Atribut tetap bisa disaring dengan operator jsonb bila perlu,
-- dan siap diindeks GIN seperti chapter sebelumnya.
SELECT id, nama
FROM produk_toko
WHERE atribut @> '{"tahan_air": true}'
ORDER BY id;
query-vs.sql
Unduh berkas-- Laporan yang sama, dua penulisan: kolom vs operator jsonb.
-- Versi kolom: JOIN ke tabel kategori, agregat kolom biasa.
SELECT k.nama AS kategori,
count(*) AS jumlah,
sum(r.harga) AS total_nilai
FROM produk_rel r
JOIN kategori k ON k.id = r.kategori_id
GROUP BY k.nama
ORDER BY k.nama;
-- Versi jsonb: kategori dan harga diekstrak dari dokumen.
SELECT detail ->> 'kategori' AS kategori,
count(*) AS jumlah,
sum((detail ->> 'harga')::numeric) AS total_nilai
FROM produk_json
GROUP BY detail ->> 'kategori'
ORDER BY detail ->> 'kategori';