| |

SQL 121 🛢️ Querying Unstructured Data with XML and XPath

⏱️ Estimated read: 15 min | |

Relational databases have stored XML data for decades, but the mechanism for querying it is not SQL. XPath is a separate expression language with its own syntax, its own type system, and its own limitations. The SQL functions that expose XPath—PostgreSQL’s xpath() and xpath_exists(), Oracle’s XMLExists and XMLQuery, SQL Server’s xml methods—are bridges between two languages that do not share a data model. The bridge works, but the impedance is real: XPath 1.0 returns node-sets and atomic values, not rows, and the SQL function must translate one into the other . Understanding what XPath can and cannot express is the difference between a query that returns the data and a query that returns an empty result for reasons that are not obvious.

Key point: XPath 1.0 has no sequences, no conditional expressions, and no string ordering comparison. It can select nodes from an XML document and compare their string values to literals. It cannot construct new nodes, iterate with control flow, or sort. Those operations belong to XQuery, which most databases do not implement in their XPath functions .


Why XML and XPath persist in relational databases

The integration problem. Enterprise systems exchange data as XML. SOAP APIs, industry standards like HL7 and FIX, and configuration files all use XML as the wire format. Storing that data in a relational database and querying it with SQL requires a bridge. The SQL/XML standard defines that bridge: functions that construct XML from relational data and functions that query XML with XPath or XQuery . PostgreSQL, Oracle, and SQL Server all implement parts of this standard, though with different levels of conformance.

The semi-structured problem. XML is hierarchical and schema-flexible in a way that relational tables are not. A purchase order has a variable number of line items. A configuration file has optional sections. Storing these in normalized tables requires either a rigid schema that cannot accommodate variation or an entity-attribute-value model that is difficult to query. Storing the document as a single XML value preserves the structure, and XPath provides a query language that understands that structure .

The XPath 1.0 constraint. Most database implementations rely on libxml2 for XPath evaluation, and libxml2 supports only XPath 1.0 . This is not a choice; it is a dependency. PostgreSQL’s documentation is explicit: “PostgreSQL relies on the libxml2 library, which provides only XPath 1.0” . Oracle’s OSM documentation states the same limitation . SQL Server implements a subset of XQuery that includes XPath 2.0 features, but its XPath functions are still constrained by the data type’s capabilities . The practical consequence is that the query language available in SQL is XPath 1.0, not the more expressive XPath 2.0 or 3.1.


a. PostgreSQL: xpath() and xpath_exists()

PostgreSQL provides two XPath functions: xpath() returns an array of XML values matching the expression, and xpath_exists() returns a boolean indicating whether any match exists . Both take an XPath expression and an XML document as arguments. The XPath expression is evaluated against the document, and the result is converted to the function’s return type.

-- xpath() returns an array of XML values
SELECT xpath('/book/title/text()', '<book><title>Dune</title></book>');
-- Result: {Dune}

-- xpath_exists() returns a boolean
SELECT xpath_exists('/book/title[text()="Dune"]', '<book><title>Dune</title></book>');
-- Result: true

The return type of xpath() is xml[], an array of XML fragments. To use the result as text in a comparison or a join, it must be cast. The text() node test in the XPath expression selects the text content of the element rather than the element itself, which makes the cast to text direct.

-- Cast the result to text
SELECT (xpath('/book/title/text()', '<book><title>Dune</title></book>'))[1]::text AS title;
-- Result: Dune

PostgreSQL also provides xpath_table(), a set-returning function that applies multiple XPath expressions to each row of a table and returns the results as a virtual table. The function takes a key column, the XML document column, the table name, a pipe-separated list of XPath expressions, and a WHERE clause . This is the mechanism for turning XML documents stored in a table into relational rows that can be joined and aggregated.

SELECT * FROM xpath_table(
  'id',
  'doc',
  'orders',
  '/order/@id | /order/item/name | /order/item/price',
  'true'
) AS t(order_id text, item_name text, price numeric);

The column types in the AS clause determine how the XPath results are cast. If a result cannot be cast to the specified type, the query fails. The documentation recommends using text for columns where the data may be empty or malformed, because the cast is more forgiving .


b. Oracle: XMLExists and XMLQuery

Oracle’s SQL/XML functions follow the standard more closely than PostgreSQL’s. XMLExists takes an XPath expression and a passing clause that binds a variable to the XML document. It returns a boolean that can be used in a WHERE clause . XMLQuery returns an XML value, and XMLCast converts that value to a SQL type.

-- XMLExists in a WHERE clause
SELECT count(*) FROM purchaseorder p
WHERE XMLExists('$p/PurchaseOrder[Reference="SBELL-2002100912333601PDT"]'
  PASSING p.OBJECT_VALUE AS "p");

-- XMLQuery with XMLCast to extract a value
SELECT XMLCast(XMLQuery('$p/PurchaseOrder/Reference'
  PASSING OBJECT_VALUE AS "p" RETURNING CONTENT) AS VARCHAR2(30))
FROM purchaseorder;

The $p variable is bound to the XML document by the PASSING clause. The XPath expression uses the variable as the context node. This is the standard mechanism for correlating the XPath expression with the row being queried . The RETURNING CONTENT clause specifies that the result is an XML fragment rather than a serialized string.

Oracle also provides XMLTable, which is the standard mechanism for shredding XML into relational rows. It takes an XPath expression that selects a set of nodes, and a column definition list that defines how to extract values from each node. This is more powerful than xpath_table because it supports nested structures and default values.


c. SQL Server: XQuery Methods on the xml Data Type

SQL Server’s approach is different. The xml data type has methods that are called like functions: query(), value(), exist(), and nodes(). These methods accept XQuery expressions, which are a superset of XPath 2.0 . The value() method extracts a scalar value and requires a SQL type and a singleton XQuery expression.

-- value() extracts a scalar from the XML
SELECT doc.value('(/book/title)[1]', 'nvarchar(100)') AS title
FROM books;

-- exist() returns 1 or 0 for a boolean XQuery
SELECT doc.exist('/book/title[text()="Dune"]') AS has_dune
FROM books;

-- nodes() shreds the XML into rows
SELECT T.c.value('@id', 'int') AS item_id,
       T.c.value('name[1]', 'nvarchar(100)') AS item_name
FROM orders
CROSS APPLY doc.nodes('/order/item') AS T(c);

The nodes() method is the SQL Server equivalent of XMLTable and xpath_table. It returns a rowset of XML fragments, one per matching node, which can then be cross-applied to the original row. The value() method is called on each fragment to extract scalar values. This pattern is the standard way to shred XML into relational rows in SQL Server.

The value() method requires a singleton XQuery expression. If the expression returns more than one node, the query fails. This is why the expression (/book/title)[1] includes the [1] predicate: it ensures that only one title is selected . The nodes() method does not have this restriction because its purpose is to return multiple nodes.


Complete Example Session

-- ============================================
-- PART 1: POSTGRESQL — xpath() BASIC EXTRACTION
-- ============================================
-- Extract the text content of a single element
SELECT (xpath('/book/title/text()',
  '<book><title>Dune</title><author>Herbert</author></book>'))[1]::text;
-- Result: Dune


-- ============================================
-- PART 2: POSTGRESQL — xpath_exists() FILTERING
-- ============================================
-- Filter rows where the XPath matches
SELECT * FROM books
WHERE xpath_exists('/book/author[text()="Herbert"]', doc);
-- Returns rows where the author is Herbert


-- ============================================
-- PART 3: POSTGRESQL — xpath_table() SHREDDING
-- ============================================
-- Turn XML documents into relational rows
SELECT * FROM xpath_table(
  'id',
  'doc',
  'orders',
  '/order/item/name | /order/item/price',
  'true'
) AS t(order_id text, item_name text, price numeric);


-- ============================================
-- PART 4: ORACLE — XMLExists IN WHERE
-- ============================================
SELECT count(*) FROM purchaseorder p
WHERE XMLExists('$p/PurchaseOrder[Reference="SBELL-2002100912333601PDT"]'
  PASSING p.OBJECT_VALUE AS "p");


-- ============================================
-- PART 5: ORACLE — XMLQuery WITH XMLCast
-- ============================================
SELECT XMLCast(XMLQuery('$p/PurchaseOrder/Reference'
  PASSING OBJECT_VALUE AS "p" RETURNING CONTENT) AS VARCHAR2(30))
FROM purchaseorder;


-- ============================================
-- PART 6: SQL SERVER — value() EXTRACTION
-- ============================================
SELECT doc.value('(/book/title)[1]', 'nvarchar(100)') AS title
FROM books;


-- ============================================
-- PART 7: SQL SERVER — exist() FILTERING
-- ============================================
SELECT * FROM books
WHERE doc.exist('/book/title[text()="Dune"]') = 1;


-- ============================================
-- PART 8: SQL SERVER — nodes() SHREDDING
-- ============================================
SELECT T.c.value('@id', 'int') AS item_id,
       T.c.value('name[1]', 'nvarchar(100)') AS item_name
FROM orders
CROSS APPLY doc.nodes('/order/item') AS T(c);


-- ============================================
-- PART 9: XPATH 1.0 LIMITATION — NO CONDITIONAL
-- ============================================
-- XPath 1.0 has no if/then/else
-- To return a default value when a node is missing,
-- use SQL COALESCE around the XPath result
SELECT COALESCE((xpath('/book/subtitle/text()', doc))[1]::text, 'No subtitle')
FROM books;


-- ============================================
-- PART 10: XPATH 1.0 LIMITATION — NO SEQUENCES
-- ============================================
-- XPath 1.0 returns a node-set or a single atomic value
-- It cannot return a mixed sequence of nodes and values
-- To combine results, use multiple XPath expressions
-- and join them in SQL

The ten parts trace the three database implementations and the two most consequential limitations. Part 9 shows the workaround for the missing conditional operator: the default value is applied in SQL, not XPath. Part 10 shows the structural limitation: XPath 1.0 cannot return a sequence that mixes nodes and atomic values.


Quick Reference

Database Function Comparison

DatabaseExtract ValueFilterShred to Rows
PostgreSQLxpath()xpath_exists()xpath_table()
OracleXMLQuery + XMLCastXMLExistsXMLTable
SQL Servervalue()exist()nodes()

XPath 1.0 Capabilities and Limits

FeatureAvailable in XPath 1.0
Node selection by pathYes
Attribute selection (@attr)Yes
Predicates ([condition])Yes
String functions (contains, starts-with)Yes
Numeric functions (count, sum)Yes
Conditional (if/then/else)No
SequencesNo
String ordering (<, >)No
Node constructionNo
SortingNo

Common XPath Expressions

ExpressionSelects
/book/titletitle child of root book
//titleAll title elements anywhere
//item[@id="1"]item with id attribute equal to 1
//title/text()Text content of title elements
//item[price > 10]item with price greater than 10

Best Practices

✅ Do This:

-- Use text() to extract content, then cast in SQL
SELECT (xpath('/book/title/text()', doc))[1]::text FROM books;          -- ✅
-- Use xpath_exists() for filtering instead of extracting and comparing
WHERE xpath_exists('/book/author[text()="Herbert"]', doc)              -- ✅
-- Use xpath_table() or nodes() to shred XML into rows
SELECT * FROM xpath_table('id', 'doc', 'orders', '//item/name', 'true') -- ✅
-- Cast XPath results to text in SQL when the data may be malformed
SELECT (xpath('//price/text()', doc))[1]::text AS price_text           -- ✅

❌ Don’t Do This:

-- Expect XPath 1.0 to return a default value for missing nodes
-- xpath() returns an empty array, not a default
SELECT (xpath('/book/subtitle/text()', doc))[1]::text;  -- NULL         -- ❌
-- Use string ordering comparison in XPath 1.0
-- "cat" < "dog" is a numeric comparison of NaN, always false            -- ❌
-- Assume XPath 2.0 features are available
-- if/then/else, sequences, and node construction are not supported      -- ❌
-- Use the `::` axis with node types in MySQL XPath functions
-- Not supported (MySQL limitation)                                      -- ❌

Common Pitfalls

PitfallWhy It HappensFix
xpath() returns empty arrayNo node matchedVerify path and use xpath_exists() to test
Cast failsXPath returns an element, not textUse text() in the XPath expression
XMLExists returns false unexpectedlyVariable binding is wrongCheck the PASSING clause variable name
value() fails in SQL ServerXQuery returns multiple nodesAdd [1] predicate to force singleton
XPath string comparison failsXPath 1.0 has no string orderingUse = or != for equality only

Real-World Examples

1. PostgreSQL Extract Title

SELECT (xpath('/book/title/text()', doc))[1]::text FROM books;

2. PostgreSQL Filter by Author

SELECT * FROM books WHERE xpath_exists('/book/author[text()="Herbert"]', doc);

3. PostgreSQL Shred with xpath_table

SELECT * FROM xpath_table('id', 'doc', 'orders', '/order/item/name', 'true') AS t(id text, name text);

4. Oracle XMLExists Filter

SELECT count(*) FROM po WHERE XMLExists('$p/PurchaseOrder[Reference="SBELL"]' PASSING OBJECT_VALUE AS "p");

5. Oracle XMLQuery Extract

SELECT XMLCast(XMLQuery('$p/PurchaseOrder/Reference' PASSING OBJECT_VALUE AS "p" RETURNING CONTENT) AS VARCHAR2(30)) FROM po;

6. SQL Server value() Extract

SELECT doc.value('(/book/title)[1]', 'nvarchar(100)') FROM books;

7. SQL Server exist() Filter

SELECT * FROM books WHERE doc.exist('/book/title[text()="Dune"]') = 1;

8. SQL Server nodes() Shred

SELECT T.c.value('@id', 'int') FROM orders CROSS APPLY doc.nodes('/order/item') AS T(c);

9. XPath text() for Content

SELECT (xpath('//title/text()', doc))[1]::text FROM books;

10. COALESCE for Default Value

SELECT COALESCE((xpath('//subtitle/text()', doc))[1]::text, 'None') FROM books;

Visual

The SQL-XPath Bridge

┌──────────────────────────────────────────────────────────────┐
│  SQL QUERY                                                   │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ SELECT ... FROM table WHERE xpath_exists(...)           ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             │                                │
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ XPath EXPRESSION                                        ││
│  │ /book/title[text()="Dune"]                              ││
│  │                                                         ││
│  │ Evaluated by libxml2 (XPath 1.0)                        ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             │                                │
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ RESULT                                                  ││
│  │ node-set, boolean, string, or number                    ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             │                                │
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ SQL FUNCTION RETURN TYPE                                ││
│  │ xml[], boolean, text, etc.                              ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  XPath is a separate language embedded in SQL.               │
│  Its type system is not SQL's type system.                   │
│                                                              │
└──────────────────────────────────────────────────────────────┘

XPath 1.0 Type System

┌──────────────────────────────────────────────────────────────┐
│  XPATH 1.0 TYPES                                             │
│                                                              │
│  ┌─────────────────┐                                        │
│  │ Node-set        │  Unordered collection of nodes         │
│  │                 │  No duplicates                         │
│  └─────────────────┘                                        │
│                                                              │
│  ┌─────────────────┐                                        │
│  │ Boolean         │  true or false                         │
│  └─────────────────┘                                        │
│                                                              │
│  ┌─────────────────┐                                        │
│  │ Number          │  IEEE 754 double                       │
│  └─────────────────┘                                        │
│                                                              │
│  ┌─────────────────┐                                        │
│  │ String          │  Unicode string                        │
│  └─────────────────┘                                        │
│                                                              │
│  NO sequences. NO node construction.                         │
│  NO conditional expressions.                                 │
│  These are XQuery/XPath 2.0 features.                        │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Shredding Pattern

┌──────────────────────────────────────────────────────────────┐
│  XML DOCUMENT                                                │
│  <order id="1">                                              │
│    <item><name>A</name><price>10</price></item>             │
│    <item><name>B</name><price>20</price></item>             │
│  </order>                                                    │
│                                                              │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ SHREDDING FUNCTION (xpath_table, XMLTable, nodes)       ││
│  │                                                         ││
│  │ XPath: /order/item                                      ││
│  │ Returns one row per item                                ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ RELATIONAL ROWS                                         ││
│  │                                                         ││
│  │ name | price                                            ││
│  │ A    | 10                                               ││
│  │ B    | 20                                               ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  The function translates node-set members into rows.         │
│  Each row's columns are extracted by separate XPath          │
│  expressions relative to the matched node.                   │
│                                                              │
└──────────────────────────────────────────────────────────────┘

XPath 1.0 Limitation: No Conditional

┌──────────────────────────────────────────────────────────────┐
│  XPATH 2.0 / XQUERY                                          │
│  if (book/subtitle) then book/subtitle else "None"           │
│                                                              │
│  XPATH 1.0                                                   │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ No if/then/else                                         ││
│  │                                                         ││
│  │ Workaround: use SQL COALESCE around the XPath result    ││
│  │                                                         ││
│  │ SELECT COALESCE(                                        ││
│  │   (xpath('//subtitle/text()', doc))[1]::text,           ││
│  │   'None'                                                ││
│  │ ) AS subtitle                                           ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  The default is applied in SQL, not in XPath.               │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
PostgreSQL extractxpath() returns xml[]
PostgreSQL filterxpath_exists() returns boolean
PostgreSQL shredxpath_table() returns virtual table
Oracle filterXMLExists with PASSING clause
Oracle extractXMLQuery + XMLCast
SQL Server extractvalue() requires singleton
SQL Server filterexist() returns 1 or 0
SQL Server shrednodes() with CROSS APPLY
XPath version1.0 in most database implementations
XPath 1.0 typesNode-set, boolean, number, string
XPath 1.0 missingSequences, conditionals, node construction

Key takeaways:

  • XPath is a separate language, not SQL. The SQL function evaluates the XPath expression and translates the result into a SQL type. The translation is not lossless: a node-set becomes an array or a rowset, a boolean becomes a SQL boolean, a string becomes text. The function’s return type determines the translation .
  • XPath 1.0 is the practical limit. Most database implementations rely on libxml2, which supports only XPath 1.0. This means no sequences, no if/then/else, no node construction, and no string ordering comparison. Queries that require these features must use SQL to compensate .
  • The extraction function and the filtering function are different. xpath() and XMLQuery extract values for SELECT lists. xpath_exists() and XMLExists return booleans for WHERE clauses. Using the extraction function in a WHERE clause requires casting and comparing, which is less efficient and less readable.
  • Shredding is the bridge from XML to relational. xpath_table, XMLTable, and nodes() apply an XPath expression to select a set of nodes, then extract values from each node into columns. This is the mechanism for joining XML data to relational tables or aggregating it with SQL functions .
  • SQL Server’s value() requires a singleton. The XQuery expression passed to value() must return exactly one node. The [1] predicate is the standard way to enforce this. The nodes() method does not have this restriction because its purpose is to return multiple nodes .

Remember: Querying XML with SQL is a two-language operation. SQL handles the relational side: filtering rows, joining tables, aggregating results. XPath handles the document side: navigating the hierarchy, selecting nodes, testing conditions. The functions that connect the two languages are bridges, and every bridge has a load limit. XPath 1.0’s load limit is its type system: it can return a node-set or a single atomic value, but not a sequence of both. When the query requires something XPath cannot express—a default value for a missing node, a sort order, a constructed element—the work must be done in SQL. The database does not hide this boundary; it exposes it through the return types of its functions. Understanding where the boundary lies is the skill.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!