Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 30 minutes
  • Examples run with sql.js 1.14.2
  • By MySmartCoPilot
Free core, Pro depth: 10 of 30 minutes free
Free: JSON into rows with jsoneach() · The same job in PostgreSQL and MySQL · Rows into JSON: one object per row, one array per order · 2 more sections. With a pass: 2 in-depth parts and 3 interview answers, about 20 minutes.

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.

A tags array becomes one row per tag, SQL counts the rows, and the counts are built back into a JSON object.Order 101, as storedtags: ["gift", "express"]One row per tag101 · gift101 · expressA query resultexpress · 3 ordersgift · 1 orderJSON built from the rows{"express": 3, "gift": 1}json_each()jsonb_array_elements()JSON_TABLEWHERE, JOINand GROUP BYjson_group_object()json_object_agg()JSON_OBJECTAGG()

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.

  1. Order 101 as it is stored: one row whose tags column holds the JSON array ["gift", "express"].
  2. An arrow labelled json_each(), jsonb_array_elements() and JSON_TABLE leads to one row per tag: 101 with gift, and 101 with express.
  3. 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.
  4. 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()

Each order's tags, one row per tag, then counted SQL · tags_to_rows.sql
-- 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

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; a PATH '$' column with a type in MySQL.
  • One row per key of an object: jsonb_each(doc) in PostgreSQL; JSON_TABLE over JSON_KEYS(doc) in MySQL.
  • The position of each element: WITH ORDINALITY in PostgreSQL and a FOR ORDINALITY column in MySQL, both counted from 1.
  • Named, typed columns: JSON_TABLE, in PostgreSQL 17 and later and in MySQL; PostgreSQL also has jsonb_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

Items as objects, then each order as one document SQL · order_document.sql
-- 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

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() and json_build_array() build an object and an array, as do the standard’s JSON_OBJECT() and JSON_ARRAY() from version 16. Over rows, json_agg() (or jsonb_agg() and JSON_ARRAYAGG()) builds an array and json_object_agg() (or JSON_OBJECTAGG()) an object, and ORDER BY may follow the aggregate’s argument.
  • MySQL 8.4: JSON_OBJECT() and JSON_ARRAY(), and over rows JSON_ARRAYAGG() and JSON_OBJECTAGG(), which take no ORDER BY.
  • SQLite 3.49: json_object() and json_array(), and over rows json_group_array() and json_group_object(), which accept ORDER BY from 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 JOIN followed 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 BY follows 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_TABLE turns a value it cannot convert into NULL unless told otherwise.
CSV to JSON Converter Turn a JSON array of objects into CSV rows and back, and compare the result with what json_each() or JSON_TABLE returns.

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 keys id and total_paise in that order, sorted by id from 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.

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.

  1. Question 1 of 4 How many rows does this query return in SQLite 3.49?

    Read the code, then choose one answer.

    SELECT key, value FROM json_each('["gift", "express", "fragile"]');
    Show the answer to question 1

    Answer: Three, one per element, with key 0, 1 and 2

    json_each() is a table-valued function: it returns one row per element of an array, and key holds the element's position, counted from 0. For an object, key holds each member's name instead.

  2. Question 2 of 4 What does this query return in SQLite 3.49?

    Read the code, then choose one answer.

    SELECT json_group_array(sku) FROM items WHERE order_id = 999;
    Show the answer to question 2

    Answer: [], because the aggregate saw no rows

    An aggregate over no rows still returns one row. SQLite's json_group_array() returns an empty array then, which is why a subquery per order gives an order without items the array [].

  3. Question 3 of 4 What does this query return in SQLite 3.49?

    Read the code, then choose one answer.

    SELECT json_object('gift', 1 = 1) AS doc;
    Show the answer to question 3

    Answer: {"gift":1}

    SQLite has no boolean type: 1 = 1 is the integer 1, and json_object() writes it as the JSON number 1. Use json('true') or json('false') where a document needs JSON's true and false.

  4. Question 4 of 4 What does this query return in SQLite 3.49?

    Read the code, then choose one answer.

    SELECT json_array('[1, 2]', json('[1, 2]')) AS arr;
    Show the answer to question 4

    Answer: ["[1, 2]",[1,2]], a string and then an array

    json_array() and json_object() write plain text as a JSON string, even when the text looks like JSON. A value that comes straight from json() or another JSON function is embedded as JSON.

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.

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.

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

  1. 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 in value, its position from 0 in key and its JSON type in type; for an object, one row per member, with the member's name in key. 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 has json_each() and json_tree() instead.

  2. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

  4. 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.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Turning JSON into rows uses a function in FROM: json_each() in SQLite, jsonb_array_elements() or JSON_TABLE in PostgreSQL, and JSON_TABLE in MySQL.
  • An inner join to an expanded array drops the rows whose array is empty or missing.
  • json_object() and an aggregate such as json_group_array() turn rows into JSON; a subquery per list gives each parent its own array.
  • State the order of an array with ORDER BY inside 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.