SQL JSON Functions
JSON arrives as text. The -> operator reads a field as JSON, ->> reads it as text, a path reaches nested fields, and from_json turns a document into typed structs and lists.
JSON usually reaches a database as text from somewhere else: an API, an
event log, an export. DuckDB reads fields straight out of that text with two
operators, -> and ->>, and converts whole
documents into its own lists and
structs when you need them.
with payments(payload) as (
values ('{"order_id": 1, "status": "paid", "amount": "54.00", "card": {"brand": "visa"}}'),
('{"order_id": 3, "status": "paid", "amount": "45.50", "wallet": "paypal"}')
)
select (payload->>'order_id')::integer as order_id,
payload->>'status' as status,
(payload->>'amount')::decimal(10, 2) as amount,
payload->>'$.card.brand' as card_brand
from payments
The PayPal payment has no card, so its card_brand is
NULL. A missing key is never an error.
Operators and paths
| Form | Gives |
|---|---|
j->'status' |
The field as JSON. A string keeps its quotes: "paid" |
j->>'status' | The field as text: paid |
j->'card'->>'brand' | A nested field, one step at a time |
j->>'$.card.brand' | The same field by path. $ is the whole document |
j->'items'->0, '$.items[0]' | The first array element: JSON arrays count from 0 |
'$.items[#-1]' | The last array element |
json_extract(j, path) and
json_extract_string(j, path) are the function forms of
-> and ->>. Keys are case-sensitive:
'A' doesn't find "a".
Functions
| Function | Gives |
|---|---|
json_array_length(j, path) | The number of elements in an array |
json_keys(j) | An object's keys, as a list |
json_type(j, path) | OBJECT, ARRAY, VARCHAR,
UBIGINT, and so on
|
json_exists(j, path) | True if the path is present |
json_valid(s) | True if the text parses as JSON |
json_structure(j) | The document's shape, with a type in place of each value |
from_json(j, shape) | A typed struct or list, built from the shape you describe |
json_each(j) | A table function: one row per key or element |
to_json(v), json_object(k, v, ...),
json_array(v, ...) | JSON built from values |
json_group_array(v),
json_group_object(k, v) | Aggregates: a group's values as one JSON array or object |
Arrays into rows
from_json converts a JSON array of objects into a list of
structs, using a shape written as JSON with a type for each field.
unnest with recursive := true then makes a row per
element and a column per field:
with orders_in(payload) as (
values ('{"order_id": 9, "items": [{"sku": 3, "qty": 2}, {"sku": 10, "qty": 1}]}')
)
select payload->>'order_id' as order_id,
unnest(from_json(payload->'items', '[{"sku": "INTEGER", "qty": "INTEGER"}]'),
recursive := true)
from orders_in json_structure writes a starting shape for you: run it on one
document, then change the types you want. A value that doesn't fit its type
comes back as NULL without an error, so
from_json('{"a": "x"}', '{"a": "INTEGER"}') is
{'a': NULL}. Count the NULLs after converting.
Building JSON
select c.category_name,
to_json(list({'id': p.product_id, 'name': p.product_name}
order by p.product_id)) as products
from products as p
join categories as c on c.category_id = p.category_id
where c.category_id in (3, 6)
group by c.category_name
order by c.category_name What can go wrong
- A single arrow next to
=fails. DuckDB uses->for lambdas too, and gives it a lower precedence than=.payload->'status' = 'paid'is read aspayload->('status' = 'paid')and fails withNo function matches ... 'json_extract(VARCHAR, BOOLEAN)'.->>doesn't have this problem. - JSON doesn't compare to text. Even in parentheses,
(payload->'status') = 'paid'fails withMalformed JSON, because DuckDB converts'paid'to JSON, and a bare word isn't JSON. Compare text with text: use->>. -
::binds before the arrow.payload->>'order_id'::integercasts the key, and fails withCould not convert string 'order_id' to INT32. Put the extraction in parentheses, then cast. -
->>returns text, even for numbers.payload->>'amount' > '100'compares letter by letter, so'54.00'counts as greater. Cast before comparing, as with any number stored as text. - Duplicate keys keep the first.
'{"a": 1, "a": 2}'->>'a'is1; other engines may keep the last.
with payments(payload) as (
values ('{"order_id": 1, "status": "paid"}')
)
select *
from payments
where payload->'status' = 'paid' select '{"order_id": 1}'->>'order_id'::integer as order_id How other databases differ
| Engine | Field as text | Notes |
|---|---|---|
| DuckDB | j->>'key', j->>'$.a.b' | Takes a key or a path. Text columns work without a cast |
| PostgreSQL | j->>'key', j #>> '{a,b}' |
On json or jsonb columns; the arrows bind
tighter than = |
| MySQL 8 | j->>'$.key' |
Always a path; JSON_UNQUOTE(JSON_EXTRACT(...)) in full
|
| SQL Server | JSON_VALUE(j, '$.key') | JSON_QUERY for objects and arrays;
OPENJSON for rows
|
| SQLite | j->>'$.key', json_extract | json_each for rows |
| Snowflake | j:key::string |
On a VARIANT column, after PARSE_JSON;
LATERAL FLATTEN for rows
|
| BigQuery | JSON_VALUE(j, '$.key') | JSON_QUERY for objects; JSON_EXTRACT_ARRAY
for arrays
|
DuckDB 1.5 also has a VARIANT type, the closest thing to
Snowflake's. The browser build this site runs can't display one (it fails
with Unsupported Arrow type VARIANT), so cast a
VARIANT to text or JSON before selecting it here.
Related
Keep each customer's rental history as a list of structs.
Read status, card brand, and amount out of payment JSON.
Named fields in one value, and key-value maps.
list(), indexing, lambdas, and unnest back into rows.