Ada data yang bentuknya berubah-ubah. Log aplikasi hari ini punya field user_id, besok dapat field retry_count, minggu depan malah muncul field baru dari versi API yang naik. Kalau kamu paksa masukin ke kolom tabel relasional biasa, tiap perubahan bikin kamu ALTER TABLE terus. JSONB adalah jawaban PostgreSQL buat masalah ini: tipe data biner yang nyimpen JSON, tapi tetap bisa di-query dan di-index kayak kolom biasa.
JSON vs JSONB: Bukan Sekadar Nama
PostgreSQL punya dua tipe buat JSON: json dan jsonb. json nyimpen teks mentah persis seperti yang kamu kirim, lengkap dengan spasi dan urutan key-nya. jsonb nge-parse teks itu jadi format biner terkompresi: buang spasi, sortir key, dan dedupe key yang dobel. Konsekuensinya, json lebih cepat buat insert karena nggak perlu parse, tapi jsonb jauh lebih cepat buat query dan bisa di-index. Untuk hampir semua kasus produksi, pilih jsonb.
Kalau kamu belum pernah main PostgreSQL sama sekali, baca dulu Belajar PostgreSQL dengan Python biar setup koneksinya nggak bikin bingung. Di sana kamu juga dapat dasar psycopg yang kita pakai lagi di bagian akhir artikel ini.
Siapkan Tabel dengan Kolom JSONB
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
event_type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Kolom payload adalah JSONB-nya. DEFAULT '{}'::jsonb penting: tanpa default, kolom ini bisa NULL dan operator -> bakal error. Sekarang isi beberapa baris, satu dengan bentuk yang beda dari yang lain. Justru itu poinnya, skema payload tiap event nggak harus seragam.
INSERT INTO events (event_type, payload) VALUES
('signup', '{"user_id": 7, "plan": "pro", "referrer": "twitter"}'),
('purchase', '{"user_id": 7, "amount": 149000, "items": ["kursus", "ebook"], "coupon": "DISKON20"}'),
('login_failed', '{"user_id": 7, "reason": "wrong_password", "attempts": 3}');
Baca Field dengan -> dan ->>
Dua operator ini yang paling sering kamu pakai. -> mengembalikan hasil sebagai JSONB (masih ada tanda kutipnya), ->> mengembalikan hasil sebagai text. Bedanya penting kalau kamu mau bandingin nilai atau masukan ke kolom biasa.
SELECT payload -> 'plan' AS plan_jsonb,
payload ->> 'plan' AS plan_text,
payload -> 'items' AS items_jsonb,
payload ->> 'items' AS items_text
FROM events
WHERE event_type = 'purchase';
Hasilnya: plan_jsonb keluar sebagai "DISKON20" (dengan kutip), sementara plan_text keluar sebagai DISKON20 polos. Kalau kamu tulis WHERE payload ->> 'plan' = 'pro', itu baru match, sedangkan pakai -> bakal gagal karena JSONB "pro" beda dari text pro.
Operator -> dan ->> itu null-safe: kalau key-nya nggak ada, hasilnya NULL, bukan error. Ini beda jauh dari JavaScript yang lempar TypeError waktu kamu akses properti yang undefined. Tapi hati-hati: kalau kolom payload-nya sendiri yang NULL (bukan key-nya), -> tetap lempar error. Itu kenapa kita kasih NOT NULL DEFAULT '{}' di atas.
Tembus Struktur Nested dengan #> dan #>>
Buat struktur yang bersarang dalam-dalam, ada #> dan #>>. Bedanya sama seperti sebelumnya, tapi operator ini nerima path berupa array text, bukan nama key tunggal.
-- Path {'a','b','c'} artinya a.b.c
SELECT '{"a":{"b":{"c":42}}}'::jsonb #>> '{a,b,c}' AS deep_value;
-- 42 (sebagai text)
-- Ambil elemen pertama dari array di dalam JSON
SELECT payload #>> '{items,0}' AS first_item
FROM events
WHERE event_type = 'purchase';
Buat ngebongkar array jadi baris-baris terpisah, pakai jsonb_array_elements. Ini kombinasi yang kuat buat laporan, karena tiap elemen array jadi satu baris yang bisa kamu join atau agregasi.
SELECT jsonb_array_elements(payload -> 'items') AS item
FROM events
WHERE event_type = 'purchase';
-- item
-- "kursus"
-- "ebook"
Agregasi: jsonb_agg dan jsonb_object_agg
Arah sebaliknya juga jalan: bangun JSON dari baris-baris tabel. jsonb_agg ngegabungin banyak nilai jadi satu array JSON, jsonb_object_agg ngegabungin pasangan key-value jadi satu object JSON.
SELECT jsonb_agg(payload ORDER BY created_at) AS semua_event
FROM events;
SELECT jsonb_object_agg(id::text, event_type) AS id_ke_tipe
FROM events;
GIN Index Biar Query JSON Nggak Lemot
Tanpa index, query JSONB jalan lewat sequential scan: PostgreSQL baca seluruh tabel buat nemuin baris yang cocok. Buat tabel besar, ini lambat. Solusinya GIN index, yang nge-index tiap key dan value di dalam JSON sebagai entri terpisah.
CREATE INDEX idx_events_payload ON events USING GIN (payload);
-- Cari baris yang payload-nya mengandung {"user_id": 7}
EXPLAIN ANALYZE
SELECT * FROM events
WHERE payload @> '{"user_id": 7}';
Operator @> artinya "mengandung" (containment). Ini cocok buat query kayak "cari semua event dari user 7" tanpa peduli field lain di payload. Konsep GIN index ini sama persis yang dipakai di Full-Text Search PostgreSQL, satu index buat banyak nilai dalam satu kolom.
GIN dengan operator class default mendukung operator ?, ?|, ?&, dan @>. Kalau kamu cuma butuh containment (@>) dan mau index lebih ramping, pakai CREATE INDEX ... USING GIN (payload jsonb_path_ops). Index-nya lebih kecil, tapi nggak mendukung ? yang ngecek keberadaan key.
Update Sebagian Field dengan jsonb_set
Nggak perlu tulis ulang seluruh payload buat ganti satu field. jsonb_set ngubah nilai di path tertentu, operator - hapus key, dan || ngegabungin dua JSONB.
-- Ganti nilai coupon
UPDATE events
SET payload = jsonb_set(payload, '{coupon}', '"DISKON50"')
WHERE event_type = 'purchase';
-- Hapus key referrer
UPDATE events
SET payload = payload - 'referrer'
WHERE event_type = 'signup';
-- Tambah key baru
UPDATE events
SET payload = payload || '{"verified": true}'
WHERE event_type = 'signup';
Pakai JSONB dari Python (psycopg)
Enaknya JSONB muncul waktu kamu pakai dari Python. psycopg versi 3 otomatis nge-serialize dict Python jadi jsonb waktu insert, dan ngubah hasil query balik jadi dict. Nggak perlu json.dumps manual.
import psycopg
with psycopg.connect("postgresql://postgres:password@localhost:5432/mydb") as conn:
with conn.cursor() as cur:
# dict Python langsung jadi jsonb
cur.execute(
"INSERT INTO events (event_type, payload) VALUES (%s, %s)",
("signup", {"user_id": 9, "plan": "free"}),
)
# query containment, parameter juga dict
cur.execute(
"SELECT payload ->> 'plan' FROM events WHERE payload @> %s",
({"user_id": 9},),
)
print(cur.fetchone()) # ('free',)
JSONB cocok buat data yang nggak pasti bentuknya: log, payload webhook, respons API pihak ketiga, sampai metadata produk e-commerce. Kalau kamu nyimpen embedding vektor di PostgreSQL, kamu juga bakal ketemu JSONB di Vector Search dengan pgvector, karena pgvector nyimpen metadata-nya di kolom JSONB. Sekarang coba: buat tabel events di atas, isi 10 baris payload acak, lalu latihan nulis query @> tanpa liat dokumentasi.
Kapan pakai jsonb, kapan pakai kolom relasional biasa?
Kalau skema kamu stabil dan butuh foreign key, constraint, atau join yang ketat, pakai kolom biasa. Kalau bentuk data sering berubah, datang dari API pihak ketiga, atau banyak field opsional yang beda-beda tiap baris, JSONB lebih praktis. Banyak tim nyampur keduanya: kolom penting yang selalu di-query jadi kolom biasa, sisanya dibungkus JSONB.
Bukankah ini sama saja seperti pakai MongoDB?
Mirip dari sisi fleksibilitas, tapi beda fondasi. JSONB memberi kamu fleksibilitas JSON plus fitur SQL lengkap: join, transaksi ACID, dan constraint, semua dalam satu database. Kamu nggak perlu narik data ke aplikasi cuma buat filter sederhana, dan nggak perlu maintain dua sistem database terpisah.
Apakah kolom jsonb bisa dipakai di WHERE biasa?
Bisa. Kamu pakai operator seperti ->> buat banding nilai text, @> buat containment, dan ? buat ngecek keberadaan key. Semuanya bisa dipercepat dengan GIN index, jadi query JSONB nggak otomatis lambat dibanding kolom biasa.