Chapter 05 · Advanced SQL: Aggregation, Window Functions, JSON, and Analytical Patterns
JSON Documents, Paths, JSON_TABLE, Indexable JSON Patterns, and Validation
Use MySQL native JSON without abandoning relational discipline: validate document shape, reason about JSON/SQL NULL, project arrays with JSON_TABLE, and index stable hot paths with plan evidence.
Learning outcomes
Operational schemas sometimes need a small amount of semi-structured data: a work order may carry channel, asset metadata, required skills, and an SLA value that vary by asset type. A native JSON column can be a good boundary when the attributes are genuinely flexible, but “put it in JSON” is not a license to abandon types, constraints, or indexes.
This lesson treats JSON as part of a relational design. We will validate documents, distinguish JSON null from SQL NULL, project arrays into rows with JSON_TABLE(), and make a frequently queried path indexable. The goal is not to turn MySQL into a document database.
Explain what MySQL native JSON storage guarantees and what application/schema validation must still guarantee.
Use JSON paths, -> / ->>, JSON_VALUE(), JSON_TYPE(), and JSON_TABLE() with precise NULL semantics.
Project a JSON array into relational rows and aggregate it safely.
Use EXPLAIN, SHOW INDEX, and an expression index to make a frequently filtered JSON scalar indexable.
Diagnose a tempting but irrelevant index and compare it with a path-specific corrected design.
The mandatory lab uses only MySQL Community Server. JSON is appropriate here for optional per-work-order attributes; customer identity, status, keys, timestamps, and money remain strongly typed relational columns because they participate in core constraints and joins.
Native JSON validates syntax; your schema validates meaning
A MySQL JSON column accepts valid JSON values and rejects malformed JSON text. That solves syntax validation, not domain validation. A valid JSON array is still the wrong shape if your application contract says attributes must be an object. The lab therefore adds a CHECK that allows either SQL NULL or a JSON object.
USE servicehub_analytics_lab;SHOW CREATE TABLE work_orders;SELECT work_order_id, JSON_TYPE(attributes) AS json_typeFROM work_ordersORDER BY work_order_id;-- Malformed JSON text: rejected by the JSON data type.INSERT INTO work_orders(work_order_id,customer_id,technician_id,status,priority,opened_at, labor_minutes,parts_cost,summary,attributes)VALUES(2001,1,11,'open',2,NOW(),10,0,'bad json syntax','{"channel": portal}');-- Valid JSON, wrong business shape: rejected by chk_attributes_object.INSERT INTO work_orders(work_order_id,customer_id,technician_id,status,priority,opened_at, labor_minutes,parts_cost,summary,attributes)VALUES(2002,1,11,'open',2,NOW(),10,0,'wrong json shape',JSON_ARRAY('portal','api'));-- Verify neither failed row exists.SELECT COUNT(*) FROM work_orders WHERE work_order_id IN (2001,2002);Error text can vary slightly by patch/client, but the first failure is invalid JSON text and the second is an enforced check-constraint violation. The final count must remain zero.
Paths and scalar extraction: JSON text versus SQL text
A JSON path begins at $. Object members use dot notation such as $.asset.type; arrays can use indexes or wildcards. The -> operator returns a JSON value, while ->> unquotes a scalar into SQL text. JSON_VALUE() is useful when you want a typed scalar result.
SELECT work_order_id, attributes->'$.channel' AS channel_json, attributes->>'$.channel' AS channel_text, JSON_VALUE(attributes,'$.sla_hours' RETURNING UNSIGNED) AS sla_hours, attributes->>'$.asset.type' AS asset_typeFROM work_ordersWHERE attributes IS NOT NULLORDER BY work_order_id;Choose an extraction form that matches the consumer. Quoted JSON strings are correct JSON but awkward for relational comparison/display. A typed JSON_VALUE(... RETURNING ...) expression can also be reused in a functional index.
JSON null, missing path, and SQL NULL are different states
Work order 1001 contains a JSON member "note": null. Work order 1010 has SQL NULL in the entire attributes column. A missing path is another case. If you collapse these states too early, the application cannot tell “document absent,” “member absent,” and “member explicitly JSON null” apart.
SELECT work_order_id, attributes IS NULL AS attributes_is_sql_null, JSON_TYPE(JSON_EXTRACT(attributes,'$.note')) AS note_json_type, JSON_EXTRACT(attributes,'$.missing_member') IS NULL AS missing_path_is_sql_nullFROM work_ordersWHERE work_order_id IN (1001,1002,1010)ORDER BY work_order_id;For 1001, the extracted note has JSON type NULL; for a missing path, extraction yields SQL NULL; and for 1010 the whole document is SQL NULL. Test the state you actually mean.
JSON_TABLE turns repeated JSON values into relational rows
The skills member is an array. Searching it with string patterns would be fragile. JSON_TABLE() maps array elements to a derived relational table, after which normal joins, grouping, and predicates apply.
SELECT w.work_order_id, jt.ord, jt.skillFROM work_orders AS wJOIN JSON_TABLE( COALESCE(w.attributes, JSON_OBJECT('skills', JSON_ARRAY())), '$.skills[*]' COLUMNS ( ord FOR ORDINALITY, skill VARCHAR(30) PATH '$' ) ) AS jt ON TRUEWHERE w.attributes IS NOT NULLORDER BY w.work_order_id, jt.ord;SELECT jt.skill, COUNT(*) AS work_order_mentionsFROM work_orders AS wJOIN JSON_TABLE( COALESCE(w.attributes, JSON_OBJECT('skills', JSON_ARRAY())), '$.skills[*]' COLUMNS (skill VARCHAR(30) PATH '$') ) AS jt ON TRUEWHERE w.attributes IS NOT NULLGROUP BY jt.skillORDER BY work_order_mentions DESC, jt.skill;JSON_TABLE() is powerful, but it is still query work. If a skill becomes a high-cardinality, heavily joined business entity with integrity rules, promote it to a relational table rather than forcing every workload through JSON expansion.
Before tuning, prove the predicate and the current access path
Assume channel becomes a frequent filter. First write the exact predicate and inspect the plan. On a tiny twelve-row table, a full scan may be rational; the important baseline is what indexes are eligible and what the optimizer currently chooses.
EXPLAIN FORMAT=TREESELECT work_order_id, summaryFROM work_ordersWHERE JSON_VALUE(attributes,'$.channel' RETURNING CHAR(20)) = 'portal';EXPLAIN ANALYZESELECT work_order_id, summaryFROM work_ordersWHERE JSON_VALUE(attributes,'$.channel' RETURNING CHAR(20)) = 'portal';Do not turn the local timing into a performance promise. Record row count, schema, server version, and plan if you compare changes.
Tempting but ineffective tuning: index a different column
A developer sees a slow query and adds an index on status because status is “commonly used.” That index can help queries filtering status, but it does not make the JSON channel expression searchable. This is a useful failure because the DDL succeeds yet the target predicate is unchanged.
CREATE INDEX ix_work_orders_status ON work_orders(status);SHOW INDEX FROM work_orders;EXPLAINSELECT work_order_id, summaryFROM work_ordersWHERE JSON_VALUE(attributes,'$.channel' RETURNING CHAR(20)) = 'portal';DROP INDEX ix_work_orders_status ON work_orders;For the channel-only predicate, ix_work_orders_status should not appear as a useful access path. The lesson is methodological: tune the predicate that actually limits the query, not a nearby column.
Corrected alternative: index the scalar expression you query
MySQL can index an expression based on JSON_VALUE(). The query must use a compatible expression so the optimizer can match it. This turns a frequently queried scalar inside JSON into an indexable access path without duplicating the entire document into relational columns.
CREATE INDEX ix_work_orders_channelON work_orders ( (JSON_VALUE(attributes,'$.channel' RETURNING CHAR(20))));ANALYZE TABLE work_orders;SHOW INDEX FROM work_orders WHERE Key_name='ix_work_orders_channel';EXPLAINSELECT work_order_id, summaryFROM work_ordersWHERE JSON_VALUE(attributes,'$.channel' RETURNING CHAR(20)) = 'portal';On this tiny seed, MySQL may still choose a table scan because reading twelve rows is cheap. That does not mean the index is malformed. Check possible_keys, the chosen key, row estimates, and—on a representative larger dataset—actual execution evidence. An index is an available access structure, not a command that forces the optimizer to use it.
The expression index consumes storage and must be maintained on inserts and updates that affect the JSON document. Create path indexes for stable, frequently queried attributes—not every possible key in a flexible document.
Hands-on lab and cleanup
- Inspect
SHOW CREATE TABLEand identify both JSON syntax validation and the object-shape check. - Trigger malformed-JSON and wrong-shape failures; verify neither row was inserted.
- Compare
->,->>, and typedJSON_VALUE(). - Distinguish JSON null, missing path, and SQL NULL with the provided probe query.
- Use
JSON_TABLE()to count skill mentions. - Capture the channel-filter plan, add the irrelevant status index, then add the expression index and compare evidence on the same table.
DROP INDEX ix_work_orders_channel ON work_orders;-- The next lesson needs the data, not this optional tuning artifact.SELECT COUNT(*) AS work_orders_after_cleanup FROM work_orders;Knowledge check
- What does a native JSON column validate automatically?
- How does ->> differ from -> for a JSON string?
- Why is JSON null different from SQL NULL?
- What relational object does JSON_TABLE produce?
- Why might MySQL still scan a twelve-row table after a correct expression index exists?
Reveal answers
- It validates that stored non-NULL values are valid JSON; additional business shape/field rules still need schema or application constraints.
- -> returns the JSON value (including JSON quoting); ->> unquotes the scalar to SQL text.
- JSON null is a value inside a JSON document, while SQL NULL represents absence/unknown at the SQL value level; a missing path can also yield SQL NULL.
- A derived relational table with columns defined by the JSON_TABLE COLUMNS clause.
- For a tiny table, the optimizer can estimate that scanning all rows is cheaper than using the index; index existence does not force index selection.
Production judgment and next bridge
Use JSON when variability is real and the relational core remains clear. Promote frequently constrained or joined attributes to typed columns/tables when that improves integrity and access patterns. For JSON paths that stay flexible but become hot predicates, index deliberately and verify with plan evidence. Avoid storing opaque blobs of application state that no SQL consumer can reason about.
Next: we return to scalar SQL functions—date/time, collation-aware strings, ICU regular expressions, exact/approximate numeric behavior, and conditional expressions—organized around real transformation problems rather than a function catalog.
Authoritative references
- MySQL 8.4 Reference Manual — The JSON Data Type
- MySQL 8.4 Reference Manual — JSON Functions
- MySQL 8.4 Reference Manual — JSON Table Functions
- MySQL 8.4 Reference Manual — Functions That Search JSON Values / JSON_VALUE
- MySQL 8.4 Reference Manual — Optimizer Use of Generated Column Indexes
- MySQL 8.4 Reference Manual — CREATE INDEX