Deep merge — Rewrite one nested key without losing the rest
There are two ways to replace a single nested key. jsonb_set points at a position with a path and replaces the value there, while || overwrites keys at the same level. The overwrite performed by || covers that level only: if the value is an object, it is replaced whole rather than blended (a shallow merge).
SELECT jsonb_set(json_col, '{parent_key,child_key}', 'false'::jsonb, true), json_col || '{"top_key": 1}'::jsonb FROM table_name; -- 4th argument true = add the key if it is missing
parent_key itself is missing, jsonb_set changes nothing and returns the original value. And when any argument is NULL, the whole result is NULL.For prefs in the settings table, produce the result of setting email inside the notify object to false. Leave the other keys of notify and the other top-level keys as they are. Rows that have no notify, and rows whose prefs is NULL, must also end up with a notify whose email is false. Return user_id, prefs, sorted by user_id ascending.
| user_id | prefs (jsonb) |
|---|---|
| 1 | {"theme":"dark","notify":{"email":true,"push":true}} |
| 2 | {"notify":{"push":true}} |
| 3 | {"theme":"light"} |
| 4 | NULL |
| user_id | prefs |
|---|---|
| 1 | {"theme": "dark", "notify": {"push": true, "email": false}} |
| 2 | {"notify": {"push": true, "email": false}} |
| 3 | {"theme": "light", "notify": {"email": false}} |
| 4 | {"notify": {"email": false}} |
jsonb_each — Match two versions and list the changed keys
An audit log or a change history wants "which key changed" as rows. Open the before and after documents downwards with jsonb_each and FULL JOIN them on the key, and changes, additions and removals all line up in one table. A key present on only one side leaves NULL on the other, so the comparison uses IS DISTINCT FROM rather than <>.
SELECT COALESCE(b.key, a.key) AS key, b.value, a.value FROM jsonb_each(before_col) AS b(key, value) FULL JOIN jsonb_each(after_col) AS a(key, value) ON a.key = b.key WHERE b.value IS DISTINCT FROM a.value; -- two NULLs count as "the same"
<> the additions and removals fall out: on a row whose key exists on one side only, one side of the comparison is NULL, so b.value <> a.value is UNKNOWN and does not pass WHERE. Only keys whose value changed survive, and additions and removals vanish silently.Each row of the record_versions table holds the pre-update before_doc and the post-update after_doc. For each record, produce one row per changed key. Keys whose value changed, keys that were added and keys that were removed are all in scope. The before_value of an added key and the after_value of a removed key are NULL. Return record_id, key, before_value, after_value, sorted by record_id ascending and key ascending.
| record_id | before_doc (jsonb) | after_doc (jsonb) |
|---|---|---|
| 101 | {"name":"A","price":100,"tag":"x"} | {"name":"A","price":120,"tag":"x"} |
| 102 | {"name":"B","price":50} | {"name":"B","price":50,"color":"red"} |
| 103 | {"name":"C","stock":3} | {"name":"C"} |
| 104 | {"name":"D"} | {"name":"D"} |
| record_id | key | before_value | after_value |
|---|---|---|---|
| 101 | price | 100 | 120 |
| 102 | color | NULL | "red" |
| 103 | stock | 3 | NULL |
Delete operators — Drop the keys and elements a public copy must not carry
There are two operators for taking part of a JSON away. - drops a top-level key (given as text) or an array element (given as an integer index), and #- drops the single point named by a path array, so it reaches into the nesting. Both do nothing and return the original value if what they point at does not exist.
SELECT json_col - 'top_key' AS a, -- drop a top-level key json_col #- '{parent_key,child_key}' AS b, -- drop something deeper down json_col #- '{arr_key,0}' AS c -- drop the first element of an array FROM table_name;
- text means different things to different targets: against an object it is "drop that key", but against an array it is "drop every element equal to that string". To drop by position, use an integer index or a #- path.From body in the documents table, build a public JSON with the items that must not leave the company removed. Three things are dropped: the top-level internal_memo key, the email key inside the owner object, and the first element of the tags array (an internal label). A document that has none of them is returned with that part unchanged. Return doc_id, public_body, sorted by doc_id ascending.
| doc_id | body (jsonb) |
|---|---|
| 1 | {"title":"T1","internal_memo":"internal only","owner":{"name":"A","email":"a@example.com"},"tags":["draft","sql","index"]} |
| 2 | {"title":"T2","owner":{"name":"B"}} |
| 3 | {"title":"T3","internal_memo":"m3","owner":{"name":"C","email":"c@example.com"},"tags":["draft","json"]} |
| 4 | {"title":"T4","owner":{"name":"D","email":"d@example.com"},"tags":[]} |
| doc_id | public_body |
|---|---|
| 1 | {"tags": ["sql", "index"], "owner": {"name": "A"}, "title": "T1"} |
| 2 | {"owner": {"name": "B"}, "title": "T2"} |
| 3 | {"tags": ["json"], "owner": {"name": "C"}, "title": "T3"} |
| 4 | {"tags": [], "owner": {"name": "D"}, "title": "T4"} |
@@ predicate — Narrow JSON rows by a numeric range
The containment operator @> can only decide "does it contain this value" and cannot express a numeric comparison. @@, which evaluates a JSONPath predicate, takes a condition containing >= or && exactly as written. Where @? reports whether any element matches a path expression, @@ reports the truth of the predicate itself.
SELECT * FROM table_name WHERE json_col @@ '$.num_key >= 1000 && $.text_key == "value"'; CREATE INDEX ON table_name USING gin (json_col jsonb_path_ops); -- jsonb_path_ops serves @>, @? and @@
$.num_key >= 1000 is a comparison between JSON numbers. On a row that stores the value as the string "1000", the result is undefined and the row does not pass WHERE. A row missing the key altogether is false.From the events table, retrieve the events whose amount in props is 1000 or more and whose channel is web. Return event_id, props, sorted by event_id ascending.
| event_id | props (jsonb) |
|---|---|
| 1 | {"channel":"web","amount":1500} |
| 2 | {"channel":"app","amount":2000} |
| 3 | {"channel":"web","amount":800} |
| 4 | {"channel":"web","amount":"1200"} |
| 5 | {"channel":"web"} |
| 6 | {"channel":"web","amount":1000} |
| event_id | props |
|---|---|
| 1 | {"amount": 1500, "channel": "web"} |
| 6 | {"amount": 1000, "channel": "web"} |
Array containment — Select the rows that carry every tag
Applied to a JSON array, @> decides whether the left side contains every element of the right side. Because the decision is made on sets, neither the order nor the duplication of elements affects the result. For an array of nothing but strings, the same thing can be written with ?& (has all) and ?| (has any), and a GIN index serves both.
SELECT * FROM table_name WHERE json_col -> 'arr_key' @> '["a","b"]'::jsonb; -- for an array of strings, ?& array['a','b'] makes the same decision
json_col @> '{"arr_key":"a"}' means "the value of arr_key equals the string a", so it matches no row that holds an array there. To look inside the array, write the right side as an array too.In the articles table, meta holds a tags array. Retrieve the articles that carry both sql and postgres. Return article_id, tags, sorted by article_id ascending. tags is the array inside meta, returned as it is.
| article_id | meta (jsonb) |
|---|---|
| 1 | {"title":"Index Design","tags":["sql","postgres","index"]} |
| 2 | {"title":"Intro to SQL","tags":["sql"]} |
| 3 | {"title":"Reading Query Plans","tags":["postgres","sql"]} |
| 4 | {"title":"A Tuning Case Study","tags":["sql","sql","postgres"]} |
| 5 | {"title":"Draft","tags":[]} |
| 6 | {"title":"Notes"} |
| article_id | tags |
|---|---|
| 1 | ["sql", "postgres", "index"] |
| 3 | ["postgres", "sql"] |
| 4 | ["sql", "sql", "postgres"] |