Dokumen sudah tersimpan rapi di kolom jsonb — sekarang giliran pertanyaan yang paling sering diajukan aplikasi: bagaimana cara mengambil satu nilai dari dalamnya? Nilai itu bisa bersembunyi dua tingkat bersarang di bawah spesifikasi, tersebar sebagai elemen array tag, atau sama sekali tidak ada di sebagian baris. PostgreSQL menyediakan satu keluarga operator untuk semua kasus itu: dua panah untuk akses per langkah, dua operator path untuk menempuh jalur sekaligus. Kita membedah keduanya di atas katalog produk Toko Maju Jaya, menutup dengan keterampilan yang tak terhindarkan: hasil ->> selalu text, jadi angka harus dicast sebelum bisa dihitung.

Dua Panah: -> untuk jsonb, -» untuk text #

Perbedaan kedua operator ini murni soal tipe hasil. Operator -> mengembalikan nilai bertipe jsonb — tanda kutip string ikut terbawa, dan hasilnya bisa dirantai lagi ke operator berikutnya. Operator ->> mengembalikan text murni — siap ditampilkan, digabung, atau dibandingkan sebagai string, tetapi tidak bisa diakses lagi per kunci.

EkspresiTipe hasilContoh hasil
info -> 'merek'jsonb"Elang" (kutip ikut)
info ->> 'merek'textElang (tanpa kutip)
info -> 'tag' -> 0jsonb"olahraga"
info ->> 'harga'text750000 (angka sebagai teks)
info -> 'spesifikasi'jsonbobjek bersarang utuh
sql buat-tabel.sql
-- Katalog produk untuk seluruh contoh pembacaan nilai di chapter ini.
CREATE TABLE produk (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    info  jsonb NOT NULL
);

INSERT INTO produk (id, nama, info) VALUES
    (1, 'Sepatu Lari',
     '{"merek": "Elang", "harga": 750000, "stok": 25, "tag": ["olahraga", "diskon"], "spesifikasi": {"berat": 280, "bahan": "mesh", "dimensi": {"panjang": 31, "lebar": 11, "tinggi": 9}}}'),
    (2, 'Kemeja Batik',
     '{"merek": "Nusantara", "harga": 320000, "stok": 40, "tag": ["formal", "diskon"], "spesifikasi": {"berat": 220, "bahan": "katun"}}'),
    (3, 'Tas Ransel',
     '{"merek": "Gunung Jaya", "harga": 450000, "stok": 15, "tag": ["olahraga", "outdoor"], "garansi": "12 bulan", "spesifikasi": {"berat": 850, "kapasitas": 30, "tahan_air": true}}'),
    (4, 'Sendok Set 6',
     '{"merek": "Sinar Pagi", "harga": 65000, "stok": 120, "tag": ["dapur"], "spesifikasi": {"berat": 300, "bahan": "stainless"}}'),
    (5, 'Sarung Tangan Sepeda',
     '{"merek": "Elang", "harga": 85000, "stok": 60, "tag": ["olahraga"], "spesifikasi": {"berat": 90, "bahan": "kulit sintetis"}}'),
    (6, 'Topi Pantai',
     '{"merek": "Kasih", "harga": 45000, "stok": 200, "tag": ["outdoor", "diskon"], "spesifikasi": {"berat": 60, "bahan": "katun", "lipat": true}}'),
    (7, 'Termos 1L',
     '{"merek": "Sinar Pagi", "harga": 185000, "stok": 35, "tag": ["dapur", "bestseller"], "garansi": "12 bulan", "spesifikasi": {"berat": 540, "kapasitas": 1, "tahan_air": true}}'),
    (8, 'Kacamata Renang',
     '{"merek": "Elang", "harga": 95000, "stok": 75, "tag": ["olahraga", "bestseller"], "spesifikasi": {"berat": 120, "anti_uv": true}}');

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 8
-- Bukti tipe: -> mengembalikan jsonb, ->> mengembalikan text.
SELECT pg_typeof(info -> 'merek') AS tipe_panah_tunggal,
       pg_typeof(info ->> 'merek') AS tipe_panah_ganda
FROM produk
WHERE id = 1;

-- Perbedaan tampilan: kutip string hanya ikut pada hasil ->.
SELECT nama,
       info -> 'merek'  AS merek_jsonb,
       info ->> 'merek' AS merek_teks
FROM produk
ORDER BY id;

-- Path bertingkat: objek di dalam objek, dirantai bertahap.
SELECT nama,
       info -> 'spesifikasi' -> 'berat'  AS berat_jsonb,
       info -> 'spesifikasi' ->> 'berat' AS berat_teks
FROM produk
ORDER BY id;

-- Elemen array diakses dengan indeks mulai dari 0;
-- indeks negatif berhitung dari belakang.
SELECT nama,
       info -> 'tag' -> 0  AS tag_pertama,
       info -> 'tag' ->> 0 AS tag_pertama_teks,
       info -> 'tag' -> -1 AS tag_terakhir
FROM produk
ORDER BY id;

-- ->> pada nilai objek: isinya diserialisasi menjadi teks, bukan error.
SELECT info ->> 'spesifikasi' AS spek_sebagai_teks
FROM produk
WHERE id = 2;

-- Kunci yang tidak ada menghasilkan NULL, bukan error.
SELECT nama,
       info ->> 'garansi' AS garansi
FROM produk
ORDER BY id;

-- jsonb_typeof membedakan jenis nilai di dalam dokumen.
SELECT jsonb_typeof(info -> 'harga') AS tipe_harga,
       jsonb_typeof(info -> 'tag') AS tipe_tag,
       jsonb_typeof(info -> 'spesifikasi') AS tipe_spek
FROM produk
WHERE id = 1;

Output yang Dihasilkan:

text
 tipe_panah_tunggal | tipe_panah_ganda 
--------------------+------------------
 jsonb              | text
(1 row)

         nama         |  merek_jsonb  | merek_teks  
----------------------+---------------+-------------
 Sepatu Lari          | "Elang"       | Elang
 Kemeja Batik         | "Nusantara"   | Nusantara
 Tas Ransel           | "Gunung Jaya" | Gunung Jaya
 Sendok Set 6         | "Sinar Pagi"  | Sinar Pagi
 Sarung Tangan Sepeda | "Elang"       | Elang
 Topi Pantai          | "Kasih"       | Kasih
 Termos 1L            | "Sinar Pagi"  | Sinar Pagi
 Kacamata Renang      | "Elang"       | Elang
(8 rows)

         nama         | berat_jsonb | berat_teks 
----------------------+-------------+------------
 Sepatu Lari          | 280         | 280
 Kemeja Batik         | 220         | 220
 Tas Ransel           | 850         | 850
 Sendok Set 6         | 300         | 300
 Sarung Tangan Sepeda | 90          | 90
 Topi Pantai          | 60          | 60
 Termos 1L            | 540         | 540
 Kacamata Renang      | 120         | 120
(8 rows)

         nama         | tag_pertama | tag_pertama_teks | tag_terakhir 
----------------------+-------------+------------------+--------------
 Sepatu Lari          | "olahraga"  | olahraga         | "diskon"
 Kemeja Batik         | "formal"    | formal           | "diskon"
 Tas Ransel           | "olahraga"  | olahraga         | "outdoor"
 Sendok Set 6         | "dapur"     | dapur            | "dapur"
 Sarung Tangan Sepeda | "olahraga"  | olahraga         | "olahraga"
 Topi Pantai          | "outdoor"   | outdoor          | "diskon"
 Termos 1L            | "dapur"     | dapur            | "bestseller"
 Kacamata Renang      | "olahraga"  | olahraga         | "bestseller"
(8 rows)

        spek_sebagai_teks         
----------------------------------
 {"bahan": "katun", "berat": 220}
(1 row)

         nama         | garansi  
----------------------+----------
 Sepatu Lari          | 
 Kemeja Batik         | 
 Tas Ransel           | 12 bulan
 Sendok Set 6         | 
 Sarung Tangan Sepeda | 
 Topi Pantai          | 
 Termos 1L            | 12 bulan
 Kacamata Renang      | 
(8 rows)

 tipe_harga | tipe_tag | tipe_spek 
------------+----------+-----------
 number     | array    | object
(1 row)

Query pg_typeof di awal file membuktikan tabel di atas secara langsung: jsonb dan text. Query kedua memperlihatkan kolom merek_jsonb yang selalu bertanda kutip lawan merek_teks yang bersih. Perhatikan satu kejutan kecil pada angka: berat_teks tampil 280 tanpa kutip — angka JSON memang tidak dibungkus kutip sejak awal, jadi perbedaan -> dan ->> untuk angka tidak terlihat di layar meskipun tipenya tetap berbeda.

Path Bertingkat dan Elemen Array #

Nilai yang bersarang dijangkau dengan merantai operator: info -> 'spesifikasi' -> 'berat' masuk dulu ke objek spesifikasi, lalu mengambil kunci berat di dalamnya. Rantai bisa sepanjang apa pun — dimensi di produk 1 bahkan berada tiga tingkat di bawah akar. Elemen array memakai indeks angka alih-alih nama kunci, dihitung mulai dari nol; indeks negatif berhitung dari belakang, jadi info -> 'tag' -> -1 mengambil tag terakhir tanpa perlu tahu panjang arraynya. Query jsonb_typeof di akhir file menegaskan jenis tiap nilai: harga adalah number, tag adalah array, spesifikasi adalah object — pengetahuan yang menentukan operator apa yang sah untuk langkah berikutnya.

Dua perilaku dari file yang sama layak digarisbawahi. Pertama, info ->> 'spesifikasi' tidak error meski spesifikasi adalah objek: ->> menyerialisasikan seluruh isinya menjadi satu teks JSON. Kedua, kunci yang tidak ada — garansi hanya hadir pada Tas Ransel dan Termos 1L — menghasilkan NULL dengan tenang, bukan error. Perilaku NULL ini sangat memudahkan query lintas dokumen bervariasi: satu query yang sama melayani delapan struktur berbeda tanpa CASE percabangan.

Path sebagai Array: #> dan #» #

Rantai -> tiga tingkat berbunyi panjang dan harus ditulis persis sama di setiap query. Operator #> menawarkan bentuk ringkas: seluruh jalur ditulis sebagai satu array teks — info #> '{spesifikasi,dimensi,panjang}' — dan #>> adalah pasangannya yang mengembalikan text. Elemen array dalam path ditulis sebagai angka: '{tag,0}' berarti kunci tag lalu indeks ke-0.

-- #> dan #>> menempuh path yang ditulis sebagai array teks.
-- Hasil #> berupa jsonb; hasil #>> berupa text.
SELECT nama,
       info #> '{spesifikasi,dimensi,panjang}'  AS panjang_jsonb,
       info #>> '{spesifikasi,dimensi,panjang}' AS panjang_teks
FROM produk
ORDER BY id;

-- Path boleh memuat indeks array setelah nama kunci.
SELECT nama,
       info #>> '{tag,0}' AS tag_pertama,
       info #>> '{tag,1}' AS tag_kedua
FROM produk
ORDER BY id;

-- Path yang putus di tengah menghasilkan NULL, bukan error.
SELECT nama,
       info #> '{spesifikasi,kapasitas}' AS kapasitas
FROM produk
WHERE id IN (1, 3, 7)
ORDER BY id;

-- Rantai -> dan path #> menghasilkan nilai yang sama persis.
SELECT '{"spesifikasi": {"berat": 280}}'::jsonb -> 'spesifikasi' -> 'berat' AS lewat_rantai,
       '{"spesifikasi": {"berat": 280}}'::jsonb #> '{spesifikasi,berat}'     AS lewat_path;

Output yang Dihasilkan:

text
         nama         | panjang_jsonb | panjang_teks 
----------------------+---------------+--------------
 Sepatu Lari          | 31            | 31
 Kemeja Batik         |               | 
 Tas Ransel           |               | 
 Sendok Set 6         |               | 
 Sarung Tangan Sepeda |               | 
 Topi Pantai          |               | 
 Termos 1L            |               | 
 Kacamata Renang      |               | 
(8 rows)

         nama         | tag_pertama | tag_kedua  
----------------------+-------------+------------
 Sepatu Lari          | olahraga    | diskon
 Kemeja Batik         | formal      | diskon
 Tas Ransel           | olahraga    | outdoor
 Sendok Set 6         | dapur       | 
 Sarung Tangan Sepeda | olahraga    | 
 Topi Pantai          | outdoor     | diskon
 Termos 1L            | dapur       | bestseller
 Kacamata Renang      | olahraga    | bestseller
(8 rows)

    nama     | kapasitas 
-------------+-----------
 Sepatu Lari | 
 Tas Ransel  | 30
 Termos 1L   | 1
(3 rows)

 lewat_rantai | lewat_path 
--------------+------------
 280          | 280
(1 row)

Hasil pertama memperlihatkan kekuatan sekaligus kelembutannya: hanya Sepatu Lari yang memiliki dimensi.panjang, tujuh baris lain menghasilkan NULL — path yang putus di tengah jalan tidak pernah melempar error. Query kedua menambahkan bukti dari sisi array: Sendok Set 6 dan Sarung Tangan Sepeda hanya punya satu tag, sehingga '{tag,1}' menghasilkan NULL untuk keduanya. Query terakhir menegaskan bahwa rantai -> dan path #> benar-benar setara — keduanya menghasilkan 280 dari dokumen yang sama.

Cast Hasil ke Tipe SQL #

Angka di dokumen JSON berpindah ke SQL sebagai text begitu melewati ->>. Untuk operasi aritmetika, agregasi, atau perbandingan numerik, hasilnya harus dicast dulu ke tipe SQL yang tepat — ::integer untuk stok, ::numeric untuk harga.

sql cast-tipe.sql
-- Hasil ->> selalu text, jadi operasi numerik menuntut cast eksplisit.
SELECT nama,
       (info ->> 'stok')::integer AS stok,
       (info ->> 'stok')::integer * 2 AS stok_ganda
FROM produk
ORDER BY id;

SELECT nama,
       (info ->> 'harga')::numeric AS harga,
       round((info ->> 'harga')::numeric * 0.9) AS harga_diskon
FROM produk
ORDER BY id;

Output yang Dihasilkan:

text
         nama         | stok | stok_ganda 
----------------------+------+------------
 Sepatu Lari          |   25 |         50
 Kemeja Batik         |   40 |         80
 Tas Ransel           |   15 |         30
 Sendok Set 6         |  120 |        240
 Sarung Tangan Sepeda |   60 |        120
 Topi Pantai          |  200 |        400
 Termos 1L            |   35 |         70
 Kacamata Renang      |   75 |        150
(8 rows)

         nama         | harga  | harga_diskon 
----------------------+--------+--------------
 Sepatu Lari          | 750000 |       675000
 Kemeja Batik         | 320000 |       288000
 Tas Ransel           | 450000 |       405000
 Sendok Set 6         |  65000 |        58500
 Sarung Tangan Sepeda |  85000 |        76500
 Topi Pantai          |  45000 |        40500
 Termos 1L            | 185000 |       166500
 Kacamata Renang      |  95000 |        85500
(8 rows)

Kedua query memperlihatkan polanya: (info ->> 'stok')::integer menghasilkan angka sungguhan yang bisa dikalikan dua, dan (info ->> 'harga')::numeric mengikuti perhitungan diskon 10% dengan round(). Tanpa cast, kedua ekspresi matematika itu akan ditolak parser karena operator * tidak terdefinisi untuk text. Cast juga punya sisi tegasnya — nilai teks yang bukan angka membuat cast gagal keras:

sql cast-gagal.sql
-- Teks yang bukan angka tidak bisa dicast ke integer.
SELECT nama, (info ->> 'merek')::integer AS merek_salah_tipe
FROM produk
WHERE id = 1;

Output yang Dihasilkan:

text
psql:cast-gagal.sql:4: ERROR:  invalid input syntax for type integer: "Elang"

Error invalid input syntax for type integer: "Elang" muncul karena nilai merek memang teks nama. Kegagalan seperti ini biasanya menandakan dua kemungkinan: tipe nilai di dokumen berubah tanpa koordinasi (aplikasi lama menulis angka, aplikasi baru menulis teks), atau query salah memilih kunci. CHECK constraint dari chapter Menyimpan Data JSON adalah pagar pertama untuk mencegah kategori pertama.

Rantai panah dan path array kini menjadi keterampilan yang siap pakai. Namun keduanya tetap mengambil satu nilai pada satu jalur — belum ada cara menyatakan “semua elemen array yang harganya di atas seratus ribu” dalam satu ekspresi ringkas. Untuk itu PostgreSQL menyediakan bahasa query tersendiri untuk dokumen: JSONPath, materi chapter berikutnya.

Referensi #

Berkas Sumber

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

buat-tabel.sql

Unduh berkas
sql
-- Katalog produk untuk seluruh contoh pembacaan nilai di chapter ini.
CREATE TABLE produk (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    info  jsonb NOT NULL
);

INSERT INTO produk (id, nama, info) VALUES
    (1, 'Sepatu Lari',
     '{"merek": "Elang", "harga": 750000, "stok": 25, "tag": ["olahraga", "diskon"], "spesifikasi": {"berat": 280, "bahan": "mesh", "dimensi": {"panjang": 31, "lebar": 11, "tinggi": 9}}}'),
    (2, 'Kemeja Batik',
     '{"merek": "Nusantara", "harga": 320000, "stok": 40, "tag": ["formal", "diskon"], "spesifikasi": {"berat": 220, "bahan": "katun"}}'),
    (3, 'Tas Ransel',
     '{"merek": "Gunung Jaya", "harga": 450000, "stok": 15, "tag": ["olahraga", "outdoor"], "garansi": "12 bulan", "spesifikasi": {"berat": 850, "kapasitas": 30, "tahan_air": true}}'),
    (4, 'Sendok Set 6',
     '{"merek": "Sinar Pagi", "harga": 65000, "stok": 120, "tag": ["dapur"], "spesifikasi": {"berat": 300, "bahan": "stainless"}}'),
    (5, 'Sarung Tangan Sepeda',
     '{"merek": "Elang", "harga": 85000, "stok": 60, "tag": ["olahraga"], "spesifikasi": {"berat": 90, "bahan": "kulit sintetis"}}'),
    (6, 'Topi Pantai',
     '{"merek": "Kasih", "harga": 45000, "stok": 200, "tag": ["outdoor", "diskon"], "spesifikasi": {"berat": 60, "bahan": "katun", "lipat": true}}'),
    (7, 'Termos 1L',
     '{"merek": "Sinar Pagi", "harga": 185000, "stok": 35, "tag": ["dapur", "bestseller"], "garansi": "12 bulan", "spesifikasi": {"berat": 540, "kapasitas": 1, "tahan_air": true}}'),
    (8, 'Kacamata Renang',
     '{"merek": "Elang", "harga": 95000, "stok": 75, "tag": ["olahraga", "bestseller"], "spesifikasi": {"berat": 120, "anti_uv": true}}');

cast-gagal.sql

Unduh berkas
sql
-- Teks yang bukan angka tidak bisa dicast ke integer.
SELECT nama, (info ->> 'merek')::integer AS merek_salah_tipe
FROM produk
WHERE id = 1;

cast-tipe.sql

Unduh berkas
sql
-- Hasil ->> selalu text, jadi operasi numerik menuntut cast eksplisit.
SELECT nama,
       (info ->> 'stok')::integer AS stok,
       (info ->> 'stok')::integer * 2 AS stok_ganda
FROM produk
ORDER BY id;

SELECT nama,
       (info ->> 'harga')::numeric AS harga,
       round((info ->> 'harga')::numeric * 0.9) AS harga_diskon
FROM produk
ORDER BY id;

operator-panah.sql

Unduh berkas
sql
-- Bukti tipe: -> mengembalikan jsonb, ->> mengembalikan text.
SELECT pg_typeof(info -> 'merek') AS tipe_panah_tunggal,
       pg_typeof(info ->> 'merek') AS tipe_panah_ganda
FROM produk
WHERE id = 1;

-- Perbedaan tampilan: kutip string hanya ikut pada hasil ->.
SELECT nama,
       info -> 'merek'  AS merek_jsonb,
       info ->> 'merek' AS merek_teks
FROM produk
ORDER BY id;

-- Path bertingkat: objek di dalam objek, dirantai bertahap.
SELECT nama,
       info -> 'spesifikasi' -> 'berat'  AS berat_jsonb,
       info -> 'spesifikasi' ->> 'berat' AS berat_teks
FROM produk
ORDER BY id;

-- Elemen array diakses dengan indeks mulai dari 0;
-- indeks negatif berhitung dari belakang.
SELECT nama,
       info -> 'tag' -> 0  AS tag_pertama,
       info -> 'tag' ->> 0 AS tag_pertama_teks,
       info -> 'tag' -> -1 AS tag_terakhir
FROM produk
ORDER BY id;

-- ->> pada nilai objek: isinya diserialisasi menjadi teks, bukan error.
SELECT info ->> 'spesifikasi' AS spek_sebagai_teks
FROM produk
WHERE id = 2;

-- Kunci yang tidak ada menghasilkan NULL, bukan error.
SELECT nama,
       info ->> 'garansi' AS garansi
FROM produk
ORDER BY id;

-- jsonb_typeof membedakan jenis nilai di dalam dokumen.
SELECT jsonb_typeof(info -> 'harga') AS tipe_harga,
       jsonb_typeof(info -> 'tag') AS tipe_tag,
       jsonb_typeof(info -> 'spesifikasi') AS tipe_spek
FROM produk
WHERE id = 1;

path-operator.sql

Unduh berkas
sql
-- #> dan #>> menempuh path yang ditulis sebagai array teks.
-- Hasil #> berupa jsonb; hasil #>> berupa text.
SELECT nama,
       info #> '{spesifikasi,dimensi,panjang}'  AS panjang_jsonb,
       info #>> '{spesifikasi,dimensi,panjang}' AS panjang_teks
FROM produk
ORDER BY id;

-- Path boleh memuat indeks array setelah nama kunci.
SELECT nama,
       info #>> '{tag,0}' AS tag_pertama,
       info #>> '{tag,1}' AS tag_kedua
FROM produk
ORDER BY id;

-- Path yang putus di tengah menghasilkan NULL, bukan error.
SELECT nama,
       info #> '{spesifikasi,kapasitas}' AS kapasitas
FROM produk
WHERE id IN (1, 3, 7)
ORDER BY id;

-- Rantai -> dan path #> menghasilkan nilai yang sama persis.
SELECT '{"spesifikasi": {"berat": 280}}'::jsonb -> 'spesifikasi' -> 'berat' AS lewat_rantai,
       '{"spesifikasi": {"berat": 280}}'::jsonb #> '{spesifikasi,berat}'     AS lewat_path;