Modifying JSONB — Building, Updating, and Merging Documents
Modifying JSONB — Building, Updating, and Merging Documents
JSONB is not read-only. PostgreSQL provides a rich set of functions and operators for constructing new documents, updating existing ones, merging documents together, and removing keys — all without leaving SQL.
JSONB modification functions
| Function/Operator | Operation | Example |
|---|---|---|
jsonb_build_object(k,v,...) |
Create JSONB from key-value pairs | jsonb_build_object('name','Alice','age',30) |
jsonb_build_array(v,...) |
Create JSONB array | jsonb_build_array(1,2,'three') |
to_jsonb(value) |
Convert any SQL value to JSONB | to_jsonb(NOW()) |
row_to_json(row) |
Convert a row to JSON | row_to_json(t) |
jsonb_set(doc, path, value) |
Set/replace a path in a document | jsonb_set(attrs, '{color}', '"red"') |
jsonb_insert(doc, path, value) |
Insert without overwriting | Works for arrays and new keys |
doc || other_doc |
Merge (shallow) two JSONB documents | '{"a":1}' || '{"b":2}' |
doc - 'key' |
Remove a key | attributes - 'draft' |
doc - ARRAY['k1','k2'] |
Remove multiple keys | attributes - ARRAY['tmp','_v'] |
doc #- '{path,to,key}' |
Remove a nested path | doc #- '{specs,draft}' |
Example 1: Building JSONB from query results
jsonb_build_object is the primary tool for constructing JSONB from dynamic query results — useful for building API responses, creating audit records, or populating JSONB columns from existing data:
-- Build a JSONB summary document per client
SELECT
c.company_name,
jsonb_build_object(
'client_id', c.client_id,
'industry', c.industry,
'country', c.country,
'account_manager', c.account_manager,
'invoice_count', COUNT(i.invoice_id),
'total_billed', ROUND(SUM(ii.quantity * ii.unit_price), 2),
'paid_pct', ROUND(100.0 * SUM(CASE WHEN i.status='paid'
THEN ii.quantity * ii.unit_price ELSE 0 END)
/ NULLIF(SUM(ii.quantity * ii.unit_price), 0), 1),
'statuses', jsonb_agg(DISTINCT to_jsonb(i.status)),
'last_invoice', MAX(i.issue_date)
) AS client_summary
FROM clients c
JOIN invoices i ON i.client_id = c.client_id
JOIN invoice_items ii ON ii.invoice_id = i.invoice_id
GROUP BY c.client_id, c.company_name, c.industry, c.country, c.account_manager
ORDER BY c.company_name;
jsonb_agg is the JSONB-specific version of ARRAY_AGG — it aggregates values into a JSONB array rather than a PostgreSQL array. Use it when you need JSONB output; use ARRAY_AGG when you need a PostgreSQL array.
Example 2: jsonb_set — updating a nested field
jsonb_set(target, path, new_value, create_missing) sets a specific path within an existing document. The path is an array literal (e.g. '{key}' or '{nested,key}'). The fourth parameter controls whether to create the path if it doesn't exist.
-- Add a 'last_updated' timestamp to all product attributes
UPDATE product_catalog
SET attributes = jsonb_set(
attributes,
'{last_updated}',
to_jsonb(NOW()::TEXT),
true -- create if missing
)
WHERE category = 'Electronics';
-- Update a nested value: increase ram_gb by 8 for all eligible products
UPDATE product_catalog
SET attributes = jsonb_set(
attributes,
'{ram_gb}',
to_jsonb((attributes ->> 'ram_gb')::INT + 8)
)
WHERE category = 'Electronics'
AND (attributes ->> 'ram_gb')::INT < 32;
-- Verify the update
SELECT name, attributes ->> 'ram_gb' AS ram_gb, attributes ->> 'last_updated' AS updated
FROM product_catalog WHERE category = 'Electronics';
Example 3: The || merge operator
The || operator performs a shallow merge of two JSONB objects. Keys from the right document overwrite keys from the left:
-- Merge additional metadata into existing attributes
WITH enrichment AS (
SELECT
product_id,
jsonb_build_object(
'in_stock', TRUE,
'warehouse', 'London-1',
'last_checked', CURRENT_DATE::TEXT
) AS extra_data
FROM product_catalog
WHERE category = 'Electronics'
)
UPDATE product_catalog pc
SET attributes = pc.attributes || e.extra_data
FROM enrichment e
WHERE e.product_id = pc.product_id;
-- The || operator does a SHALLOW merge — if both documents have 'specs' object,
-- the right-side 'specs' completely replaces the left-side 'specs'.
-- For deep merge, use jsonb_set or a custom function.
-- Demonstrate: remove staging keys before production export
SELECT
name,
attributes - ARRAY['last_updated', 'warehouse', 'last_checked'] AS clean_attributes
FROM product_catalog
WHERE category = 'Electronics';
Example 4: Transforming relational data to JSONB — the aggregation pattern
A common data pipeline requirement: take normalised relational data and aggregate it into JSONB documents for export to an API or document store. This example builds a complete order document with embedded line items:
SELECT
o.order_id,
jsonb_build_object(
'order_id', o.order_id,
'order_date', o.created_at::DATE,
'status', o.status,
'customer', jsonb_build_object(
'id', c.customer_id,
'country', c.country,
'channel', c.acquisition_channel
),
'items', jsonb_agg(
jsonb_build_object(
'product_id', p.product_id,
'name', p.name,
'category', p.category,
'quantity', oi.quantity,
'unit_price', oi.unit_price,
'line_total', ROUND(oi.quantity * oi.unit_price, 2)
)
ORDER BY p.category, p.name
),
'totals', jsonb_build_object(
'subtotal', o.total_amount,
'discount', o.discount_amount,
'item_count', SUM(oi.quantity),
'unique_products', COUNT(DISTINCT oi.product_id)
)
) AS order_document
FROM ec_orders o
JOIN ec_customers c ON c.customer_id = o.customer_id
JOIN ec_order_items oi ON oi.order_id = o.order_id
JOIN ec_products p ON p.product_id = oi.product_id
WHERE o.status = 'completed'
GROUP BY o.order_id, o.created_at::DATE, o.status, o.total_amount, o.discount_amount,
c.customer_id, c.country, c.acquisition_channel
ORDER BY o.created_at::DATE DESC
LIMIT 5;
Key Takeaway
JSONB modification follows three patterns: jsonb_build_object for construction, jsonb_set for surgical updates (one path at a time), and || for shallow merges. The - key operator removes keys cleanly. For transforming relational data into documents, jsonb_build_object + jsonb_agg is the standard approach. Use to_jsonb() to convert any PostgreSQL value (timestamp, integer, boolean) into a JSONB-compatible value.