Chapter 13 · JSON and Semi-Structured Data in SQLite

json_each, json_tree, and Querying JSON Arrays/Objects Relationally

Turn JSON arrays and objects into relational rowsets with json_each and json_tree, join traversal results back to FieldNotes rows, and recognize when repeated traversal signals a modeling problem.

Beginner110–130 minutesTraversal + join labSQLite 3.53.4 baselinejson_each/json_treeLast reviewed: August 2026

Learning outcomes

An array is convenient inside a document, but SQL works most naturally with rows. SQLite's JSON table-valued functions bridge those models by presenting document elements as a temporary relational rowset that can participate in joins, filters, grouping, and ordering.

01

Explain table-valued JSON functions as row-producing query sources rather than scalar functions.

02

Use json_each() for one level of an object or array.

03

Use json_tree() for recursive traversal and interpret key, value, atom, type, path, fullkey, id, and parent.

04

Join expanded JSON tags and attributes back to ordinary relational rows.

05

Diagnose malformed-input and mixed-shape problems before traversal.

06

Recognize when frequent traversal should become modeled relational structure.

Scalar extraction answers one path; traversal answers many elements

If the question is “what is this device's firmware version?”, json_extract() is direct. If the question is “which devices have any tag equal to critical?”, the document contains zero or more candidate elements. json_each() exposes those immediate children as rows.

sql · one row per tag
SELECT p.device_id,       j.key AS array_index,       j.value AS tag,       j.type AS json_typeFROM device_profile AS p,     json_each(p.metadata, '$.tags') AS jORDER BY p.device_id, j.key;

For an array, key is the zero-based array index. For an object, key is the member name.

json_each(): shallow traversal

json_each(X) walks only the immediate children of the top-level array/object. Its optional second argument chooses a path to treat as the traversal root. That makes it ideal for a known tags array or one level of optional attributes.

sql · find devices tagged critical
SELECT DISTINCT d.device_code, d.device_nameFROM device AS dJOIN device_profile AS p ON p.device_id=d.device_idJOIN json_each(p.metadata, '$.tags') AS tagWHERE tag.value = 'critical'ORDER BY d.device_code;

This is a relational join even though one side is produced dynamically from a JSON value. The json_each() rowset exists for the query; it is not a persisted child table or index.

json_tree(): recursive traversal

json_tree() recursively walks nested substructure. Current SQLite exposes descriptive columns including key, value, type, atom, id, parent, fullkey, and path. The id is useful for relating rows within one traversal, but its exact computation is an internal detail and should not be persisted as an application identifier.

ColumnMeaning for query work
keyArray index or object member name relative to the parent.
valueSQL scalar for primitives; JSON text for object/array containers in json_each/json_tree.
atomSQL scalar for primitive leaves; NULL for object/array containers.
typeJSON type such as object, array, text, integer, true, false, or null.
idTraversal-local integer identifier; not a stable application key.
parentParent element id in json_tree; not useful as a cross-query durable identifier.
fullkeyFull path to the current element.
pathPath to the container that holds the current row.

Inspect leaves instead of printing every container

Recursive output can be noisy because each object and array also gets a row. Filtering to atom IS NOT NULL gives a compact list of primitive leaf values. If JSON null must remain visible, filter by type NOT IN ('object','array') instead, because a JSON null has a NULL atom.

sql · flatten readable leaf paths
SELECT d.device_code,       jt.fullkey,       jt.type,       jt.atomFROM device AS dJOIN device_profile AS p ON p.device_id=d.device_idJOIN json_tree(p.metadata) AS jtWHERE jt.type NOT IN ('object','array')ORDER BY d.device_code, jt.fullkey;

Search nested attributes without assuming one fixed depth

Recursive traversal can be useful during data discovery or migration when an attribute may be nested differently across historical payloads. Do not let that convenience become a permanent excuse for an uncontrolled schema.

sql · find a protocol key anywhere in metadata
SELECT DISTINCT d.device_code,       jt.fullkey,       jt.value AS protocolFROM device AS dJOIN device_profile AS p ON p.device_id=d.device_idJOIN json_tree(p.metadata) AS jtWHERE jt.key = 'protocol'  AND jt.type = 'text'ORDER BY d.device_code;

If a production query repeatedly searches “anywhere in the document” for an operational key, that is evidence the data contract may be too vague.

Object traversal is useful for dynamic attribute reports

For a known object such as $.network, json_each() can produce key/value rows that are easy to display in a generic diagnostics UI.

sql · network attributes as rows
SELECT d.device_code,       attr.key AS attribute_name,       attr.value AS attribute_value,       attr.type AS json_typeFROM device AS dJOIN device_profile AS p ON p.device_id=d.device_idJOIN json_each(p.metadata, '$.network') AS attrORDER BY d.device_code, attr.key;

A generic attribute viewer is a strong JSON use case. A high-volume query that filters millions of rows by one of those attributes is a different workload and may call for indexing or relational promotion.

Performance mental model: traversal work happens at query time

Each call must interpret the document and walk relevant structure. SQLite's newer internal JSONB representation can avoid text parsing, but traversal is still not equivalent to an indexed relational child table. There is no automatic B-tree index over every arbitrary JSON key or array element.

Do not optimize by slogan

A small metadata document traversed occasionally can be entirely appropriate. Repeatedly expanding large arrays for latency-sensitive searches is a signal to measure and possibly remodel. Lesson 5 shows an indexable path strategy for stable scalar properties.

Failure case: mixed plain text and JSON arrays

json_each() expects well-formed JSON. If one historical row stores critical as plain SQL text while another stores ["critical"], a single traversal query can fail or require shape-specific branches. The Chapter 13 schema prevents that by validating every metadata document; individual member shapes still need a documented contract.

sql · diagnose the shape before traversal
SELECT device_id,       json_type(metadata, '$.tags') AS tags_type,       json_array_length(metadata, '$.tags') AS tag_countFROM device_profileORDER BY device_id;

Expected healthy rows report array for tags_type. Missing tags return SQL NULL for the type/path rather than pretending an empty array exists.

Lab: tags and nested-attribute search

sql · two search patterns
-- A. Immediate array membershipSELECT DISTINCT d.device_codeFROM device AS dJOIN device_profile AS p USING(device_id)JOIN json_each(p.metadata,'$.tags') AS tagWHERE tag.value IN ('critical','inspection')ORDER BY d.device_code;-- B. Recursive discovery of text leaves containing "modbus"SELECT DISTINCT d.device_code, jt.fullkey, jt.atomFROM device AS dJOIN device_profile AS p USING(device_id)JOIN json_tree(p.metadata) AS jtWHERE jt.type='text'  AND lower(jt.atom) LIKE '%modbus%'ORDER BY d.device_code, jt.fullkey;

Record the query requirement, not just the result. If the membership search becomes a core application path, compare a normalized device_tag table against repeated JSON traversal before choosing an optimization.

Version note: jsonb_each/jsonb_tree

SQLite 3.51.0 added jsonb_each() and jsonb_tree(). They differ mainly in returning JSONB for object/array values in the value column. Chapter 13 does not require them because json_each()/json_tree() have much broader version compatibility and already teach the relational traversal model.

Verification checkpoint

Traversal checkpoint

Choose shallow or recursive traversal deliberately.

  1. What is the difference between json_each() and json_tree()?
  2. What does key represent for an array versus an object?
  3. Why should the json_tree id column not become an application identifier?
  4. How can you suppress object/array container rows while keeping JSON null leaves visible?
  5. Why can repeated json_each() searches be slower than a modeled indexed child table?
  6. What modeling smell appears when production queries constantly search for the same nested key at arbitrary depths?
Review the answers

json_each() walks one level; json_tree() recurses. key is an array index or object label. id is traversal implementation metadata, not a durable key. Filter type NOT IN (object,array) to keep primitive leaves including JSON null. Traversal happens at query time and is not an automatic index. Constant arbitrary-depth searching usually signals hidden schema that deserves a stronger contract or relational promotion.

Production judgment and bridge

Traversal makes nested JSON queryable, but parsing and representation still have costs. Lesson 4 introduces SQLite JSONB as an optional internal representation and measures it without confusing it with PostgreSQL JSONB or promising universal speedups.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.