Katalog produk nyata jarang seragam: sepatu punya ukuran dan daftar tag, kemeja punya bahan, tas punya kapasitas liter dan ketahanan air. Model relasional klasik memaksa semua baris berbagi struktur kolom yang sama — setiap atribut baru berarti ALTER TABLE untuk semua orang. Dokumen JSON menyelesaikan kekakuan itu: tiap baris membawa pasangan kunci-nilai bentuk bebas yang bisa berbeda satu sama lain, dan PostgreSQL menyimpannya lewat bukan satu, melainkan dua tipe: json dan jsonb. Keduanya menerima input yang sama, tetapi karakter penyimpanannya berbeda total — dan memilih yang tepat adalah keputusan paling penting di chapter ini. Kita akan menyimpan dokumen, membacanya dengan operator -> dan ->>, membuktikan perbedaan json vs jsonb lewat eksperimen langsung, lalu menyaring baris berdasarkan isi dokumen dengan @>.
Menyimpan Dokumen dalam Kolom jsonb #
Deklarasi kolomnya sesederhana tipe lain: atribut jsonb NOT NULL. Tiga produk berikut sengaja membawa struktur atribut yang berbeda-beda — inilah esensi data semi-terstruktur:
CREATE TABLE katalog (
id integer PRIMARY KEY,
nama text NOT NULL,
atribut jsonb NOT NULL
);
-- tiap produk membawa struktur atribut yang BERBEDA
INSERT INTO katalog (id, nama, atribut) VALUES
(1, 'Sepatu Lari',
'{"merek": "Elang", "ukuran": 42, "tag": ["olahraga", "diskon"]}'),
(2, 'Kemeja Batik',
'{"merek": "Nusantara", "ukuran": "L", "bahan": "katun"}'),
(3, 'Tas Ransel',
'{"merek": "Gunung Jaya", "kapasitas_liter": 30, "tahan_air": true}');
SELECT id, nama, atribut
FROM katalog
ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 3
id | nama | atribut
----+--------------+--------------------------------------------------------------------
1 | Sepatu Lari | {"tag": ["olahraga", "diskon"], "merek": "Elang", "ukuran": 42}
2 | Kemeja Batik | {"bahan": "katun", "merek": "Nusantara", "ukuran": "L"}
3 | Tas Ransel | {"merek": "Gunung Jaya", "tahan_air": true, "kapasitas_liter": 30}
(3 rows)Tiga baris masuk tanpa keluhan meski kunci-kuncinya tidak sama — tidak ada ALTER TABLE, tidak ada kolom kosong untuk yang tak relevan. Sekaligus perhatikan satu hal di output: teks yang kita tulis memuat spasi dan urutan merek duluan, tetapi hasil SELECT menampilkan dokumen rapit tanpa spasi dengan kunci tertata ulang ("tag" duluan pada baris pertama). Itu bukan bug — itu kerja jsonb yang menormalkan dokumen, dan kita bedah alasannya di bagian json vs jsonb.
Membaca Nilai: -> dan -» #
Nilai di dalam dokumen diakses lewat dua operator panah. Operator -> mengembalikan nilai bertipe jsonb — tanda kutip string ikut terbawa; operator ->> mengembalikan teks murni tanpa kutip:
-- -> mengembalikan nilai bertipe jsonb; tanda kutip string ikut terbawa
SELECT atribut -> 'merek' AS merek_jsonb
FROM katalog
ORDER BY id;
-- ->> mengembalikan teks murni tanpa kutip
SELECT atribut ->> 'merek' AS merek_teks
FROM katalog
ORDER BY id;
-- akses berantai: objek lalu indeks array (mulai dari 0)
SELECT atribut -> 'tag' -> 1 AS tag_kedua
FROM katalog
WHERE id = 1;
-- kunci yang tidak ada menghasilkan NULL, bukan error
SELECT id, nama, atribut ->> 'ukuran' AS ukuran
FROM katalog
ORDER BY id;Output yang Dihasilkan:
merek_jsonb
---------------
"Elang"
"Nusantara"
"Gunung Jaya"
(3 rows)
merek_teks
-------------
Elang
Nusantara
Gunung Jaya
(3 rows)
tag_kedua
-----------
"diskon"
(1 row)
id | nama | ukuran
----+--------------+--------
1 | Sepatu Lari | 42
2 | Kemeja Batik | L
3 | Tas Ransel |
(3 rows)Query pertama dan kedua membandingkan keduanya langsung: "Elang" (jsonb, dengan kutip) lawan Elang (teks). Aturan praktisnya — pakai ->> saat hasilnya ingin ditampilkan atau dibandingkan sebagai string di WHERE, dan -> saat hasilnya masih merupakan objek/array yang akan diakses lagi, seperti rantai atribut -> 'tag' -> 1 yang mengambil elemen kedua array tag. Perilaku terakhir layak dicatat: kunci yang tidak ada di dokumen menghasilkan NULL dengan tenang — Tas Ransel kolomnya kosong karena dokumen tersebut memang tidak memuat ukuran, bukan karena error.
json vs jsonb #
Inilah perbedaan yang menentukan. Tipe json menyimpan salinan teks input apa adanya — spasi, urutan kunci, kunci kembar, semuanya utuh. Tipe jsonb mengurai dokumen lalu menyimpannya dalam bentuk biner terstruktur — ibarat perbedaan string SQL dengan pohon sintaks yang sudah terurai: jsonb membuang spasi, mengurutkan kunci (menurut panjang, lalu alfabetis), dan menyimpan hanya nilai terakhir untuk kunci kembar. Bandingkan kedua kolom tabel berikut, lalu lihat buktinya di query:
| Aspek | json | jsonb |
|---|---|---|
| Penyimpanan | Teks persis seperti input | Bentuk biner hasil parsing |
| Spasi dan urutan kunci | Dipertahankan apa adanya | Dibuang; kunci diurutkan |
| Kunci kembar | Semua tetap ada di teks | Hanya nilai terakhir disimpan |
Operasi ->, ->>, @> | Harus mengurai ulang tiap kali | Langsung pada struktur biner |
| Index | Tidak didukung langsung | GIN untuk @> dan operator keanggotaan |
| Kapan dipakai | Perlu bentuk asli persis (audit, arsip) | Default untuk hampir semua kasus |
-- json menyimpan teks input apa adanya:
-- spasi berlebih dan urutan kunci tetap utuh
SELECT '{ "kota" : "Bandung", "nama" : "Siti" }'::json AS kolom_json;
-- jsonb menormalkan dokumen: spasi dibuang, kunci diurutkan
SELECT '{ "kota" : "Bandung", "nama" : "Siti" }'::jsonb AS kolom_jsonb;
-- kunci kembar: teks json tetap memuat keduanya,
-- jsonb hanya menyimpan nilai TERAKHIR
SELECT '{"harga": 1000, "harga": 2000}'::json AS json_duplikat;
SELECT '{"harga": 1000, "harga": 2000}'::jsonb AS jsonb_duplikat;
-- saat dibaca kembali, keduanya mengambil nilai terakhir
SELECT '{"harga": 1000, "harga": 2000}'::json ->> 'harga' AS json_baca;
SELECT '{"harga": 1000, "harga": 2000}'::jsonb ->> 'harga' AS jsonb_baca;Output yang Dihasilkan:
kolom_json
--------------------------------------------
{ "kota" : "Bandung", "nama" : "Siti" }
(1 row)
kolom_jsonb
-------------------------------------
{"kota": "Bandung", "nama": "Siti"}
(1 row)
json_duplikat
--------------------------------
{"harga": 1000, "harga": 2000}
(1 row)
jsonb_duplikat
-----------------
{"harga": 2000}
(1 row)
json_baca
-----------
2000
(1 row)
jsonb_baca
------------
2000
(1 row)Baris pertama dan kedua memperlihatkan perlakuan spasi: json mengembalikan teks dengan spasi jamak yang kita ketik, sedangkan jsonb mengembalikan versi rapit yang kuncinya tertata (kota sebelum nama — sama-sama empat huruf, dipecah secara alfabetis). Empat query berikutnya membongkar perilaku kunci kembar: dokumen json tetap memuat kedua pasangan "harga" apa adanya, jsonb hanya menyimpan 2000 — nilai terakhir menang saat parsing. Namun saat dibaca dengan ->> 'harga', keduanya mengembalikan 2000: fungsi pemroses json juga membaca nilai terakhir dari duplikat, jadi perbedaan nyatanya ada pada apa yang tersimpan di disk, bukan pada hasil pembacaan.
Kesimpulan praktisnya: kecuali Anda benar-benar perlu mempertahankan bentuk asli dokumen (misalnya menyimpan bukti payload API untuk audit), jsonb adalah pilihan default — lebih cepat dioperasikan, bisa diindeks, dan satu-satunya yang mendukung containment di bagian berikutnya.
Menyaring Baris dengan @> #
Operator @> (contains) menguji apakah dokumen di sisi kiri memuat seluruh struktur di sisi kanan — turunannya bisa berupa pasangan kunci-nilai, objek bersarang, maupun daftar elemen array yang harus hadir:
-- @>: dokumen di kiri MEMUAT seluruh struktur di kanan
SELECT '{"nama": "Siti", "tag": ["diskon", "prioritas"]}'::jsonb
@> '{"tag": ["diskon"]}' AS terkandung;
-- nilai yang tidak sama persis tidak dianggap terkandung
SELECT '{"harga": 15000}'::jsonb @> '{"harga": 15001}' AS tidak_cocok;
-- filter baris berdasarkan isi dokumen
SELECT id, nama, atribut ->> 'merek' AS merek
FROM katalog
WHERE atribut @> '{"tag": ["olahraga"]}'
ORDER BY id;Output yang Dihasilkan:
terkandung
------------
t
(1 row)
tidak_cocok
-------------
f
(1 row)
id | nama | merek
----+-------------+-------
1 | Sepatu Lari | Elang
(1 row)Contoh pertama menunjukkan semantik array-nya: {"tag": ["diskon"]} dianggap terkandung karena array ["diskon", "prioritas"] memuat elemen "diskon" — operator menuntut elemen kanan hadir, bukan arraynya identik. Contoh kedua menegaskan bahwa skalar harus sama persis. Query terakhir memakai @> langsung di WHERE untuk mencari seluruh produk bertag olahraga — query bentuk inilah yang diakselerasi index GIN pada kolom jsonb, topik section Indexes.
JSON yang Tidak Valid Ditolak #
Kedua tipe menolak teks yang bukan JSON sejak input — validasi sintaks terjadi di depan pintu, sebelum penyimpanan:
INSERT INTO katalog (id, nama, atribut) VALUES
(4, 'Topi Pantai', '{merek: "Kasih"}');Output yang Dihasilkan:
psql:json-invalid.sql:2: ERROR: invalid input syntax for type json
LINE 2: (4, 'Topi Pantai', '{merek: "Kasih"}');
^
DETAIL: Token "merek" is invalid.
CONTEXT: JSON data, line 1: {merek...Error invalid input syntax for type json lengkap dengan DETAIL: Token "merek" is invalid — kunci dalam JSON wajib dibungkus kutip dua ("merek"), dan kesalahan seperti ini tertangkap oleh server, bukan menunggu meledak di aplikasi. Perhatikan bahwa error-nya menyebut tipe json meski kolomnya jsonb: proses validasi input memang berbagi parser yang sama, dan jsonb hanya melanjutkan dengan normalisasi setelah teks dinyatakan valid.
Dengan json dan jsonb, tabel PostgreSQL mampu menampung data yang strukturnya berubah dari baris ke baris — flexibility tanpa kehilangan validasi, operator pembacaan, dan jalur index. Topik terakhir di katalog tipe data ini tetap berurusan dengan byte mentah, tetapi tanpa makna struktural sama sekali: bytea untuk gambar dan file biner.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
baca-operator.sql
Unduh berkas-- -> mengembalikan nilai bertipe jsonb; tanda kutip string ikut terbawa
SELECT atribut -> 'merek' AS merek_jsonb
FROM katalog
ORDER BY id;
-- ->> mengembalikan teks murni tanpa kutip
SELECT atribut ->> 'merek' AS merek_teks
FROM katalog
ORDER BY id;
-- akses berantai: objek lalu indeks array (mulai dari 0)
SELECT atribut -> 'tag' -> 1 AS tag_kedua
FROM katalog
WHERE id = 1;
-- kunci yang tidak ada menghasilkan NULL, bukan error
SELECT id, nama, atribut ->> 'ukuran' AS ukuran
FROM katalog
ORDER BY id;
json-invalid.sql
Unduh berkasINSERT INTO katalog (id, nama, atribut) VALUES
(4, 'Topi Pantai', '{merek: "Kasih"}');
json-vs-jsonb.sql
Unduh berkas-- json menyimpan teks input apa adanya:
-- spasi berlebih dan urutan kunci tetap utuh
SELECT '{ "kota" : "Bandung", "nama" : "Siti" }'::json AS kolom_json;
-- jsonb menormalkan dokumen: spasi dibuang, kunci diurutkan
SELECT '{ "kota" : "Bandung", "nama" : "Siti" }'::jsonb AS kolom_jsonb;
-- kunci kembar: teks json tetap memuat keduanya,
-- jsonb hanya menyimpan nilai TERAKHIR
SELECT '{"harga": 1000, "harga": 2000}'::json AS json_duplikat;
SELECT '{"harga": 1000, "harga": 2000}'::jsonb AS jsonb_duplikat;
-- saat dibaca kembali, keduanya mengambil nilai terakhir
SELECT '{"harga": 1000, "harga": 2000}'::json ->> 'harga' AS json_baca;
SELECT '{"harga": 1000, "harga": 2000}'::jsonb ->> 'harga' AS jsonb_baca;
jsonb-contains.sql
Unduh berkas-- @>: dokumen di kiri MEMUAT seluruh struktur di kanan
SELECT '{"nama": "Siti", "tag": ["diskon", "prioritas"]}'::jsonb
@> '{"tag": ["diskon"]}' AS terkandung;
-- nilai yang tidak sama persis tidak dianggap terkandung
SELECT '{"harga": 15000}'::jsonb @> '{"harga": 15001}' AS tidak_cocok;
-- filter baris berdasarkan isi dokumen
SELECT id, nama, atribut ->> 'merek' AS merek
FROM katalog
WHERE atribut @> '{"tag": ["olahraga"]}'
ORDER BY id;
simpan-dokumen.sql
Unduh berkasCREATE TABLE katalog (
id integer PRIMARY KEY,
nama text NOT NULL,
atribut jsonb NOT NULL
);
-- tiap produk membawa struktur atribut yang BERBEDA
INSERT INTO katalog (id, nama, atribut) VALUES
(1, 'Sepatu Lari',
'{"merek": "Elang", "ukuran": 42, "tag": ["olahraga", "diskon"]}'),
(2, 'Kemeja Batik',
'{"merek": "Nusantara", "ukuran": "L", "bahan": "katun"}'),
(3, 'Tas Ransel',
'{"merek": "Gunung Jaya", "kapasitas_liter": 30, "tahan_air": true}');
SELECT id, nama, atribut
FROM katalog
ORDER BY id;