JSONB operators & JSONPath — ->, ->>, @>, ?, jsonb_path_query
PostgreSQL không có dot notation cho JSONB: mỗi cách đọc field là một operator riêng, khác nhau ở kiểu trả về. Bài mổ 7 operator cốt lõi, JSONPath và bẫy cast số.
TL;DR: PostgreSQL không có task_metadata.priority. Muốn đọc field JSONB bạn chọn operator theo hai câu hỏi: cần kiểu gì trả về, và muốn làm gì. -> trả jsonb để chain tiếp, ->> trả text để so sánh trong WHERE; #>/#>> là bản nested với path array; @> hỏi "document có chứa mảnh này không" và là operator duy nhất GIN index tăng tốc trực tiếp; ? hỏi key có tồn tại ở top-level không. JSONPath (jsonb_path_match, jsonb_path_query) lo predicate phức tạp và so sánh số không cần cast. Hai bẫy hay gặp: ->> luôn trả text nên '9' > '10' là đúng theo bảng chữ cái, và field vắng mặt trả NULL chứ không lỗi.
TaskFlow có column task_metadata JSONB DEFAULT '{}' lưu custom field per project. Row điển hình:
task_metadata = '{"priority": "high", "labels": ["bug","prod"], "estimate": {"hours": 4, "complexity": "M"}}'
Bạn muốn "tất cả task có priority = high". Câu lệnh tự nhiên nhất trong đầu:
-- KHONG CHAY DUOC:
SELECT * FROM tasks WHERE task_metadata.priority = 'high';
-- ERROR: column "task_metadata.priority" does not exist
PostgreSQL không có dot notation cho JSONB. Mỗi kiểu truy cập là một operator riêng, và chọn đúng operator phụ thuộc vào kiểu trả về bạn cần, độ sâu path, và mục đích (lấy value, check containment, check key tồn tại).
1. Analogy — đọc hồ sơ nested nhiều lớp
Hình dung task_metadata là một tập hồ sơ có nhiều ngăn lồng nhau. Mỗi operator là một cách lấy thông tin từ tập hồ sơ đó:
| Operator | Analogy | Trả về | Dùng khi |
|---|---|---|---|
-> | Mở ngăn, lấy cả folder con | jsonb | Cần giữ JSON structure để query tiếp |
->> | Mở ngăn, lấy tờ giấy (text) | text | So sánh string, WHERE equality |
#> | Mở nhiều ngăn lồng nhau, lấy folder | jsonb | Nested path sâu, kết quả vẫn là jsonb |
#>> | Mở nhiều ngăn lồng nhau, lấy tờ giấy | text | Nested path sâu, kết quả dùng làm text |
@> | Folder có chứa sub-folder này không | boolean | Filter "chứa pattern này không" |
<@ | Folder có nằm trong folder lớn hơn không | boolean | Mirror của @> |
? | Ngăn có tồn tại không | boolean | Key existence check |
Một mũi tên (-> / ->>) cho top-level field. Dấu thăng (#> / #>>) cho nested path dùng array notation. Kết thúc bằng > trả jsonb, bằng >> trả text. @> và ? cho boolean check.

2. -> và ->> — cùng một ngăn, khác kiểu trả về
Hai operator dùng nhiều nhất, khác nhau đúng một chỗ: kiểu trả về. Nhớ lại bài 01: JSONB đã sort key và có bảng offset, nên cả hai đều nhảy thẳng tới key mà không parse lại.
-- Schema tham khao
-- tasks.task_metadata JSONB, vi du row:
-- '{"priority": "high", "labels": ["bug","prod"], "estimate": {"hours": 4}}'
-- -> tra ve JSONB (co dau ngoac kep khi la string)
SELECT task_metadata->'priority' FROM tasks WHERE id = 1;
-- "high" (jsonb string)
-- ->> tra ve TEXT (khong co dau ngoac kep)
SELECT task_metadata->>'priority' FROM tasks WHERE id = 1;
-- high (text)
-- ->> de so sanh trong WHERE
SELECT title, task_metadata->>'priority' AS priority
FROM tasks
WHERE task_metadata->>'priority' = 'high';
-- -> de chain tiep
SELECT task_metadata->'estimate'->'hours' FROM tasks WHERE id = 1;
-- 4 (jsonb number)
-- ->> voi array index (0-based)
SELECT task_metadata->'labels'->>0 FROM tasks WHERE id = 1;
-- bug
Vì sao phân biệt quan trọng: -> trả jsonb nên chain tiếp được; ->> trả text nên không chain được nhưng dùng thẳng trong WHERE.
-- Sai: chain tren text (ERROR)
SELECT task_metadata->>'estimate'->>'hours' FROM tasks WHERE id = 1;
-- ERROR: operator does not exist: text ->> unknown
-- Dung: chain tren jsonb, lay text o cuoi
SELECT task_metadata->'estimate'->>'hours' FROM tasks WHERE id = 1;
-- "4" (text)
Thử ngẫmtask_metadata->>'estimate'->>'hours' báo lỗi operator does not exist: text ->> unknown. Muốn lấy field ba cấp làm text, dấu >> chỉ được đặt ở đâu trong chuỗi?
3. #> và #>> — nested path với array notation
Khi path sâu nhiều cấp, thay vì chain nhiều ->, dùng #> với path array. Ngắn hơn và ý định rõ hơn.
-- #> lay nested field, tra ve JSONB
SELECT task_metadata #> '{estimate,hours}' FROM tasks WHERE id = 1;
-- 4 (jsonb)
-- #>> lay nested field, tra ve TEXT (tuong duong ->'estimate'->>'hours')
SELECT task_metadata #>> '{estimate,hours}' FROM tasks WHERE id = 1;
-- 4 (text)
-- Array index trong path
SELECT task_metadata #>> '{labels,0}' FROM tasks WHERE id = 1;
-- bug
-- Path khong ton tai: NULL, khong raise error
SELECT task_metadata #>> '{nonexistent,field}' FROM tasks WHERE id = 1;
-- NULL
4. Vì sao nên filter JSONB bằng @> thay vì ->> bằng?
Operator @> (contains) kiểm tra left operand có chứa toàn bộ structure của right operand không. Đây là operator mạnh nhất để filter theo pattern, và là operator mà GIN index hỗ trợ trực tiếp.
-- Filter task co priority = high
SELECT title FROM tasks
WHERE task_metadata @> '{"priority": "high"}';
-- Filter task co label "prod" trong array
SELECT title FROM tasks
WHERE task_metadata @> '{"labels": ["prod"]}';
-- Containment array, khong phai exact match:
-- {"labels": ["bug","prod"]} @> '{"labels": ["prod"]}' -> TRUE
-- {"labels": ["bug","prod"]} @> '{"labels": ["prod","bug"]}' -> TRUE (thu tu khong quan trong)
-- Nhieu field cung luc (AND semantic)
SELECT title FROM tasks
WHERE task_metadata @> '{"priority": "high", "estimate": {"complexity": "M"}}';
-- <@ la mirror (it dung hon): left la subset cua right
SELECT '{"a": 1}'::jsonb <@ '{"a": 1, "b": 2}'::jsonb;
-- TRUE
@> là operator mà GIN index (jsonb_ops hoặc jsonb_path_ops) tăng tốc trực tiếp. Khi thấy WHERE lọc theo pattern JSONB, viết bằng @> thay vì ->> cộng equality, vì @> scale tốt hơn khi có GIN. Bài 03 mổ GIN index.
Thử ngẫmbảng tasks sắp có 5 triệu row. Giữa WHERE task_metadata->>'priority' = 'high' và WHERE task_metadata @> '{"priority": "high"}', GIN index tăng tốc câu nào, và câu còn lại thì sao?
5. ?, ?|, ?& — key có tồn tại không
Ba operator kiểm tra sự tồn tại của key, không quan tâm value.
-- ? mot key ton tai o top-level
SELECT title FROM tasks WHERE task_metadata ? 'compliance_tag';
-- ?| it nhat mot trong cac key (OR)
SELECT title FROM tasks
WHERE task_metadata ?| ARRAY['compliance_tag', 'gdpr_flag', 'pii_data'];
-- ?& TAT CA cac key (AND)
SELECT title FROM tasks
WHERE task_metadata ?& ARRAY['priority', 'estimate', 'labels'];
-- ? chi check top-level key, khong check nested
SELECT '{"estimate": {"hours": 4}}'::jsonb ? 'hours';
-- FALSE: 'hours' la nested
SELECT '{"estimate": {"hours": 4}}'::jsonb ? 'estimate';
-- TRUE
6. JSONPath (PG 12+) — filter array element và predicate
PostgreSQL 12 (2019) thêm JSONPath, ngôn ngữ query kiểu XPath cho JSONB, theo chuẩn SQL/JSON. Ba hàm chính:
-- jsonb_path_match (hoac toan tu @@): predicate tra ve boolean
SELECT jsonb_path_match(task_metadata, '$.priority == "high"')
FROM tasks WHERE id = 1;
-- true
-- jsonb_path_exists (hoac @?): path co tra ve item nao khong
SELECT jsonb_path_exists(task_metadata, '$.estimate.hours')
FROM tasks WHERE id = 1;
-- true
-- jsonb_path_query: tra ve SET OF jsonb
SELECT jsonb_path_query(task_metadata, '$.labels[*]')
FROM tasks WHERE id = 1;
-- "bug"
-- "prod"
-- jsonb_path_query_first: chi lay ket qua dau tien
SELECT jsonb_path_query_first(task_metadata, '$.labels[0]')
FROM tasks WHERE id = 1;
-- "bug"
-- Filter array element theo predicate
SELECT title FROM tasks
WHERE jsonb_path_exists(task_metadata, '$.labels[*] ? (@ == "prod")');
-- JSONPath syntax chinh:
-- $.field : top-level field
-- $.a.b : nested field
-- $.arr[*] : moi element cua array
-- $.arr[*] ? (@ > 5) : filter element lon hon 5
-- $.tasks[*] ? (@.priority == "high") : filter object trong array
Chọn @> khi containment đơn giản (có GIN index). Chọn JSONPath khi predicate phức tạp, so sánh số, hoặc regex.
Một bẫy đáng nhớ: jsonb_path_exists(col, '$.priority == "high"') luôn trả true, vì biểu thức predicate luôn sinh ra đúng một item (true/false) và exists chỉ hỏi "có item không". Predicate trần thì dùng jsonb_path_match hoặc @@; với jsonb_path_exists/@? phải viết dạng filter '$.priority ? (@ == "high")'.
7. Cast số và boolean — ->> luôn trả text
Trước khi đọc tiếp, hãy đoán: với ->>'score' trả text, câu WHERE task_metadata->>'score' > '9' có lấy được task có score 10 không?
-- So sanh text: '10' < '9' theo bang chu cai
WHERE task_metadata->>'score' > '9'
-- DUNG: cast sang numeric
WHERE (task_metadata->>'score')::numeric > 9
-- JSONPath hieu so la so, khong can cast
WHERE task_metadata @@ '$.estimate.hours > 3'
-- Boolean: ->> tra text 'true'/'false', can cast
WHERE (task_metadata->>'is_archived')::boolean = false
-- Hoac so sanh jsonb voi jsonb literal
WHERE task_metadata->'is_archived' = 'false'::jsonb
Task có score 10 bị loại: '10' > '9' là sai vì so sánh dừng ở ký tự đầu ('1' đứng trước '9'). Không có lỗi, không có cảnh báo, chỉ có report thiếu đúng những task điểm cao.
8. Pitfall — return type, key hoa/thường, NULL
Pitfall 1 — nhầm -> với ->> khi so sánh:
-- SAI: so sanh jsonb voi text literal
WHERE task_metadata->'priority' = 'high'
-- ERROR: operator does not exist: jsonb = unknown
-- DUNG: ->> de lay text, hoac @> voi jsonb literal
WHERE task_metadata->>'priority' = 'high'
WHERE task_metadata @> '{"priority": "high"}'
Pitfall 2 — key phân biệt hoa/thường:
-- task_metadata = '{"Priority": "high"}'
SELECT task_metadata->>'priority' FROM tasks WHERE id = 1;
-- NULL: key 'priority' (chu thuong) khong ton tai
-- Khong co case-insensitive operator cho key -> dong nhat cach viet khi insert
Pitfall 3 — field vắng mặt trả NULL, và NULL không bằng gì cả:
-- task_metadata = '{"priority": "high"}' -- khong co 'score'
SELECT task_metadata->>'score' FROM tasks WHERE id = 1;
-- NULL (khong raise error)
-- COALESCE de fallback default
SELECT COALESCE(task_metadata->>'score', '0') AS score FROM tasks;
-- WHERE NULL = '0' luon FALSE -> row khong co field bi loai
WHERE task_metadata->>'score' = '0'
OR task_metadata->>'score' IS NULL
9. Applied — TaskFlow: filter, aggregate, update
-- Aggregate theo JSONB field
SELECT task_metadata->>'priority' AS priority,
COUNT(*) AS task_count,
AVG((task_metadata->'estimate'->>'hours')::numeric) AS avg_hours
FROM tasks
WHERE task_metadata ? 'priority' -- bo qua row khong co field
GROUP BY task_metadata->>'priority'
ORDER BY task_count DESC;
-- Update nested field: jsonb_set(target, path, new_value, create_missing)
UPDATE tasks
SET task_metadata = jsonb_set(task_metadata, '{estimate,hours}', '6'::jsonb, true)
WHERE id = 1;
-- Merge / them field moi: || (key trung -> gia tri ben phai thang)
UPDATE tasks
SET task_metadata = task_metadata || '{"reviewed_at": "2025-05-05"}'::jsonb
WHERE id = 1;
-- Xoa key: -
UPDATE tasks
SET task_metadata = task_metadata - 'temp_flag'
WHERE task_metadata ? 'temp_flag';
10. Deep Dive
- PostgreSQL Documentation — "JSON Functions and Operators": bảng đầy đủ mọi operator và function JSONB (
->,->>,#>,@>,?,||,-,jsonb_set,jsonb_path_*). Nguồn duy nhất cần bookmark cho JSONB. - PostgreSQL Documentation — "The SQL/JSON Path Language": cú pháp JSONPath đầy đủ, filter expression,
like_regex, và modelaxvsstrict. Đọc phần Strict and Lax Modes để hiểu NULL handling khác nhau. - PostgreSQL 12 Release Notes: JSONPath được thêm ở PG 12 (2019) theo chuẩn SQL/JSON.
11. Liên hệ các bài khác
- Bài 01 — JSONB vs JSON vs TEXT: vì sao
->nhảy thẳng tới key mà không parse (binary đã sort key và có offset). - Bài 03 — GIN index cho JSONB:
@>được GIN tăng tốc, còn?chỉ chạy vớijsonb_ops; chọn operator ở đây quyết định index ở bài sau. - Bài 04 — Hybrid schema: khi một field cần
(->>)::intvà expression index thường xuyên, đó là dấu hiệu nên dời nó lên column. - Bài 07 — Mini-challenge:
@> '{"labels":["urgent"]}'gộp với FTS và B-tree trong một query Bitmap And. - CTE / LATERAL trong khoá SQL & Database:
jsonb_path_querytrả set, thường ghép với LATERAL để mở array thành row.
12. Tóm tắt
- NULL bất ngờ từ
->>thường do path sai hoặc key thiếu: kiểm bằngjsonb_path_exists(col, '$.a.b')trước khi nghi ngờ dữ liệu. - Trước khi so sánh số từ
->>, cast:(col->>'hours')::int > 8; hoặc chuyển sangcol @@ '$.hours > 8'(predicate trần đi với@@/jsonb_path_match, không phảijsonb_path_exists). - Debug "query không trả row": kiểm tra key hoa/thường và field vắng mặt (NULL), thêm
OR ... IS NULLnếu muốn gộp row thiếu field. - Nested existence:
col #>> '{a,b}' IS NOT NULLhoặcjsonb_path_exists(col, '$.a.b');?không có bản nested. - Sửa JSONB tại chỗ:
jsonb_setcho nested,||để merge,-để xoá key.
13. Tự kiểm tra
- Q1Tại sao `WHERE task_metadata->'priority' = 'high'` báo lỗi, trong khi `WHERE task_metadata->>'priority' = 'high'` chạy bình thường? Viết lại câu đầu cho đúng mà không dùng `->>`.
- Q2Row có `task_metadata = '{"estimate": {"hours": 12, "complexity": "L"}}'`. Viết query tính `SUM` số giờ. Tại sao không dùng `SUM(task_metadata->'estimate'->'hours')` trực tiếp?
- Q3Tại sao `'{"labels": ["bug","prod"]}'::jsonb @> '{"labels": ["prod"]}'::jsonb` là TRUE, nhưng chiều ngược lại là FALSE?
- Q4Bạn cần tìm task có `estimate.hours` lớn hơn 8. So sánh cách dùng `->>` với cast và cách dùng JSONPath. Khi nào chọn cách nào?
- Q5Operator `?` chỉ check key top-level. Làm sao kiểm tra field `estimate.complexity` có tồn tại không?
- Q6Team đang dùng `WHERE task_metadata->>'tags' LIKE '%urgent%'` để tìm task urgent. Vì sao cách này sai về ngữ nghĩa và kém hiệu quả? Viết lại đúng.
Bài tiếp theo: GIN index trên JSONB — jsonb_ops vs jsonb_path_ops
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên