Query JSONB terasa manis di tabel kecil dan pahit di tabel besar. Filter detail @> '{"kategori": "elektronik"}' pada 20.000 baris tanpa index berarti mem-parse ulang seluruh dokumen di setiap baris, dan yang empuk di demo menjadi detik-detik di produksi. Section Indexes sudah memperkenalkan dua strategi dasarnya: GIN pada seluruh kolom jsonb dan expression index pada satu path. Chapter ini membedahnya lebih dalam dari sisi jsonb-nya sendiri: bagaimana dua kelas operator GIN (jsonb_ops dan jsonb_path_ops) mengindeks dokumen secara berbeda, berapa ukuran nyata masing-masing pada data yang sama, bagaimana expression index memakai cast ::numeric untuk melayani rentang harga, serta jebakan perbandingan teks yang mengintai di balik operator ->>. Semuanya ditutup verifikasi EXPLAIN — karena index yang tidak dipakai planner hanyalah beban tulis.

Data Uji: 20.000 Dokumen Produk #

Tabel uji memakai domain katalog yang sama dengan section ini: 20.000 produk, tiap baris membawa dokumen detail berisi kategori, merek, harga di tingkat atas, objek spesifikasi bersarang, dan kunci promo yang hanya ada pada satu dari dua puluh produk — kunci sparse yang nanti membedakan nasib dua kelas operator:

sql buat-tabel.sql
-- Katalog 20.000 produk dengan dokumen detail jsonb:
-- kategori, merek, harga di tingkat atas; berat_gram bersarang
-- di objek spesifikasi; kunci promo hanya ada pada 1 dari 20 produk.
CREATE TABLE produk (
    id     integer PRIMARY KEY,
    nama   text NOT NULL,
    detail jsonb NOT NULL
);

INSERT INTO produk (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,
           'spesifikasi', jsonb_build_object('berat_gram', (g % 500) + 100)
       )
       -- Kunci sparse: hanya produk kelipatan 20 yang berpromosi.
       -- Penggabungan || memasukkan pasangan promo secara bersyarat.
       || CASE WHEN g % 20 = 0
               THEN '{"promo": "diskon 20%"}'::jsonb
               ELSE '{}'::jsonb
           END
FROM generate_series(1, 20000) AS g;

ANALYZE produk;

-- Produk 1 tanpa promo; produk 20 (kelipatan 20) memuat kunci promo.
SELECT id, detail
FROM produk
WHERE id IN (1, 20)
ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 20000
ANALYZE
 id |                                                            detail                                                             
----+-------------------------------------------------------------------------------------------------------------------------------
  1 | {"harga": 5000, "merek": "Nusantara", "kategori": "pakaian", "spesifikasi": {"berat_gram": 101}}
 20 | {"harga": 52500, "merek": "Gunung Jaya", "promo": "diskon 20%", "kategori": "elektronik", "spesifikasi": {"berat_gram": 120}}
(2 rows)

Baris produk 1 memperlihatkan dokumen tanpa promo; produk 20 (kelipatan 20) memuatnya. jsonb_build_object merakit dokumen, lalu penggabungan || dari chapter Mengubah Data JSON menempelkan kunci promo secara bersyarat. ANALYZE di akhir memperbarui statistik planner agar rencana eksekusi nanti dihitung dari data yang sebenarnya.

jsonb_ops vs jsonb_path_ops #

Kedua kelas operator membangun GIN index dari dokumen yang sama, tetapi dengan mesin pemotong yang berbeda. Kelas bawaan jsonb_ops membuat entri index untuk setiap kunci dan setiap nilai yang ditemukannya — string "promo", kunci "kategori", nilai "elektronik", angka 52500, semuanya jadi entri terpisah. Kelas jsonb_path_ops hanya membuat entri untuk hash pasangan path-nilai — posisi kunci di dalam dokumen beserta nilainya dijadikan satu hash. Konsekuensinya dua arah: index jsonb_path_ops selalu lebih kecil, tetapi hanya sanggup melayani operator @> karena tidak punya entri kunci mandiri untuk diuji operator ?:

-- Dua kelas operator GIN untuk jsonb:
-- jsonb_ops (default) vs jsonb_path_ops.

-- jsonb_ops: satu entri untuk setiap kunci DAN setiap nilai di dokumen.
CREATE INDEX idx_gin_ops ON produk USING gin (detail);

-- jsonb_path_ops: satu entri untuk setiap pasangan path-nilai (dihash).
CREATE INDEX idx_gin_path ON produk USING gin (detail jsonb_path_ops);

-- Ukuran kedua index pada data yang sama persis.
SELECT pg_size_pretty(pg_relation_size('idx_gin_ops'))  AS ukuran_jsonb_ops,
       pg_size_pretty(pg_relation_size('idx_gin_path')) AS ukuran_jsonb_path_ops;

-- @> dilayani kedua kelas operator: hasil dan jumlah baris sama.
SELECT count(*) AS produk_elektronik
FROM produk
WHERE detail @> '{"kategori": "elektronik"}';

-- ? (dan ?&, ?|) hanya dilayani jsonb_ops.
SELECT count(*) AS produk_promosi
FROM produk
WHERE detail ? 'promo';

SELECT count(*) AS promo_dan_merek
FROM produk
WHERE detail ?& ARRAY['promo', 'merek'];

-- Containment tetap bekerja untuk nilai bersarang:
-- path spesifikasi ikut terindeks oleh GIN kolom penuh.
SELECT count(*) AS berat_101
FROM produk
WHERE detail @> '{"spesifikasi": {"berat_gram": 101}}';

Output yang Dihasilkan:

text
CREATE INDEX
CREATE INDEX
 ukuran_jsonb_ops | ukuran_jsonb_path_ops 
------------------+-----------------------
 440 kB           | 280 kB
(1 row)

 produk_elektronik 
-------------------
              5000
(1 row)

 produk_promosi 
----------------
           1000
(1 row)

 promo_dan_merek 
-----------------
            1000
(1 row)

 berat_101 
-----------
        40
(1 row)

Angka-angkanya memberi tiga kesimpulan sekaligus. Pertama, ukuran: jsonb_path_ops memakan 280 kB lawan 440 kB milik jsonb_ops pada data yang sama — lebih dari sepertiga hemat. Kedua, coverage operator: query @> mengembalikan 5.000 produk elektronik dan query ? 'promo' menemukan 1.000 produk berpromosi, tapi hanya jsonb_ops yang bisa mengakselerasi ? dan ?& — jsonb_path_ops tidak memiliki entri kunci yang dibutuhkan kedua operator itu. Ketiga, baris terakhir membuktikan GIN kolom penuh mengindeks seluruh kedalaman dokumen: containment pada path bersarang {"spesifikasi": {"berat_gram": 101}} menemukan 40 produk tanpa index tambahan apa pun.

Aspekjsonb_ops (default)jsonb_path_ops
Entri indextiap kunci dan tiap nilai terpisahhash tiap pasangan path-nilai
Operator yang dilayani@>, ?, ?&, `?`
Ukuran (20.000 dokumen)440 kB280 kB
Kapan dipilihrutin memakai ?/?&/`?`

Expression Index pada Path Spesifik #

Strategi kedua mengabaikan struktur dokumen sama sekali: pilih satu path yang selalu dikueri, indeks hasil ekspresinya dengan B-tree biasa. Mekanisme umumnya sudah dibahas di chapter Expression Index ; kebaruan di sini adalah cast ke numeric. Hasil detail ->> 'harga' adalah text, jadi index dan query sama-sama harus mencantumkan ::numeric agar perbandingan rentang menjadi perbandingan angka:

-- Expression index: B-tree pada SATU path yang rutin dikueri.

-- Path teks: filter kategori pada halaman katalog.
CREATE INDEX idx_expr_kategori ON produk ((detail ->> 'kategori'));

-- Path angka: rentang harga. Hasil ->> adalah text,
-- jadi ekspresinya harus mencantumkan cast ke numeric
-- supaya perbandingan rentang bersifat numerik.
CREATE INDEX idx_expr_harga ON produk (((detail ->> 'harga')::numeric));

-- Perbandingan ukuran keempat strategi pada tabel yang sama.
SELECT 'gin kolom penuh (jsonb_ops)' AS strategi,
       pg_size_pretty(pg_relation_size('idx_gin_ops')) AS ukuran
UNION ALL
SELECT 'gin kolom penuh (jsonb_path_ops)',
       pg_size_pretty(pg_relation_size('idx_gin_path'))
UNION ALL
SELECT 'btree ekspresi (->> kategori)',
       pg_size_pretty(pg_relation_size('idx_expr_kategori'))
UNION ALL
SELECT 'btree ekspresi ((->> harga)::numeric)',
       pg_size_pretty(pg_relation_size('idx_expr_harga'))
ORDER BY strategi;

-- Query ekspresi harus menulis ekspresi yang sama persis
-- dengan yang dideklarasikan di CREATE INDEX.
SELECT count(*) AS produk_makanan
FROM produk
WHERE detail ->> 'kategori' = 'makanan';

-- Rentang harga kini query biasa pada B-tree numeric.
SELECT count(*) AS harga_di_atas_90rb
FROM produk
WHERE (detail ->> 'harga')::numeric > 90000;

Output yang Dihasilkan:

text
CREATE INDEX
CREATE INDEX
               strategi                | ukuran 
---------------------------------------+--------
 btree ekspresi ((->> harga)::numeric) | 456 kB
 btree ekspresi (->> kategori)         | 152 kB
 gin kolom penuh (jsonb_ops)           | 440 kB
 gin kolom penuh (jsonb_path_ops)      | 280 kB
(4 rows)

 produk_makanan 
----------------
           5000
(1 row)

 harga_di_atas_90rb 
--------------------
               2000
(1 row)

Query detail ->> 'kategori' = 'makanan' mengembalikan 5.000 baris dan rentang harga (detail ->> 'harga')::numeric > 90000 menemukan 2.000 produk — keduanya kini query B-tree biasa: murah, terurut, dan melayani ORDER BY pada path itu. Tabel ukuran memperlihatkan spektrum lengkapnya: expression index kategori hanya 152 kB (terkecil — empat nilai berbeda), GIN jsonb_path_ops 280 kB, GIN jsonb_ops 440 kB, dan expression index harga 456 kB — terbesar karena 40.000 nilai numerik hampir unik. Pelajarannya: ukuran index ditentukan kardinalitas nilai yang diindeks, bukan oleh jenis strategi semata.

Jebakan Teks dari -» dan Solusi Cast #

Jebakan itu sendiri layak dibedah karena kerap lolos dari review kode. Operator ->> selalu menghasilkan text, dan perbandingan text di PostgreSQL bersifat leksikografis — karakter demi karakter — bukan numerik:

-- Jebakan teks: hasil ->> adalah text, bukan angka.

-- ORDER BY pada teks harga mengurutkan secara leksikografis:
-- "100000" dianggap lebih kecil dari "22500" karena karakter '1' < '2'.
SELECT id, detail ->> 'harga' AS harga_teks
FROM produk
WHERE id IN (1, 8, 24, 39)
ORDER BY detail ->> 'harga';

-- Perbandingan rentang pada teks pun leksikografis:
-- tidak ada harga yang teksnya lebih besar dari '99999',
-- padahal 500 produk berharga 100000.
SELECT count(*) AS teks_di_atas_99999
FROM produk
WHERE (detail ->> 'harga') > '99999';

-- Cast ke numeric mengembalikan perbandingan yang benar.
SELECT count(*) AS angka_di_atas_99999
FROM produk
WHERE (detail ->> 'harga')::numeric > 99999;

Output yang Dihasilkan:

text
 id | harga_teks 
----+------------
 39 | 100000
  8 | 22500
  1 | 5000
 24 | 62500
(4 rows)

 teks_di_atas_99999 
--------------------
                  0
(1 row)

 angka_di_atas_99999 
---------------------
                 500
(1 row)

Query pertama mengurutkan empat harga sebagai teks: 100000 justru tampil paling atas karena karakter '1' lebih kecil dari '2', disusul 22500, 5000, lalu 62500 — urutan yang jelas salah untuk harga. Dua query berikutnya menunjukkan akibat yang lebih senyap dan lebih berbahaya: > '99999' pada teks menemukan 0 baris (tidak ada string harga yang karakter pertamanya melebihi '9'), padahal versi numeric menemukan 500 produk berharga di atas 99.999. Query teks semacam ini tidak error — hasilnya saja yang diam-diam salah. Aturannya satu kalimat: setiap kali ->> dipakai untuk nilai numerik dalam WHERE atau ORDER BY, sertakan cast ::numeric (atau ::int) dan pastikan expression index dibuat dengan ekspresi cast yang sama.

Memverifikasi dengan EXPLAIN #

File terakhir menjalankan tiga query pola berbeda dan membuktikan index mana yang dipilih planner untuk masing-masing:

-- Verifikasi lewat EXPLAIN ANALYZE: index mana yang dipilih planner.
-- Angka waktu (actual time, Execution Time) bervariasi antar mesin;
-- pilihan index dan bentuk rencananya yang menjadi pokok bahasan.

-- 1. Containment @>: dua index GIN kandidat, planner memilih
--    jsonb_path_ops karena ukurannya lebih kecil.
EXPLAIN ANALYZE SELECT count(*)
FROM produk
WHERE detail @> '{"kategori": "elektronik"}';

-- 2. Operator ? hanya dilayani jsonb_ops: index path_ops
--    dan expression index tidak bisa membantu.
EXPLAIN ANALYZE SELECT count(*)
FROM produk
WHERE detail ? 'promo';

-- 3. Rentang harga pada ekspresi cast numeric:
--    dilayani B-tree expression index.
EXPLAIN ANALYZE SELECT count(*)
FROM produk
WHERE (detail ->> 'harga')::numeric > 90000;

Output yang Dihasilkan:

text
                                                            QUERY PLAN                                                            
----------------------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=560.62..560.63 rows=1 width=8) (actual time=2.050..2.052 rows=1 loops=1)
   ->  Bitmap Heap Scan on produk  (cost=42.51..548.44 rows=4874 width=0) (actual time=0.329..1.824 rows=5000 loops=1)
         Recheck Cond: (detail @> '{"kategori": "elektronik"}'::jsonb)
         Heap Blocks: exact=445
         ->  Bitmap Index Scan on idx_gin_path  (cost=0.00..41.30 rows=4874 width=0) (actual time=0.285..0.286 rows=5000 loops=1)
               Index Cond: (detail @> '{"kategori": "elektronik"}'::jsonb)
 Planning Time: 0.405 ms
 Execution Time: 2.095 ms
(8 rows)

                                                           QUERY PLAN                                                           
--------------------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=477.54..477.55 rows=1 width=8) (actual time=0.496..0.496 rows=1 loops=1)
   ->  Bitmap Heap Scan on produk  (cost=17.92..475.11 rows=975 width=0) (actual time=0.111..0.450 rows=1000 loops=1)
         Recheck Cond: (detail ? 'promo'::text)
         Heap Blocks: exact=445
         ->  Bitmap Index Scan on idx_gin_ops  (cost=0.00..17.68 rows=975 width=0) (actual time=0.075..0.075 rows=1000 loops=1)
               Index Cond: (detail ? 'promo'::text)
 Planning Time: 0.081 ms
 Execution Time: 0.510 ms
(8 rows)

                                                             QUERY PLAN                                                              
-------------------------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=726.96..726.97 rows=1 width=8) (actual time=0.539..0.539 rows=1 loops=1)
   ->  Bitmap Heap Scan on produk  (cost=131.96..710.30 rows=6667 width=0) (actual time=0.128..0.453 rows=2000 loops=1)
         Recheck Cond: (((detail ->> 'harga'::text))::numeric > '90000'::numeric)
         Heap Blocks: exact=445
         ->  Bitmap Index Scan on idx_expr_harga  (cost=0.00..130.29 rows=6667 width=0) (actual time=0.094..0.094 rows=2000 loops=1)
               Index Cond: (((detail ->> 'harga'::text))::numeric > '90000'::numeric)
 Planning Time: 0.066 ms
 Execution Time: 0.551 ms
(8 rows)

Tiga pengamatan dari rencana tersebut:

  1. @> memilih idx_gin_path: untuk containment, planner membandingkan kedua index GIN dan memilih jsonb_path_ops yang lebih kecil — biaya per jumlah baris lebih murah. Query berjalan sebagai Bitmap Index Scan dengan Index Cond: (detail @> ...) dan Recheck Cond di atas heap.
  2. ? 'promo' memilih idx_gin_ops: satu-satunya index yang punya entri kunci; jsonb_path_ops dan kedua expression index tidak dilirik. Rencananya tetap Bitmap Index Scan dengan 1.000 baris.
  3. Rentang numeric memilih idx_expr_harga: ekspresi cast yang cocok persis diterjemahkan menjadi Index Cond pada B-tree, melayani 2.000 baris rentang — layaknya kolom biasa.

Kapan GIN Kolom Penuh, Kapan Path Spesifik #

SituasiStrategi yang menang
Query eksploratif pada path yang berubah-ubahGIN kolom penuh (jsonb_ops)
Hanya containment @>, storage dihematGIN jsonb_path_ops
Filter rutin pada 2-3 path tetap (kategori, status)expression index per path
Rentang atau ORDER BY pada path numerikexpression index dengan ::numeric
Dokumen kecil-kecil, semua kunci sering dikueripertimbangkan tanpa index: seq scan bisa saja menang

Pola yang paling sering benar di produksi adalah kombinasi: expression index untuk dua-tiga path panas (kecil, cepat, melayani sort), ditambah GIN kolom penuh hanya bila query analitis memang menjelajah struktur bebas. Index JSONB yang dipilih tanpa melihat query nyata hanyalah storage yang terbuang — dan storage terbuang itu juga bahan pertimbangan terbesar di pertanyaan yang lebih mendasar: perlukah data ini disimpan sebagai jsonb sama sekali? Chapter penutup section ini mengajukan pertanyaan itu langsung.

Referensi #

Berkas Sumber

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

buat-tabel.sql

Unduh berkas
sql
-- Katalog 20.000 produk dengan dokumen detail jsonb:
-- kategori, merek, harga di tingkat atas; berat_gram bersarang
-- di objek spesifikasi; kunci promo hanya ada pada 1 dari 20 produk.
CREATE TABLE produk (
    id     integer PRIMARY KEY,
    nama   text NOT NULL,
    detail jsonb NOT NULL
);

INSERT INTO produk (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,
           'spesifikasi', jsonb_build_object('berat_gram', (g % 500) + 100)
       )
       -- Kunci sparse: hanya produk kelipatan 20 yang berpromosi.
       -- Penggabungan || memasukkan pasangan promo secara bersyarat.
       || CASE WHEN g % 20 = 0
               THEN '{"promo": "diskon 20%"}'::jsonb
               ELSE '{}'::jsonb
           END
FROM generate_series(1, 20000) AS g;

ANALYZE produk;

-- Produk 1 tanpa promo; produk 20 (kelipatan 20) memuat kunci promo.
SELECT id, detail
FROM produk
WHERE id IN (1, 20)
ORDER BY id;

dua-kelas-operator.sql

Unduh berkas
sql
-- Dua kelas operator GIN untuk jsonb:
-- jsonb_ops (default) vs jsonb_path_ops.

-- jsonb_ops: satu entri untuk setiap kunci DAN setiap nilai di dokumen.
CREATE INDEX idx_gin_ops ON produk USING gin (detail);

-- jsonb_path_ops: satu entri untuk setiap pasangan path-nilai (dihash).
CREATE INDEX idx_gin_path ON produk USING gin (detail jsonb_path_ops);

-- Ukuran kedua index pada data yang sama persis.
SELECT pg_size_pretty(pg_relation_size('idx_gin_ops'))  AS ukuran_jsonb_ops,
       pg_size_pretty(pg_relation_size('idx_gin_path')) AS ukuran_jsonb_path_ops;

-- @> dilayani kedua kelas operator: hasil dan jumlah baris sama.
SELECT count(*) AS produk_elektronik
FROM produk
WHERE detail @> '{"kategori": "elektronik"}';

-- ? (dan ?&, ?|) hanya dilayani jsonb_ops.
SELECT count(*) AS produk_promosi
FROM produk
WHERE detail ? 'promo';

SELECT count(*) AS promo_dan_merek
FROM produk
WHERE detail ?& ARRAY['promo', 'merek'];

-- Containment tetap bekerja untuk nilai bersarang:
-- path spesifikasi ikut terindeks oleh GIN kolom penuh.
SELECT count(*) AS berat_101
FROM produk
WHERE detail @> '{"spesifikasi": {"berat_gram": 101}}';

explain-verifikasi.sql

Unduh berkas
sql
-- Verifikasi lewat EXPLAIN ANALYZE: index mana yang dipilih planner.
-- Angka waktu (actual time, Execution Time) bervariasi antar mesin;
-- pilihan index dan bentuk rencananya yang menjadi pokok bahasan.

-- 1. Containment @>: dua index GIN kandidat, planner memilih
--    jsonb_path_ops karena ukurannya lebih kecil.
EXPLAIN ANALYZE SELECT count(*)
FROM produk
WHERE detail @> '{"kategori": "elektronik"}';

-- 2. Operator ? hanya dilayani jsonb_ops: index path_ops
--    dan expression index tidak bisa membantu.
EXPLAIN ANALYZE SELECT count(*)
FROM produk
WHERE detail ? 'promo';

-- 3. Rentang harga pada ekspresi cast numeric:
--    dilayani B-tree expression index.
EXPLAIN ANALYZE SELECT count(*)
FROM produk
WHERE (detail ->> 'harga')::numeric > 90000;

indeks-ekspresi.sql

Unduh berkas
sql
-- Expression index: B-tree pada SATU path yang rutin dikueri.

-- Path teks: filter kategori pada halaman katalog.
CREATE INDEX idx_expr_kategori ON produk ((detail ->> 'kategori'));

-- Path angka: rentang harga. Hasil ->> adalah text,
-- jadi ekspresinya harus mencantumkan cast ke numeric
-- supaya perbandingan rentang bersifat numerik.
CREATE INDEX idx_expr_harga ON produk (((detail ->> 'harga')::numeric));

-- Perbandingan ukuran keempat strategi pada tabel yang sama.
SELECT 'gin kolom penuh (jsonb_ops)' AS strategi,
       pg_size_pretty(pg_relation_size('idx_gin_ops')) AS ukuran
UNION ALL
SELECT 'gin kolom penuh (jsonb_path_ops)',
       pg_size_pretty(pg_relation_size('idx_gin_path'))
UNION ALL
SELECT 'btree ekspresi (->> kategori)',
       pg_size_pretty(pg_relation_size('idx_expr_kategori'))
UNION ALL
SELECT 'btree ekspresi ((->> harga)::numeric)',
       pg_size_pretty(pg_relation_size('idx_expr_harga'))
ORDER BY strategi;

-- Query ekspresi harus menulis ekspresi yang sama persis
-- dengan yang dideklarasikan di CREATE INDEX.
SELECT count(*) AS produk_makanan
FROM produk
WHERE detail ->> 'kategori' = 'makanan';

-- Rentang harga kini query biasa pada B-tree numeric.
SELECT count(*) AS harga_di_atas_90rb
FROM produk
WHERE (detail ->> 'harga')::numeric > 90000;

jebakan-teks.sql

Unduh berkas
sql
-- Jebakan teks: hasil ->> adalah text, bukan angka.

-- ORDER BY pada teks harga mengurutkan secara leksikografis:
-- "100000" dianggap lebih kecil dari "22500" karena karakter '1' < '2'.
SELECT id, detail ->> 'harga' AS harga_teks
FROM produk
WHERE id IN (1, 8, 24, 39)
ORDER BY detail ->> 'harga';

-- Perbandingan rentang pada teks pun leksikografis:
-- tidak ada harga yang teksnya lebih besar dari '99999',
-- padahal 500 produk berharga 100000.
SELECT count(*) AS teks_di_atas_99999
FROM produk
WHERE (detail ->> 'harga') > '99999';

-- Cast ke numeric mengembalikan perbandingan yang benar.
SELECT count(*) AS angka_di_atas_99999
FROM produk
WHERE (detail ->> 'harga')::numeric > 99999;