SQL (PostgreSQL, MySQL, SQLite) Module 15 – JSON in SQL
Turning JSON into rows and rows into JSON
Expand JSON arrays into rows with json_each, jsonb_array_elements and JSON_TABLE, and build nested JSON from query results in three engines.
What you will learn
- Convert JSON arrays and objects into rows with json_each, jsonb_array_elements and JSON_TABLE
- Build JSON objects and arrays from query results in PostgreSQL, MySQL and SQLite
- Create one nested, API-ready document per row with a subquery for each list
- Avoid the lost rows, null items and quoted arrays that joins and aggregates can produce
Before you start
What you’ll learn · Before you start
What you will learn
- Convert JSON arrays and objects into rows with json_each, jsonb_array_elements and JSON_TABLE
- Build JSON objects and arrays from query results in PostgreSQL, MySQL and SQLite
- Create one nested, API-ready document per row with a subquery for each list
- Avoid the lost rows, null items and quoted arrays that joins and aggregates can produce
Before you start
On this page
Turning JSON into rows means giving each element of an array, or each key of an object, a row of its own, so that
WHERE, JOIN and GROUP BY can work on them like on any table. SQLite does it with the table-valued function
json_each(), PostgreSQL with jsonb_array_elements() and its relatives, and PostgreSQL 17 or later and MySQL 8 with
the SQL standard’s JSON_TABLE, which also names and types the new columns. Turning rows into JSON is the reverse:
json_object() builds one object from the values of a row, and aggregate functions such as json_group_array() in
SQLite, json_agg() in PostgreSQL and JSON_ARRAYAGG() in MySQL collect many rows into one array. That is how one
query returns an order with its items nested inside, ready for an app or an API. This lesson turns JSON into rows and
rows into JSON on an order and its items, in all three engines.
Every example on this page runs in the browser with its Run button. It runs on SQLite 3.49.1, the version that sql.js 1.14.2 bundles; for PostgreSQL and MySQL, the functions named here follow their manuals.
JSON into rows, SQL on the rows, rows back into JSON
Text description of the diagram
The diagram is a chain of four boxes, read from the top.
- Order 101 as it is stored: one row whose tags column holds the JSON array ["gift", "express"].
- An arrow labelled json_each(), jsonb_array_elements() and JSON_TABLE leads to one row per tag: 101 with gift, and 101 with express.
- An arrow labelled WHERE, JOIN and GROUP BY leads to a query result over all the orders, such as express on 3 orders and gift on 1.
- An arrow labelled json_group_object(), json_object_agg() and JSON_OBJECTAGG() leads to one JSON object built from those rows: {"express": 3, "gift": 1}.
The first arrow turns JSON into rows, so that ordinary SQL can filter, join and count the elements; the last arrow turns rows back into JSON.
JSON into rows with json_each()
-- Orders with their tags as a JSON array: json_each() gives each tag a row of its own.
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, tags TEXT);
INSERT INTO orders VALUES
(101, 'Asha', '["gift", "express"]'),
(102, 'Ravi', '[]'),
(103, 'Meera', '["express", "fragile"]'),
(104, 'Tenzin', '["express"]');
SELECT orders.id, tag.key AS position, tag.value AS tag, tag.type
FROM orders, json_each(orders.tags) AS tag;
-- Once the tags are rows, GROUP BY can count them.
SELECT tag.value AS tag, count(*) AS orders
FROM orders, json_each(orders.tags) AS tag
GROUP BY tag.value
ORDER BY orders DESC, tag; Output
┌─────┬──────────┬─────────┬──────┐ │ id │ position │ tag │ type │ ├─────┼──────────┼─────────┼──────┤ │ 101 │ 0 │ gift │ text │ │ 101 │ 1 │ express │ text │ │ 103 │ 0 │ express │ text │ │ 103 │ 1 │ fragile │ text │ │ 104 │ 0 │ express │ text │ └─────┴──────────┴─────────┴──────┘ ┌─────────┬────────┐ │ tag │ orders │ ├─────────┼────────┤ │ express │ 3 │ │ fragile │ 1 │ │ gift │ 1 │ └─────────┴────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < tags_to_rows.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
json_each(orders.tags) sits in the FROM clause like a table, and it can read a column of the row beside it. For an
array it returns one row per element: key is the position, counted from 0, value the element and type its JSON
type. For an object, key holds each member’s name. Turning JSON into rows this way lets plain SQL count the tags:
express appears on three orders. Order 102, whose list is empty, has no row at all, because the comma joins each order
only to the rows the function returns. json_tree() works the same way but walks every level of a document, nested
objects and arrays included, and its fullkey column gives each value’s full path.
The same job in PostgreSQL and MySQL
In SQLite, json_each() covers all of these jobs, with key for the position and value ->> 'name' for a column.
The other two engines split them up:
- One row per array element:
jsonb_array_elements(tags)in PostgreSQL;JSON_TABLE(tags, '$[*]' COLUMNS (...))in MySQL. - Elements as plain text:
jsonb_array_elements_text(tags)in PostgreSQL; aPATH '$'column with a type in MySQL. - One row per key of an object:
jsonb_each(doc)in PostgreSQL;JSON_TABLEoverJSON_KEYS(doc)in MySQL. - The position of each element:
WITH ORDINALITYin PostgreSQL and aFOR ORDINALITYcolumn in MySQL, both counted from 1. - Named, typed columns:
JSON_TABLE, in PostgreSQL 17 and later and in MySQL; PostgreSQL also hasjsonb_to_recordset().
JSON_TABLE is the standard’s answer: one call names the array to expand with a path such as '$.items[*]', then
lists the columns, each with a type and a path inside the element, as in
COLUMNS (sku text PATH '$.sku', qty int PATH '$.qty'). In PostgreSQL, a set-returning function such as
jsonb_array_elements() joins to its table with a comma or CROSS JOIN LATERAL, which reads the row on its left.
Rows into JSON: one object per row, one array per order
-- Rows into JSON: one object per row, then one document per order with its items nested inside.
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, paid INTEGER);
CREATE TABLE items (order_id INTEGER, sku TEXT, qty INTEGER, price_paise INTEGER);
INSERT INTO orders VALUES (101, 'Asha', 1), (102, 'Ravi', 0);
INSERT INTO items VALUES (101, 'RICE-5KG', 1, 42000), (101, 'CHAI-250G', 3, 15000);
-- json_object() takes names and values in pairs.
SELECT json_object('sku', sku, 'qty', qty, 'paise', qty * price_paise) AS item
FROM items;
-- A subquery collects each order's items into an array, sorted by SKU.
SELECT json_object(
'id', o.id,
'customer', o.customer,
'paid', json(CASE WHEN o.paid THEN 'true' ELSE 'false' END),
'items', (SELECT json_group_array(json_object('sku', i.sku, 'qty', i.qty) ORDER BY i.sku)
FROM items AS i
WHERE i.order_id = o.id)
) AS doc
FROM orders AS o; Output
┌───────────────────────────────────────────┐
│ item │
├───────────────────────────────────────────┤
│ {"sku":"RICE-5KG","qty":1,"paise":42000} │
│ {"sku":"CHAI-250G","qty":3,"paise":45000} │
└───────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ doc │
├───────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ {"id":101,"customer":"Asha","paid":true,"items":[{"sku":"CHAI-250G","qty":3},{"sku":"RICE-5KG","qty":1}]} │
│ {"id":102,"customer":"Ravi","paid":false,"items":[]} │
└───────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < order_document.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
json_object() takes names and values in pairs and writes them in that order. Each SQL value becomes the matching JSON
value: text a string, numbers numbers, NULL null. SQLite has no boolean type, so paid would come out as 1 or 0;
json('true') writes JSON’s true instead. The subquery runs once per order and collects that order’s items with
json_group_array(), and ORDER BY i.sku inside the aggregate fixes the order of the array. Order 102 has no items,
and the aggregate over no rows gives [], so the document still has its items key. Turning rows into JSON in the
other engines uses these functions:
- PostgreSQL 18:
json_build_object()andjson_build_array()build an object and an array, as do the standard’sJSON_OBJECT()andJSON_ARRAY()from version 16. Over rows,json_agg()(orjsonb_agg()andJSON_ARRAYAGG()) builds an array andjson_object_agg()(orJSON_OBJECTAGG()) an object, andORDER BYmay follow the aggregate’s argument. - MySQL 8.4:
JSON_OBJECT()andJSON_ARRAY(), and over rowsJSON_ARRAYAGG()andJSON_OBJECTAGG(), which take noORDER BY. - SQLite 3.49:
json_object()andjson_array(), and over rowsjson_group_array()andjson_group_object(), which acceptORDER BYfrom version 3.44.
Where these queries go wrong
Every one of these runs without an error and returns the wrong JSON or the wrong rows:
- Vanishing rows: an inner join to an empty or missing array drops the whole row.
- Null items: a
LEFT JOINfollowed by an aggregate turns “no items” into one item made of nulls. - Quoted arrays: JSON text that loses its JSON marking is written into a document as a string.
- Unstable order: an aggregate without
ORDER BYfollows the plan, and an index can change it. - Repeated keys: an object built from rows can name the same key twice.
- Bad values in typed columns:
JSON_TABLEturns a value it cannot convert into NULL unless told otherwise.
Your turn
Exercise · Medium · SQL
Build one JSON document per customer with their orders
The table customers has the columns id (INTEGER) and name (TEXT), and the table orders has the columns id (INTEGER), customer_id (INTEGER) and total_paise (INTEGER).
Write a query that returns one row per customer with two columns: customer_id and doc, a JSON object with exactly these keys, in this order:
name: the customer's name;orders: an array with one object per order of that customer, each with the keysidandtotal_paisein that order, sorted byidfrom the smallest.
A customer without orders gets an empty array: {"name":"Tenzin Dorjee","orders":[]}. SQLite's json_object() writes the keys in the order you give them, without spaces, and the tests compare that text exactly. The order of the rows does not matter.
Starter code · customer_documents.sql
-- One row per customer: customer_id and doc, a JSON object with the keys name and orders.
SELECT c.id AS customer_id,
json_object('name', c.name) AS doc
FROM customers AS c; The sample tests · tests.yaml
# Sample tests: each one builds small customers and orders tables, runs your query on them and compares the rows it
# returns (in any order). The JSON documents are compared as text.
tests:
- name: nests each customer's orders, sorted by id
setup: |
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, total_paise INTEGER);
INSERT INTO customers VALUES (1, 'Asha Kulkarni'), (2, 'Ravi Menon');
INSERT INTO orders VALUES (105, 1, 21500), (103, 1, 30800), (104, 2, 9900);
expected:
columns: [customer_id, doc]
rows:
- [1, '{"name":"Asha Kulkarni","orders":[{"id":103,"total_paise":30800},{"id":105,"total_paise":21500}]}']
- [2, '{"name":"Ravi Menon","orders":[{"id":104,"total_paise":9900}]}']
- name: gives a customer without orders an empty array
setup: |
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, total_paise INTEGER);
INSERT INTO customers VALUES (1, 'Asha Kulkarni'), (5, 'Tenzin Dorjee');
INSERT INTO orders VALUES (103, 1, 30800);
expected:
columns: [customer_id, doc]
rows:
- [1, '{"name":"Asha Kulkarni","orders":[{"id":103,"total_paise":30800}]}']
- [5, '{"name":"Tenzin Dorjee","orders":[]}']
- name: keeps names in any script and quotes inside names
setup: |
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, total_paise INTEGER);
INSERT INTO customers VALUES (7, 'मीरा "Meera" Iyer');
INSERT INTO orders VALUES (110, 7, 120000), (108, 7, 0);
expected:
columns: [customer_id, doc]
rows:
- [7, '{"name":"मीरा \"Meera\" Iyer","orders":[{"id":108,"total_paise":0},{"id":110,"total_paise":120000}]}'] A hint
Put the orders array in a subquery inside json_object(): one that reads only this customer's orders and collects them with json_group_array(), ordered by the order's id. Over no rows, json_group_array() returns [], so customers without orders need nothing extra.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
JSON into rows without losing any, engine by engine Pro: opens with a Pro, Premium or Ultimate pass
Expanding arrays is where rows go missing without a trace. This part reproduces the vanishing rows and their fix,
takes JSON_TABLE through nested arrays, positions and bad values in PostgreSQL and MySQL, and shows a MySQL 8.4 query
form that returns no rows when it should return one.
In this part:
- Orders that vanish in a join, reproduced
- JSON_TABLE: nested arrays, positions and existence
- Bad values: NULL, a default or an error
- Choosing between json_each(), the jsonb functions and JSON_TABLE
- A filter through JSON_TABLE that returns nothing in MySQL 8.4
1 runnable example (SQL) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Building JSON that survives: empty lists, nesting, order and keys Pro: opens with a Pro, Premium or Ultimate pass
Building JSON fails quietly in four ways: lists of nulls, arrays turned into strings, arrays whose order follows the plan, and objects with a key given twice. This part reproduces each one in SQLite and states what PostgreSQL and MySQL do with the same query.
In this part:
- Empty lists that come out as a list of nulls, reproduced
- Nested JSON that turns into a string, reproduced
- Arrays whose order follows the plan, reproduced
- One query per page instead of one per order, with numbers
- Keys given twice and keys that hold NULL
3 runnable examples (SQL) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
What do json_each() in SQLite and JSON_TABLE in PostgreSQL and MySQL do, and how do they differ?
Both turn JSON into rows, so that WHERE, JOIN and GROUP BY can work on the elements of an array.
json_each()is a table-valued function: for an array it returns one row per element, with the element invalue, its position from 0 inkeyand its JSON type intype; for an object, one row per member, with the member's name inkey.JSON_TABLE, from the SQL standard, takes a path to the rows, such as'$.items[*]', and a column list in which every column has a name, an SQL type and a path inside the element. It can number rows, follow nested arrays and say what a missing or unconvertible value becomes. PostgreSQL has it from version 17 and MySQL 8 has it too; SQLite hasjson_each()andjson_tree()instead. -
Mid to senior
An endpoint builds each order's JSON with json_agg(json_build_object('sku', i.sku, 'qty', i.qty)) over orders LEFT JOIN order_items, grouped by order. Orders without items come out as "items": [{"sku": null, "qty": null}], and the app shows them with one blank item. Why, and how do you fix it?
-
Fresher to mid
A daily report expands each order's tags JSON array and counts orders per tag. The finance team notices that the report covers fewer orders than the orders table holds, although many orders carry two tags. Which orders are missing, and how do you write the report so that every order is counted?
-
Senior to staff
An API returns each order's items as a JSON array built with JSON_ARRAYAGG in MySQL 8.4. After an index on order_items (order_id, sku) is added, receipts list the items in a different order and a contract test fails. Nothing else changed. Explain what happened and make the order stable.
Key takeaways
- Turning JSON into rows uses a function in
FROM:json_each()in SQLite,jsonb_array_elements()orJSON_TABLEin PostgreSQL, andJSON_TABLEin MySQL. - An inner join to an expanded array drops the rows whose array is empty or missing.
json_object()and an aggregate such asjson_group_array()turn rows into JSON; a subquery per list gives each parent its own array.- State the order of an array with
ORDER BYinside the aggregate where the engine allows it. - Plain text is written as a JSON string; wrap text that holds JSON in
json()to embed it.
References
- PostgreSQL 18: JSON Functions and Operators (processing, creation and JSON_TABLE) (The PostgreSQL Global Development Group)
- PostgreSQL 18: Aggregate Functions (The PostgreSQL Global Development Group)
- PostgreSQL 17 Release Notes (The PostgreSQL Global Development Group)
- PostgreSQL 16 Release Notes (The PostgreSQL Global Development Group)
- MySQL 8.4 Reference Manual: JSON Table Functions (Oracle Corporation)
- MySQL 8.4 Reference Manual: Functions That Create JSON Values (Oracle Corporation)
- MySQL 8.4 Reference Manual: Aggregate Function Descriptions (Oracle Corporation)
- MySQL 8.4 Reference Manual: Optimizing Subqueries with Semijoin Transformations (Oracle Corporation)
- JSON Functions And Operators (SQLite)
- SQLite: Aggregate Functions (ORDER BY and FILTER in aggregates) (SQLite)
- SQLite: Table-Valued Functions (SQLite)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress