JSON and Semi-Structured Data

PostgreSQL has a much stronger native JSON story than SQL Server’s pre-2025 text-plus-functions model. json and jsonb are real data types, jsonb is indexable, and the operator surface is broad enough that most app-facing semi-structured work stays inside JSONB. PostgreSQL also has an xml type and XPath support, but XML is a secondary surface here: it is useful for contracts that are already XML-shaped, not the default choice for new operational designs.

Scope

This note mirrors the SQL Server semi-structured track with PostgreSQL equivalents. It covers json versus jsonb, JSONB extraction and mutation, row shredding, JSONB indexing, XML extraction, and generation of JSON or XML output from relational rows.

  • Decision model covers when JSONB is the default, when plain json is justified, and when XML still has a role.
  • JSONB read and write patterns cover extraction operators, containment, existence, and jsonb_set.
  • JSONB shredding and indexing cover jsonb_to_recordset, jsonb_array_elements, and GIN indexes.
  • XML support covers xmlparse, xpath, and row expansion from XML fragments.
  • Output generation covers jsonb_build_object, jsonb_agg, and xmlelement or xmlforest.

Decision Model

Semi-structured storage is justified at the ingestion boundary or when the payload really is document-shaped. If the shape is stable and queried heavily, relational columns are still cheaper and easier to index than repeated document parsing.

Prefer jsonb unless exact text preservation matters

In PostgreSQL, jsonb is usually the default because it is binary-normalized, operator-rich, and index-friendly. Plain json is mainly for cases where exact input formatting, key order, or duplicate-key preservation matters more than indexing and structural operators.

JSONB Reading and Writing

JSONB work typically starts by building or receiving a document, then extracting scalars or fragments, then testing or mutating selected paths.

Build, extract, test, and modify document paths

The core operator family is compact: -> and ->> for field access, #> and #>> for nested paths, ? for key existence, @> for containment, and jsonb_set for targeted mutation.

Build JSONB objects directly from relational rows

Use this pattern when a query needs document-shaped output but the source data is relational. It is typically triggered by API payload generation, event emission, and debug snapshots. The query is read-only. Its purpose is to show the PostgreSQL-native JSON constructor surface on live warehouse rows.

FieldSourceTypeMeaning
symbolgold.scores_daily.symbolvarcharSource row key used for ordering and display.
payloadjsonb_build_object(...)jsonbJSONB document constructed from relational columns.

This query emits the top three latest Euro Stoxx 50 rows as compact JSONB documents.

SELECT
    symbol,
    jsonb_build_object(
        'symbol', symbol,
        'country', country,
        'current_price', current_price,
        'composite_score', ROUND(composite_score::numeric, 4)
    ) AS payload
FROM gold.scores_daily
WHERE score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
  AND _index = 'euro_stoxx_50'
ORDER BY composite_rank
LIMIT 3;
symbolpayload
BNP.PA{“symbol”: “BNP.PA”, “country”: “France”, “current_price”: 83.72, “composite_score”: 0.5967}
TTE.PA{“symbol”: “TTE.PA”, “country”: “France”, “current_price”: 79.26, “composite_score”: 0.4954}
ENI.MI{“symbol”: “ENI.MI”, “country”: “Italy”, “current_price”: 24.885, “composite_score”: 0.4807}

This is the common output-building pattern in PostgreSQL: build JSON at the edge from strongly typed relational values, rather than storing documents prematurely just to reshape them later.

Extract scalars and nested values from JSONB

Use this pattern when a query needs a few scalar values from a nested document without shredding the whole payload. It is typically triggered by lightweight document inspection and path-based projection. The query is read-only. Its purpose is to show the basic JSONB extraction operators.

FieldSourceTypeMeaning
index_keypayload ->> 'index'textScalar text value from a top-level JSON key.
as_of_textpayload ->> 'as_of'textScalar string value from a top-level key.
first_symbolpayload #>> '{top_symbols,0,symbol}'textScalar string from a nested array element.
second_scorepayload #>> '{top_symbols,1,score}'textScalar nested value rendered as text.

This query reads both top-level keys and nested array elements from one JSONB document.

WITH doc AS (
    SELECT '{"index":"euro_stoxx_50","as_of":"2026-04-08","top_symbols":[{"symbol":"BNP.PA","score":0.5967},{"symbol":"TTE.PA","score":0.4954}]}'::jsonb AS payload
)
SELECT
    payload ->> 'index' AS index_key,
    payload ->> 'as_of' AS as_of_text,
    payload #>> '{top_symbols,0,symbol}' AS first_symbol,
    payload #>> '{top_symbols,1,score}' AS second_score
FROM doc;
index_keyas_of_textfirst_symbolsecond_score
euro_stoxx_502026-04-08BNP.PA0.4954

The ->> and #>> forms return text, which is usually what projection queries need. Use -> or #> instead when the result should stay JSONB.

Test key existence and structural containment

Use this pattern when the question is about document shape rather than scalar extraction. It is typically triggered by document validation, feature-flag checks, and tag membership queries. The query is read-only. Its purpose is to show the existence and containment operators that make JSONB practical for structural predicates.

FieldSourceTypeMeaning
has_symbol_keypayload ? 'symbol'booleanWhether the top-level key exists.
contains_value_tagpayload @> '{"tags":["value"]}'::jsonbbooleanWhether the document contains the requested subdocument.
contains_quality_tagcontainment testbooleanWhether the requested tag is absent or present.

This query checks JSONB key existence and tag containment directly in the document structure.

WITH doc AS (
    SELECT '{"symbol":"BNP.PA","tags":["value","momentum"]}'::jsonb AS payload
)
SELECT
    payload ? 'symbol' AS has_symbol_key,
    payload @> '{"tags":["value"]}'::jsonb AS contains_value_tag,
    payload @> '{"tags":["quality"]}'::jsonb AS contains_quality_tag
FROM doc;
has_symbol_keycontains_value_tagcontains_quality_tag
truetruefalse

This is one of the biggest JSONB advantages over plain text JSON: document-shape predicates are first-class operators rather than string parsing tricks.

Modify a nested value with jsonb_set

Use jsonb_set when one path inside a JSONB document needs to change while the rest of the document stays intact. It is typically triggered by enrichment, patching, and document-stage transformations. The query is read-only here because it updates an inline value, not a table row. Its purpose is to show path-targeted JSONB mutation.

FieldSourceTypeMeaning
original_docJSONB literaljsonbOriginal source document before the path update.
updated_docjsonb_set(...) resultjsonbDocument after the nested score field has been replaced.

This query updates one nested metric value without rebuilding the whole document manually.

WITH doc AS (
    SELECT '{"symbol":"BNP.PA","metrics":{"score":0.5967,"rank":1}}'::jsonb AS payload
)
SELECT
    payload AS original_doc,
    jsonb_set(payload, '{metrics,score}', to_jsonb(0.7000::numeric), false) AS updated_doc
FROM doc;
original_docupdated_doc
{“symbol”: “BNP.PA”, “metrics”: {“rank”: 1, “score”: 0.5967}}{“symbol”: “BNP.PA”, “metrics”: {“rank”: 1, “score”: 0.7000}}

jsonb_set is the direct PostgreSQL equivalent of “surgically replace one path.” It avoids brittle text replacement and keeps the result structurally valid JSONB.

JSONB Shredding and Indexing

Documents become operational SQL only when the query can turn arrays and objects into rows and columns, and when hot predicates have an index strategy instead of repeated full-document scans.

Turn arrays into rowsets, then index the structural predicates

For stable document arrays, jsonb_to_recordset gives typed rows. For hot containment searches, GIN is the default JSONB index family.

Shred an array of objects into typed rows

Use this pattern when a JSONB array needs to participate in normal relational joins, filters, or aggregates. It is typically triggered by ingest-boundary shredding and analytics over nested arrays. The query is read-only. Its purpose is to show PostgreSQL’s typed JSONB-to-rows function.

FieldSourceTypeMeaning
symboljsonb_to_recordset outputtextSymbol extracted from each array object.
scorejsonb_to_recordset outputnumericNumeric score extracted from each array object.

This query converts a JSONB array of objects into a relational two-column rowset.

WITH doc AS (
    SELECT '[{"symbol":"BNP.PA","score":0.5967},{"symbol":"TTE.PA","score":0.4954},{"symbol":"ENI.MI","score":0.4807}]'::jsonb AS arr
)
SELECT *
FROM jsonb_to_recordset((SELECT arr FROM doc)) AS x(symbol text, score numeric);
symbolscore
BNP.PA0.5967
TTE.PA0.4954
ENI.MI0.4807

This is the PostgreSQL analogue to SQL Server’s explicit-schema OPENJSON: declare the output columns and types up front, then let the function emit typed rows.

Index JSONB containment with GIN

Use GIN when a JSONB column is queried repeatedly with containment or existence predicates. It is typically triggered by document-tag lookups, event routing, and metadata filters. The script is state-changing inside a transaction and rolls back at the end. Its purpose is to show the baseline JSONB index family in PostgreSQL.

FieldSourceTypeMeaning
indexnamepg_indexes.indexnametextName of the JSONB GIN index created for the temp table.
idtemp table row idintegerIdentifier of the demo document that matched the containment predicate.
symbolpayload ->> 'symbol'textSymbol extracted from the matching JSONB document.

This transaction creates a temp JSONB table, adds a GIN index, proves the index exists in the temp schema, and then runs a containment predicate.

BEGIN;
 
CREATE TEMP TABLE note09_jsonb_demo (
    id integer,
    payload jsonb
) ON COMMIT DROP;
 
INSERT INTO note09_jsonb_demo VALUES
    (1, '{"symbol":"BNP.PA","tags":["value","bank"]}'::jsonb),
    (2, '{"symbol":"TTE.PA","tags":["energy","momentum"]}'::jsonb),
    (3, '{"symbol":"MU","tags":["tech","value"]}'::jsonb);
 
CREATE INDEX note09_jsonb_demo_payload_gin
    ON note09_jsonb_demo
    USING gin (payload jsonb_path_ops);
 
SELECT indexname
FROM pg_indexes
WHERE schemaname LIKE 'pg_temp%'
  AND tablename = 'note09_jsonb_demo'
ORDER BY indexname;
 
SELECT
    id,
    payload ->> 'symbol' AS symbol
FROM note09_jsonb_demo
WHERE payload @> '{"tags":["value"]}'::jsonb
ORDER BY id;
 
ROLLBACK;
BEGIN
CREATE TABLE
INSERT 0 3
CREATE INDEX
indexname
note09_jsonb_demo_payload_gin
idsymbol
1BNP.PA
3MU
ROLLBACK

The teaching boundary is the index family, not the tiny rowset. In production, GIN is the default answer for broad JSONB containment and existence queries, while scalar-path hot spots often deserve expression indexes on extracted values.

XML Support

PostgreSQL’s XML surface is real but smaller than its JSONB surface. Use it when the upstream contract is XML or XPath semantics are the natural fit. Unlike SQL Server, PostgreSQL does not have a parallel family of dedicated XML indexes in core; the normal approach is to shred XML into relational form or index extracted scalar expressions.

Extract values and rows with XPath

The XML workflow is usually: parse or store XML, extract scalar fragments with xpath, and unnest repeating nodes when rows are needed.

Extract scalar values from an XML document

Use this pattern when a query needs only a few scalar values from one XML document. It is typically triggered by contract inspection and targeted attribute reads. The query is read-only. Its purpose is to show the minimal XPath extraction path in PostgreSQL.

FieldSourceTypeMeaning
symbol_attrxpath('/quote/@symbol', payload)textSymbol attribute extracted from the XML element.
country_textxpath('/quote/country/text()', payload)textCountry element text extracted from the XML document.
score_textxpath('/quote/score/text()', payload)textScore element text extracted from the XML document.

This query extracts one attribute and two element values from an XML document literal.

WITH doc AS (
    SELECT xmlparse(document '<quote symbol="BNP.PA"><country>France</country><score>0.5967</score></quote>') AS payload
)
SELECT
    (xpath('/quote/@symbol', payload))[1]::text AS symbol_attr,
    (xpath('/quote/country/text()', payload))[1]::text AS country_text,
    (xpath('/quote/score/text()', payload))[1]::text AS score_text
FROM doc;
symbol_attrcountry_textscore_text
BNP.PAFrance0.5967

The result comes back as XML nodes first, which is why the query selects the first match and casts it to text. That is the main PostgreSQL XML extraction idiom.

Unnest repeated XML nodes into rows

Use this pattern when an XML document contains a repeating collection that must be processed relationally. It is typically triggered by batch ingest, nested payload shredding, and downstream joins. The query is read-only. Its purpose is to show the XML analogue to JSON array shredding.

FieldSourceTypeMeaning
symbolxpath('/item/@symbol', node)textSymbol attribute extracted from each XML node.
scorexpath('/item/@score', node)textScore attribute extracted from each XML node.

This query turns an XML collection of <item> elements into one row per item.

WITH doc AS (
    SELECT xmlparse(document '<top_symbols><item symbol="BNP.PA" score="0.5967"/><item symbol="TTE.PA" score="0.4954"/><item symbol="ENI.MI" score="0.4807"/></top_symbols>') AS payload
)
SELECT
    array_to_string(xpath('/item/@symbol', x.node)::text[], '') AS symbol,
    array_to_string(xpath('/item/@score', x.node)::text[], '') AS score
FROM doc
CROSS JOIN LATERAL unnest(xpath('/top_symbols/item', payload)) AS x(node);
symbolscore
BNP.PA0.5967
TTE.PA0.4954
ENI.MI0.4807

The pattern is the same as every semi-structured shred: expand the repeating nodes first, then project typed columns from each node.

Building Semi-Structured Output

Semi-structured support is not only about reading documents. PostgreSQL also builds JSON and XML cleanly from relational rows, which is often the right boundary for exports and API responses.

Emit JSONB or XML from relational rows at the edge

The cleanest architecture is usually relational storage plus semi-structured output generation only where a consumer actually needs it.

Build XML output with xmlelement and xmlforest

Use this pattern when a query must emit an XML fragment from relational values without storing the payload as XML first. It is typically triggered by export interfaces and contract-bound integrations. The query is read-only. Its purpose is to show PostgreSQL’s direct XML output builders.

FieldSourceTypeMeaning
xml_payloadxmlelement(... xmlforest(...))xmlXML fragment emitted from one relational row.

This query renders the top-ranked Euro Stoxx 50 row as a small XML fragment.

SELECT
    xmlelement(
        NAME top_symbol,
        xmlforest(symbol AS symbol, country AS country, current_price AS current_price)
    ) AS xml_payload
FROM gold.scores_daily
WHERE score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
  AND _index = 'euro_stoxx_50'
ORDER BY composite_rank
LIMIT 1;
xml_payload
<top_symbol>BNP.PAFrance<current_price>83.72</current_price></top_symbol>

The point is the boundary choice: relational input, XML output only where needed. That is usually cheaper and more maintainable than storing XML in the core warehouse unless XML is the natural ingestion format.

Practical Rules

Choose the semi-structured surface that matches the operational requirement instead of translating SQL Server document features literally.

NeedPostgreSQL patternWhy
General-purpose app-facing document storagejsonbBinary-normalized, rich operator surface, index-friendly.
Preserve exact input text or key orderjsonRetains input text semantics at the cost of weaker indexing.
Extract a few scalar values->>, #>>Fast path-based JSONB scalar extraction.
Test document shape?, @>First-class existence and containment operators.
Patch a nested JSON valuejsonb_setTargeted document mutation without text surgery.
Shred JSON arrays into rowsjsonb_to_recordset, jsonb_array_elementsTyped relational expansion from document arrays.
Accelerate broad JSONB containment queriesGIN on jsonbDefault JSONB index family.
Work with XML contractsxmlparse, xpath, xmlelementPostgreSQL XML surface for XPath-based reads and XML output.
Index hot XML lookupsFunctional indexes on extracted values or relational shreddingCore PostgreSQL has no SQL Server-style XML index family.