Chapter 13 · JSON and Semi-Structured Data in SQLite
JSON Paths, json_extract, -> / ->>, and Value Construction
Navigate nested SQLite JSON values with paths, understand json_extract versus -> and ->>, distinguish SQL NULL from JSON null, and construct predictable JSON values for reports and APIs.
Learning outcomes
Once a JSON document is deliberately part of the schema, path expressions become its read interface. The important beginner habit is to inspect one level at a time and always ask whether the result is JSON text or an ordinary SQL scalar.
Read JSON path expressions from the root through object members and array indexes.
Use json_extract() and explain its scalar-versus-container return behavior.
Distinguish -> JSON text results from ->> SQL scalar results.
Differentiate missing paths, JSON null, and SQL NULL with json_type().
Construct nested JSON safely with json_object(), json_array(), json(), and json_quote().
Build a relational report from nested FieldNotes metadata without unreadable one-liners.
Start with one readable document
Use this nested document as the chapter's reference shape. Pretty formatting is for humans; SQLite accepts the equivalent compact representation.
SELECT json_pretty(json('{"firmware":{"version":"3.7.2","channel":"stable"},"sensors":[{"kind":"temperature","unit":"C"},{"kind":"vibration","unit":"mm/s"}],"calibration":{"due":"2026-10-01","certified":true},"alias":null}'));
The logical structure is an object at the root, containing nested objects, an array of sensor objects, and a JSON null value.
Path syntax: root, members, and arrays
A SQLite JSON path begins with exactly one $. A dot
selects an object label and square brackets select an array
element. Array indexes start at zero. The special
#-1 form selects the final array element and works
as part of a path on the course baseline.
| Path | Meaning |
|---|---|
$ |
The entire JSON value. |
$.firmware |
The member named firmware. |
$.firmware.version |
Nested member version. |
$.sensors[0] |
First array element. |
$.sensors[1].unit |
Unit inside the second sensor object. |
$.sensors[#-1] |
Last array element. |
WITH sample(doc) AS ( VALUES ('{"firmware":{"version":"3.7.2"},"sensors":[{"kind":"temperature"},{"kind":"vibration"}]}'))SELECT json_extract(doc,'$.firmware') AS firmware_object, json_extract(doc,'$.firmware.version') AS firmware_version, json_extract(doc,'$.sensors[0].kind') AS first_sensor, json_extract(doc,'$.sensors[#-1].kind') AS last_sensorFROM sample;
json_extract(): container JSON versus SQL scalar
With one path, json_extract() returns SQL
NULL/TEXT/INTEGER/REAL for JSON null, strings, booleans, and
numbers, while arrays and objects come back as JSON text. With
multiple paths, the result is a JSON array represented as TEXT.
This is an SQLite behavior worth knowing because similarly named
functions in other products can return different types.
WITH sample(doc) AS ( VALUES ('{"name":"Pump 7","count":3,"enabled":true,"alias":null,"tags":["pump","critical"]}'))SELECT json_extract(doc,'$.name') AS name, typeof(json_extract(doc,'$.name')) AS name_type, json_extract(doc,'$.count') AS count_value, typeof(json_extract(doc,'$.count')) AS count_type, json_extract(doc,'$.enabled') AS enabled, typeof(json_extract(doc,'$.enabled')) AS enabled_type, json_extract(doc,'$.alias') AS alias_value, json_extract(doc,'$.tags') AS tags_jsonFROM sample;
Expected runtime types are TEXT for name, INTEGER
for count and true (1), SQL NULL for JSON null, and
TEXT containing JSON for the tags array.
-> versus ->>: same location, different representation
SQLite added the extraction operators in 3.38.0. The left side
may be JSON text or JSONB. -> returns an
RFC-8259 JSON text representation of the selected component;
->> returns an ordinary SQL scalar
representation. For a JSON string, that difference means quotes
versus dequoted text. For JSON null it means the text
null versus SQL NULL.
WITH sample(doc) AS ( VALUES ('{"name":"Pump 7","count":3,"alias":null,"tags":["pump","critical"]}'))SELECT doc -> '$.name' AS name_json, doc ->> '$.name' AS name_sql, doc -> '$.count' AS count_json, typeof(doc ->> '$.count') AS count_sql_type, doc -> '$.alias' AS alias_json, doc ->> '$.alias' AS alias_sql, doc -> '$.tags' AS tags_jsonFROM sample;
Direct negative integer operands such as
doc -> -1 were added in SQLite 3.47.0. For
broader compatibility, a path like
'$[#-1]' communicates the intent explicitly.
Missing path and JSON null are not the same fact
json_extract() returns SQL NULL both when a
selected JSON value is JSON null and when the path does not
exist. Use json_type() when that distinction
matters: it returns the text null for a JSON null,
but SQL NULL for a missing path.
WITH sample(doc) AS (VALUES ('{"alias":null}'))SELECT json_extract(doc,'$.alias') AS alias_value, json_type(doc,'$.alias') AS alias_json_type, json_extract(doc,'$.owner') AS owner_value, json_type(doc,'$.owner') AS owner_json_typeFROM sample;
An API contract may care deeply about “field explicitly present with null” versus “field absent”. Do not use one extraction result to erase that distinction accidentally.
Construct values without string concatenation
Construction helpers understand SQL values. Ordinary SQL text
passed as a value becomes a JSON string, even if it looks like
JSON. If a value comes directly from another JSON function—or
from ->—SQLite understands it as JSON structure.
The json() wrapper validates/canonicalizes JSON
text; json_quote() converts an SQL scalar into its
JSON representation.
SELECT json_object( 'device','PUMP-007', 'active',1, 'tags',json_array('pump','critical'), 'network',json('{"protocol":"modbus","port":502}'), 'note',json_quote('O''Brien valve') ) AS document;SELECT json_object('payload','[1,2]') AS quoted_text, json_object('payload',json('[1,2]')) AS nested_array;
The second query is a useful debugging pair: the first output
contains a JSON string "[1,2]"; the second contains
an actual JSON array [1,2].
Build report columns before building an API object
Do not jump from a large document directly to one giant JSON-producing expression. First expose ordinary relational report columns. That makes types, missing values, and joins observable.
SELECT d.device_code, s.site_name, d.status, p.installed_at, json_extract(p.metadata,'$.firmware.version') AS firmware_version, json_extract(p.metadata,'$.firmware.channel') AS firmware_channel, json_extract(p.metadata,'$.calibration.due') AS calibration_dueFROM device AS dJOIN site AS s ON s.site_id=d.site_idJOIN device_profile AS p ON p.device_id=d.device_idORDER BY d.device_code;
Only after this report is correct should an application decide whether to return rows, construct a JSON object per row, or aggregate rows into an API response.
Failure case: confusing SQL text with JSON structure
A common bug is to pass text that contains JSON punctuation into
json_object() and assume SQLite will parse it
automatically. It will not, because accepting arbitrary-looking
strings as structure would make value boundaries ambiguous.
-- Produces {"sensor":"{"kind":"temperature"}"}SELECT json_object('sensor','{"kind":"temperature"}');-- Produces {"sensor":{"kind":"temperature"}}SELECT json_object('sensor',json('{"kind":"temperature"}'));
When the input is untrusted external text, validation is an advantage, not an inconvenience.
Lab: extract then reconstruct a device summary
WITH report AS ( SELECT d.device_code, s.site_name, d.status, json_extract(p.metadata,'$.firmware.version') AS firmware_version, json_extract(p.metadata,'$.network.protocol') AS protocol FROM device AS d JOIN site AS s ON s.site_id=d.site_id JOIN device_profile AS p ON p.device_id=d.device_id)SELECT device_code, json_object( 'device_code',device_code, 'site',site_name, 'status',status, 'firmware_version',firmware_version, 'protocol',protocol ) AS api_shapeFROM reportORDER BY device_code;
Inspect both columns. If a protocol path is absent, the SQL
value is NULL and json_object() emits JSON null for
that value. Decide whether your API wants explicit null or
omission; those are different contracts.
Verification checkpoint
Paths and values checkpoint
Trace the representation at every boundary.
- What must every full SQLite JSON path begin with?
- What does json_extract() return for a single JSON string path?
- How do -> and ->> differ for a JSON string?
- How can json_type() distinguish JSON null from a missing path?
- Why does json_object("x","[1,2]") not create a nested array?
- When should json() wrap a TEXT value?
Review the answers
A full path begins with $.
A single scalar string path through json_extract() becomes ordinary SQL TEXT.
-> returns JSON text while ->> returns the SQL scalar.
json_type() reports null for JSON null and SQL NULL for an absent path.
Ordinary TEXT is quoted as a JSON string;
wrap text in json() only when it is intended and validated as JSON structure.
Production judgment and bridge
Paths let one row expose nested values, but real JSON documents often contain arrays that must be searched or joined. Lesson 3 turns those nested elements into rows using SQLite table-valued JSON traversal.