JSON and XML in Oracle
Modern applications exchange data as JSON or XML — REST APIs, configuration files, message queues, audit logs. Oracle has first-class support for both formats: store them in standard columns, query them with SQL, validate them, index them, and project them as relational rows.
This chapter focuses on JSON (the dominant modern format) with a shorter section on XML for completeness.
Part 1 — JSON in Oracle
Storing JSON
Oracle stores JSON inside standard text columns. Three column types are common:
| Type | When to use | Limit |
|---|---|---|
VARCHAR2(4000) |
Small documents | 4 KB (or 32 KB with MAX_STRING_SIZE=EXTENDED) |
CLOB |
Large documents (most apps) | Up to 4 GB |
JSON (21c+) |
Best performance + storage | Native binary format, validated on insert |
Before Oracle 21c, JSON was always stored as text in a CLOB or VARCHAR2. From 21c onwards, a dedicated JSON data type stores parsed binary representation — faster queries and smaller storage.
-- Pre-21c style:
CREATE TABLE orders (
order_id NUMBER PRIMARY KEY,
customer VARCHAR2(100),
payload CLOB CHECK (payload IS JSON)
);
-- 21c+ style:
CREATE TABLE orders (
order_id NUMBER PRIMARY KEY,
customer VARCHAR2(100),
payload JSON
);
IS JSON Constraint
The IS JSON check constraint tells Oracle to enforce well-formed JSON on every insert and update:
CREATE TABLE orders (
order_id NUMBER PRIMARY KEY,
payload CLOB CHECK (payload IS JSON)
);
-- Valid:
INSERT INTO orders VALUES (1, '{"item": "widget", "qty": 5}');
-- Rejected: ORA-02290: check constraint violated
INSERT INTO orders VALUES (2, 'not-json-at-all');
INSERT INTO orders VALUES (3, '{"missing": "quote}');
Without this constraint, Oracle won't stop you inserting garbage. With it, every row is guaranteed parseable JSON — which the optimiser also uses to enable JSON indexing and query optimisations.
IS JSON accepts options:
-- Strict mode: also enforces RFC 8259 strictness (no comments, no trailing commas)
CHECK (payload IS JSON STRICT)
-- Lax mode (default): accepts JavaScript-style with single quotes and unquoted keys
CHECK (payload IS JSON LAX)
Reading JSON — Dot Notation
The simplest way to read a JSON field is dot notation in a SELECT:
INSERT INTO orders VALUES (1,
'{ "customer": "Acme", "items": [{"sku":"A1","qty":3},{"sku":"B2","qty":5}], "total": 125.50 }');
SELECT o.payload.customer AS customer_name,
o.payload.total AS order_total
FROM orders o
WHERE o.order_id = 1;
Result:
| customer_name | order_total |
|---|---|
| Acme | 125.50 |
Dot notation requires the table to be aliased (orders o) and the column to be either typed JSON or have an IS JSON check constraint.
JSON_VALUE — Extract a Scalar
When you need a specific data type or want to query JSON without dot notation:
SELECT JSON_VALUE(payload, '$.customer') AS customer,
JSON_VALUE(payload, '$.total' RETURNING NUMBER) AS total,
JSON_VALUE(payload, '$.items[0].sku') AS first_sku
FROM orders;
Result:
| customer | total | first_sku |
|---|---|---|
| Acme | 125.50 | A1 |
$ is the root of the JSON document; dots navigate objects; [n] indexes arrays (zero-based).
-- WITH options for error handling and defaults:
SELECT JSON_VALUE(payload, '$.total'
RETURNING NUMBER
DEFAULT 0 ON ERROR
DEFAULT 0 ON EMPTY) AS total
FROM orders;
By default, missing values return NULL. Use DEFAULT ... ON ERROR and DEFAULT ... ON EMPTY to provide fallbacks.
JSON_QUERY — Extract an Object or Array
JSON_VALUE only returns scalars. To return an object or array, use JSON_QUERY:
SELECT JSON_QUERY(payload, '$.items') AS items_array,
JSON_QUERY(payload, '$.items[0]') AS first_item_obj,
JSON_QUERY(payload, '$.items[*].sku' WITH WRAPPER) AS all_skus
FROM orders;
Result:
| items_array | first_item_obj | all_skus |
|---|---|---|
[{"sku":"A1","qty":3},{"sku":"B2","qty":5}] |
{"sku":"A1","qty":3} |
["A1","B2"] |
WITH WRAPPER wraps results in [...] when there are multiple matches. $.items[*] means "every element of the items array".
JSON_EXISTS — Test If a Path Exists
-- Orders that have at least one item with sku 'A1'
SELECT order_id
FROM orders
WHERE JSON_EXISTS(payload, '$.items[*]?(@.sku == "A1")');
The ?( ... ) is a filter expression — @ refers to the current item in the iteration. This is the SQL/JSON equivalent of asking "does any element match this predicate?"
-- Total >= 100
WHERE JSON_EXISTS(payload, '$.total?(@ >= 100)')
-- Has both 'priority' field AND priority = 'high'
WHERE JSON_EXISTS(payload, '$.priority?(@ == "high")')
JSON_TABLE — JSON → Relational Rows
JSON_TABLE is the most powerful JSON tool: it turns a JSON document or array into a virtual table of rows you can join, filter, and aggregate like any other table.
SELECT o.order_id, t.sku, t.qty
FROM orders o,
JSON_TABLE(o.payload, '$.items[*]'
COLUMNS (
sku VARCHAR2(20) PATH '$.sku',
qty NUMBER PATH '$.qty'
)
) t;
Result:
| order_id | sku | qty |
|---|---|---|
| 1 | A1 | 3 |
| 1 | B2 | 5 |
This unpacks the nested items array into one row per item, with each item's fields projected into columns. The order is now joinable with other tables, summable, filterable — all the things you couldn't do on the raw JSON.
-- Total quantity per order, computed from inside the JSON:
SELECT o.order_id, SUM(t.qty) AS total_qty
FROM orders o,
JSON_TABLE(o.payload, '$.items[*]'
COLUMNS (qty NUMBER PATH '$.qty')) t
GROUP BY o.order_id;
JSON_TABLE accepts nested paths and even nested JSON_TABLE for deeply structured data.
Generating JSON — JSON_OBJECT and JSON_ARRAY
Construct JSON on the fly inside SELECTs:
SELECT JSON_OBJECT(
'employeeId' VALUE employee_id,
'fullName' VALUE first_name || ' ' || last_name,
'email' VALUE email,
'salary' VALUE salary
) AS json_emp
FROM employees
WHERE employee_id = 100;
Result:
{"employeeId":100,"fullName":"Steven King","email":"SKING","salary":24000}
Shorthand KEY 'name' VALUE col and 'name' VALUE col are interchangeable. Oracle 18c+ also supports JavaScript-style key:value: JSON_OBJECT(employee_id, salary).
For arrays of values:
SELECT JSON_ARRAY(first_name, last_name, salary) FROM employees;
-- ["Steven","King",24000]
Aggregate into JSON arrays of objects with JSON_ARRAYAGG:
SELECT department_id,
JSON_ARRAYAGG(
JSON_OBJECT('id' VALUE employee_id, 'name' VALUE first_name)
) AS employees_json
FROM employees
GROUP BY department_id;
Result:
| department_id | employees_json |
|---|---|
| 10 | [{"id":200,"name":"Jennifer"}] |
| 20 | [{"id":201,"name":"Michael"},{"id":202,"name":"Pat"}] |
This is invaluable for building API responses directly from SQL.
Indexing JSON
Three options for speeding up JSON queries:
1. Function-based index on a specific JSON path — fastest for queries on one known field:
CREATE INDEX orders_customer_idx ON orders (JSON_VALUE(payload, '$.customer'));
-- Now this query uses the index:
WHERE JSON_VALUE(payload, '$.customer') = 'Acme';
2. JSON Search Index — Oracle Text-style, indexes the entire document:
CREATE SEARCH INDEX orders_search_idx ON orders (payload) FOR JSON;
Speeds up JSON_EXISTS and full-document searches but consumes more disk space.
3. Materialised view — extract frequently-queried JSON fields into actual columns:
CREATE MATERIALIZED VIEW orders_mv
REFRESH FAST ON COMMIT
AS SELECT order_id,
JSON_VALUE(payload, '$.customer') AS customer,
JSON_VALUE(payload, '$.total' RETURNING NUMBER) AS total
FROM orders;
Trade storage for query speed when the same JSON paths are read repeatedly.
JSON Performance Notes
- The
JSONtype (21c+) is 2-5× faster than CLOB+IS JSON for most operations because parsing happens once at insert time. - For frequently accessed scalar fields, function-based indexes are essential — without them, every read re-parses the JSON.
- Avoid
JSON_VALUEin JOIN conditions — Oracle can't always use indexes through them. Pre-extract into a column or view first. JSON_TABLEover a large CLOB can be slow; consider denormalising hot fields into real columns.
Part 2 — XML in Oracle
Oracle's XML support is mature (decades old, pre-dating JSON). It's still common in industries where XML is the standard exchange format — finance (FIX, SWIFT), healthcare (HL7), government (XBRL).
Storing XML — XMLType
The dedicated XMLType column type stores XML data in a parsed, queryable form:
CREATE TABLE orders_xml (
order_id NUMBER PRIMARY KEY,
doc XMLType
);
INSERT INTO orders_xml VALUES (1,
XMLType('<order>
<customer>Acme</customer>
<items>
<item sku="A1" qty="3"/>
<item sku="B2" qty="5"/>
</items>
<total>125.50</total>
</order>'));
Querying XML — XMLQUERY
XMLQUERY uses XPath/XQuery to extract values:
SELECT XMLQUERY('/order/customer/text()'
PASSING doc
RETURNING CONTENT) AS customer
FROM orders_xml;
Result: Acme
XPath syntax:
/order/customer— child elements//item— any descendantitemelement/order/items/item[@sku="A1"]— predicates on attributestext()— extract text content
Extracting Scalar Values — EXTRACTVALUE
For simple single-value extraction, EXTRACTVALUE is slightly less verbose than XMLQUERY:
SELECT EXTRACTVALUE(doc, '/order/total') AS total,
EXTRACTVALUE(doc, '/order/customer') AS customer
FROM orders_xml;
XML → Relational Rows — XMLTABLE
The XML equivalent of JSON_TABLE:
SELECT o.order_id, t.sku, t.qty
FROM orders_xml o,
XMLTABLE('/order/items/item'
PASSING o.doc
COLUMNS sku VARCHAR2(20) PATH '@sku',
qty NUMBER PATH '@qty'
) t;
Result:
| order_id | sku | qty |
|---|---|---|
| 1 | A1 | 3 |
| 1 | B2 | 5 |
@sku reads the XML attribute sku. Element values use the element name as the path.
Generating XML — XMLELEMENT and XMLAGG
SELECT XMLELEMENT("employee",
XMLELEMENT("id", employee_id),
XMLELEMENT("name", first_name || ' ' || last_name),
XMLELEMENT("email", email)
) AS emp_xml
FROM employees
WHERE employee_id = 100;
Result: <employee><id>100</id><name>Steven King</name><email>SKING</email></employee>
Combine with XMLAGG for groups:
SELECT department_id,
XMLELEMENT("department",
XMLATTRIBUTES(department_id AS "id"),
XMLAGG(XMLELEMENT("employee", first_name))
) AS dept_xml
FROM employees
GROUP BY department_id;
JSON vs XML — Which to Use?
| Aspect | JSON | XML |
|---|---|---|
| Verbosity | Compact | Verbose (tags both opening and closing) |
| Schema | Optional (JSON Schema, less mature) | Mature (XSD, DTD) |
| Querying | Simpler (JSON path) | More powerful (XPath, XQuery) |
| Modern ecosystem | Dominant for REST APIs | Common in enterprise integrations |
| Oracle support | First-class, native type in 21c+ | First-class for decades |
| Comments | Not allowed | Allowed |
| Namespaces | None | Yes |
Default to JSON for new systems unless an existing integration mandates XML. The Oracle tooling is comparable; the developer ecosystem is heavily JSON-favoured.
Common Errors
| Error | Cause | Fix |
|---|---|---|
| ORA-02290: check constraint violated (IS JSON) | Insert is not valid JSON | Validate the payload before insert; check for unescaped quotes |
| ORA-40441: JSON syntax error | Same as above but thrown by JSON_VALUE/JSON_QUERY | Verify payload with SELECT payload FROM table WHERE rowid=...; and a JSON linter |
| ORA-40442: JSON path expression syntax error | Malformed path like $.items[0]bad |
Check JSON path syntax; $ is the root, . for object keys, [n] for array indices |
| ORA-40443: column reference must include qualifier | Dot notation without table alias | Alias the table: FROM orders o ... o.payload.field |
| ORA-30625: method dispatch on NULL SELF argument | XML or JSON column is NULL but you tried .method() |
Add WHERE col IS NOT NULL |
| JSON_VALUE returns NULL when value exists | Path doesn't match (case mismatch, typo) | Verify with JSON_QUERY(payload, '$') to see the full structure |
| ORA-19279: XPTY0004 - XQuery dynamic type mismatch | XMLQUERY can't return multi-element result as a single value | Use XMLTABLE for multiple matches or add a predicate |
Interview Corner
▶ Show answer
Both are valid; the choice depends on access patterns and stability of the schema.
Store as JSON when:
- The structure varies row-to-row (think custom fields, sparse attributes)
- The structure evolves frequently (no DDL needed each time)
- The data is consumed mostly as a whole document (e.g., API responses)
- The volume of distinct fields is large but each row uses few
Use relational columns when:
- You frequently query individual fields
- You need foreign keys, joins, or constraints on those fields
- The schema is stable
- You need maximum performance — relational columns are always faster than JSON for the same query
The Goldilocks approach: hybrid. Promote frequently-queried fields to real columns, leave the rest as JSON:
CREATE TABLE orders (
order_id NUMBER PRIMARY KEY,
customer VARCHAR2(100), -- promoted: searched often
status VARCHAR2(20), -- promoted: indexed
total NUMBER, -- promoted: aggregated
payload JSON -- rest of the document
);
This pattern gives you SQL ergonomics and indexing for the hot fields while keeping the flexibility of JSON for the rest.
▶ Show answer
All three extract data from JSON, but they return different shapes:
| Function | Returns | Use when |
|---|---|---|
JSON_VALUE |
A single scalar value | Reading one specific scalar field: name, total, ID |
JSON_QUERY |
A JSON object or array | Reading nested structures: the whole items array, a sub-object |
JSON_TABLE |
A virtual table of rows | Unpacking arrays into rows you can join, filter, group |
Examples on {"customer": "Acme", "items": [{"sku":"A1","qty":3}], "total": 125.50}:
JSON_VALUE(payload, '$.customer') -- 'Acme' (scalar)
JSON_VALUE(payload, '$.total') -- 125.50 (scalar)
JSON_QUERY(payload, '$.items') -- '[{"sku":"A1","qty":3}]' (array)
JSON_QUERY(payload, '$.items[0]') -- '{"sku":"A1","qty":3}' (object)
JSON_TABLE(payload, '$.items[*]' COLUMNS (sku VARCHAR2(20), qty NUMBER))
-- Returns table: sku='A1', qty=3
Rule of thumb:
- "I want one value" → JSON_VALUE
- "I want a substructure as raw JSON" → JSON_QUERY
- "I want to query nested arrays like a table" → JSON_TABLE
▶ Show answer
Three escalating options:
1. Function-based index on the specific path being queried — works for predictable, repeated queries:
CREATE INDEX o_status_idx ON orders (JSON_VALUE(payload, '$.status'));
-- Now this query uses the index:
WHERE JSON_VALUE(payload, '$.status') = 'OPEN';
The expression in the WHERE clause must match the index expression exactly (same JSON path, same RETURNING type, no extra functions).
2. JSON Search Index — covers arbitrary JSON paths, slower than function-based but flexible:
CREATE SEARCH INDEX o_search_idx ON orders (payload) FOR JSON;
This accelerates JSON_EXISTS queries and ad-hoc field searches without needing to pre-declare every path.
3. Promote the field to a real column if it's queried very often:
ALTER TABLE orders ADD (status VARCHAR2(20) GENERATED ALWAYS AS
(JSON_VALUE(payload, '$.status')) VIRTUAL);
CREATE INDEX o_status_idx ON orders (status);
A VIRTUAL column doesn't store data but lets you index its computed value cleanly.
Avoid: filtering on JSON inside joins, computing JSON path values in correlated subqueries, or accidentally re-parsing the same JSON dozens of times in one query. Pre-extract once into a subquery or CTE.
Related Topics
- DDL Commands — defining tables with JSON and XMLType columns
- DML Commands — inserting and updating JSON documents
- Views — materialised views to denormalise hot JSON fields
- Indexes — function-based and JSON Search indexes
- Performance — when to extract JSON fields vs storing as relational columns