Skip to content
Reference / SQL JSON Functions
Reference

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.

Fields out of a payment payload
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:

An order's items as rows
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

Each category's products as one JSON array
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 as payload->('status' = 'paid') and fails with No function matches ... 'json_extract(VARCHAR, BOOLEAN)'. ->> doesn't have this problem.
  • JSON doesn't compare to text. Even in parentheses, (payload->'status') = 'paid' fails with Malformed 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'::integer casts the key, and fails with Could 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' is 1; other engines may keep the last.
The single arrow next to =
with payments(payload) as (
  values ('{"order_id": 1, "status": "paid"}')
)
select *
from payments
where payload->'status' = 'paid'
A cast on the key
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
Dialect note

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.