Chapter 13 · JSON and Semi-Structured Data in SQLite
Indexing JSON Paths, Generated Columns, Validation, and Hybrid Designs
Make important JSON properties queryable with validation, generated columns and indexes, verify planner use with EXPLAIN QUERY PLAN, and decide when a JSON property should graduate into the relational schema.
Learning outcomes
JSON flexibility does not remove indexing requirements. If one document path becomes a frequent predicate, SQLite can expose that deterministic scalar as a generated column or expression index. The design should still ask whether the property has become important enough to deserve an ordinary relational column.
Enforce the chosen JSON text contract with NOT NULL and CHECK(json_valid(...)).
Expose stable JSON scalar paths through generated columns.
Index generated columns and verify planner use with EXPLAIN QUERY PLAN.
Build an expression index directly on json_extract() and explain expression matching requirements.
Compare direct JSON filtering, generated-column filtering, and relational promotion.
Use a production hybrid-design checklist to decide when JSON remains appropriate.
Validation is the first index prerequisite
An indexable expression is useful only if the input contract is predictable. For canonical text JSON that is mandatory, a compact schema pattern is TEXT NOT NULL CHECK(json_valid(metadata)). If SQL NULL is allowed, make that choice explicit with CHECK(metadata IS NULL OR json_valid(metadata)).
CREATE TABLE valid_required ( id INTEGER PRIMARY KEY, metadata TEXT NOT NULL CHECK(json_valid(metadata)));CREATE TABLE valid_optional ( id INTEGER PRIMARY KEY, metadata TEXT CHECK(metadata IS NULL OR json_valid(metadata)));If the application intentionally accepts JSON5 input, decide whether to canonicalize it with json() before storage. A path index should not be built on an undefined mix of conventions.
Generated columns give an important path a relational name
Generated columns are computed from same-row deterministic expressions. SQLite's JSON functions are deterministic, so a scalar path such as firmware version is a good candidate. A VIRTUAL generated column stores no separate column payload and computes on read; it can still participate in an index.
CREATE TABLE device_profile_indexed ( device_id INTEGER PRIMARY KEY REFERENCES device(device_id) ON DELETE CASCADE, metadata TEXT NOT NULL CHECK(json_valid(metadata)), firmware_version TEXT GENERATED ALWAYS AS (json_extract(metadata,'$.firmware.version')) VIRTUAL, calibration_due TEXT GENERATED ALWAYS AS (json_extract(metadata,'$.calibration.due')) VIRTUAL);INSERT INTO device_profile_indexed(device_id, metadata)SELECT device_id, metadata FROM device_profile;Generated columns require SQLite 3.31.0+. Use PRAGMA table_xinfo(device_profile_indexed), not only table_info, when you need to see generated columns in introspection.
Measure before the index
Start with a plan rather than assuming JSON extraction is the bottleneck. On a tiny three-row course table SQLite may reasonably scan regardless of an available index, so the lab uses a larger disposable table when a visible plan contrast is needed.
EXPLAIN QUERY PLANSELECT device_idFROM device_profile_indexedWHERE firmware_version='3.7.2';Before an index exists, expect a SCAN device_profile_indexed style plan. Exact EXPLAIN QUERY PLAN text is diagnostic output and can vary by SQLite release.
Index the generated value and prove SEARCH
CREATE INDEX idx_device_profile_firmwareON device_profile_indexed(firmware_version);EXPLAIN QUERY PLANSELECT device_idFROM device_profile_indexedWHERE firmware_version='3.7.2';With a sufficiently meaningful dataset, the plan can change to SEARCH ... USING INDEX idx_device_profile_firmware. That evidence means SQLite can navigate the B-tree by the extracted value instead of re-evaluating the path for every row visited.
Expression indexes remove the named generated column
SQLite can also index an expression directly. The indexed expression may reference only columns of the indexed table and must use deterministic functions. Subqueries and nondeterministic functions are not allowed.
CREATE INDEX idx_device_profile_protocol_exprON device_profile_indexed( json_extract(metadata,'$.network.protocol'));EXPLAIN QUERY PLANSELECT device_idFROM device_profile_indexedWHERE json_extract(metadata,'$.network.protocol')='modbus';This can be compact when the path is purely an access optimization. A generated column is often easier to reuse consistently across queries, reports, and application mappings.
Apparently equivalent syntax may not match the expression index
SQLite's expression-index matching is syntactic rather than an algebra system. The planner tolerates minor whitespace differences, but it generally expects the query expression to match the indexed expression. The ->> operator and json_extract() can return equivalent scalar values while still being different expressions for index matching.
EXPLAIN QUERY PLANSELECT device_idFROM device_profile_indexedWHERE metadata ->> '$.network.protocol' = 'modbus';-- Compare with the expression that exactly matches the index:EXPLAIN QUERY PLANSELECT device_idFROM device_profile_indexedWHERE json_extract(metadata,'$.network.protocol') = 'modbus';The first query may scan while the second can search using idx_device_profile_protocol_expr. Verify the actual plan on your version and dataset instead of assuming semantic equivalence guarantees index reuse.
Large lab: make the plan difference visible
CREATE TABLE profile_probe ( id INTEGER PRIMARY KEY, metadata TEXT NOT NULL CHECK(json_valid(metadata)), protocol TEXT GENERATED ALWAYS AS ( json_extract(metadata,'$.network.protocol') ) VIRTUAL);WITH RECURSIVE seq(n) AS ( VALUES(1) UNION ALL SELECT n+1 FROM seq WHERE n < 30000)INSERT INTO profile_probe(id, metadata)SELECT n, json_object( 'network',json_object('protocol', CASE WHEN n%100=0 THEN 'modbus' ELSE 'mqtt' END), 'firmware',json_object('version',printf('3.%d.%d',n%8,n%20)) )FROM seq;EXPLAIN QUERY PLANSELECT count(*) FROM profile_probe WHERE protocol='modbus';CREATE INDEX idx_profile_probe_protocol ON profile_probe(protocol);EXPLAIN QUERY PLANSELECT count(*) FROM profile_probe WHERE protocol='modbus';The expected qualitative transition is from SCAN to SEARCH. The exact row estimates/details are version-specific diagnostics.
Promote a JSON property when its meaning outgrows metadata
An index can make a JSON path fast, but it does not automatically make the hidden schema clear. If firmware_version becomes mandatory, participates in release eligibility, requires a foreign key to an approved-version table, or is updated independently as a core business fact, an ordinary column may express the contract better.
| Signal | Likely action |
|---|---|
| Sparse vendor-specific value used rarely | Keep validated JSON. |
| Stable scalar filtered often but still optional metadata | Generated column or expression index may be enough. |
| Critical invariant or join key | Promote to ordinary relational column/table. |
| Unbounded repeated array membership searches | Consider normalized child table with indexes. |
| Document copied between heterogeneous systems | Prefer text JSON or export JSONB through json(). |
| Multiple historical key spellings | Run schema-evolution cleanup before indexing one spelling. |
Schema evolution: promotion is a migration, not a copy-paste
When promoting a path, define source-of-truth ownership first. A safe migration can add the relational column, backfill it from validated JSON, verify discrepancies, update writers to maintain only the new authoritative representation, and finally remove or stop reading the duplicated JSON key. Chapter 17 will formalize migration sequencing and compatibility.
SELECT device_id, json_type(metadata,'$.firmware.version') AS json_type, json_extract(metadata,'$.firmware.version') AS candidate_valueFROM device_profileWHERE COALESCE(json_type(metadata,'$.firmware.version'),'missing') <> 'text';Do not backfill first and ask data-quality questions later.
Lab: direct JSON predicate versus indexed generated column
-- Direct path expression without a matching expression indexEXPLAIN QUERY PLANSELECT count(*)FROM profile_probeWHERE json_extract(metadata,'$.network.protocol')='modbus';-- Named generated column with its indexEXPLAIN QUERY PLANSELECT count(*)FROM profile_probeWHERE protocol='modbus';Record both plans and verify the result count is identical. Performance claims are valid only when semantic results match.
Production hybrid-design checklist
- Identify which facts are authoritative relational columns and forbid duplicate truth inside JSON.
- Choose a JSON contract: canonical text, JSON5 input canonicalized to text, or intentionally SQLite-specific JSONB.
- Validate at the database boundary when malformed input would violate the data contract.
- Document member names, expected JSON types, null/missing semantics, and version evolution.
- Measure repeated JSON traversal/extraction before adding indexes.
- Use generated/expression indexes only for deterministic row-local expressions and verify plans.
- Promote paths that become critical invariants, join keys, high-value filters, or independently managed entities.
- Keep interoperability requirements visible before adopting JSONB.
- Test with the SQLite library actually bundled by each application, not only a developer CLI.
Verification checkpoint
Hybrid indexing checkpoint
Index important meaning, not arbitrary document complexity.
- Why combine NOT NULL with json_valid() for mandatory text JSON?
- Can a VIRTUAL generated column participate in an index?
- What requirement applies to functions inside generated columns and expression indexes?
- Why might ->> fail to use an expression index created on json_extract()?
- What should EXPLAIN QUERY PLAN prove before claiming an optimization?
- When should a frequently used JSON property become an ordinary relational column?
Review the answers
NOT NULL prevents SQL NULL while json_valid() rejects malformed text. VIRTUAL generated columns can be indexed. Expressions must be deterministic and row-local under the documented restrictions. ->> is a different expression from json_extract(), so a syntactically matched expression index may not apply. EXPLAIN QUERY PLAN should show the intended SEARCH/index access on a realistic dataset. Promote the property when it becomes a stable, critical, constrained, joined, independently managed, or heavily queried business fact.
Chapter 13 complete: JSON without abandoning relational discipline
You can now model JSON deliberately, navigate it with predictable types, traverse nested arrays/objects relationally, choose text versus JSONB based on measured requirements, and make selected paths indexable. Chapter 14 builds on this “table-like interface over specialized behavior” idea with virtual tables, FTS5, R-Tree, and extension safety.