JSON columns
MySQL's JSON type: -> versus ->>, typed comparisons, JSON_TABLE to turn arrays into rows, multi-valued indexes, and when a JSON column is a normalisation shortcut you will regret.
After this lesson you can
- Extract values with -> and ->> and know which one returns text
- Predict how a JSON value compares with a SQL value
- Expand a JSON array into rows with JSON_TABLE
- Index membership in a JSON array with a multi-valued index
A JSON column accepts only valid JSON and stores it in a binary format
that can be read by path without reparsing the text. Paths start at $:
$.color is a member, $.sizes[0] an array element, $.items[*].sku
every element's sku.
-> and ->>
col->'$.path' is shorthand for JSON_EXTRACT(col, '$.path') and returns
a JSON value. col->>'$.path' is JSON_UNQUOTE(JSON_EXTRACT(...)) and
returns text. The difference shows the moment a JSON string is turned
into SQL text, for example by CONCAT.
CREATE TABLE products (id INT PRIMARY KEY, attrs JSON NOT NULL);INSERT INTO products VALUES (1, '{"color": "red", "sizes": ["S", "M"], "weight": 1.5}'), (2, '{"color": "blue", "sizes": ["M", "L"], "weight": 10}'), (3, '{"colour": "red", "sizes": [], "weight": "10"}');SELECT id, CONCAT('color: ', attrs->'$.color') AS with_arrow, CONCAT('color: ', attrs->>'$.color') AS with_double_arrow, JSON_TYPE(attrs->'$.weight') AS weight_type, attrs->'$.weight' = 10 AS json_equals_10, attrs->>'$.weight' = 10 AS text_equals_10FROM productsORDER BY id;- With
->the color is the JSON string"red", quotes included; with->>it is the plain textred. - JSON values keep their types:
JSON_TYPEreportsDOUBLE,INTEGERandSTRINGfor the three weights. Comparing the JSON value with the number10matches only the JSON number 10; the JSON string"10"is not equal to it. After->>, the text'10'is converted to a number and matches. - A path that does not exist returns SQL
NULL, not an error. Product 3 spells the keycolour, so itscolorisNULL.
JSON_TABLE: arrays into rows
JSON_TABLE turns a JSON document into a derived table with typed
columns, so an array of line items can be joined, grouped and summed like
any other rows.
CREATE TABLE products (sku VARCHAR(10) PRIMARY KEY, name VARCHAR(20) NOT NULL);INSERT INTO products VALUES ('A', 'Pen'), ('B', 'Ink');CREATE TABLE orders (id INT PRIMARY KEY, doc JSON NOT NULL);INSERT INTO orders VALUES (1, '{"customer": "Ann", "items": [{"sku": "A", "qty": 2}, {"sku": "B", "qty": 1}]}'), (2, '{"customer": "Bob", "items": [{"sku": "A", "qty": 5}, {"sku": "Q", "qty": 1}]}'), (3, '{"customer": "Cy", "items": []}');SELECT o.id, o.doc->>'$.customer' AS customer, li.sku, li.qty, p.name AS productFROM orders oCROSS JOIN JSON_TABLE(o.doc, '$.items[*]' COLUMNS (sku VARCHAR(10) PATH '$.sku', qty INT PATH '$.qty')) AS li-- JSON_TABLE text takes the connection's collation; match the column's.LEFT JOIN products p ON p.sku = li.sku COLLATE utf8mb4_0900_ai_ciORDER BY o.id, li.sku;Each element of $.items[*] became a row, with sku and qty cast to the
declared types. An order whose items array is empty contributes no rows.
Bob's second item names SKU Q, which is not in products: the JSON
column accepted it, because nothing inside a JSON document can be the
subject of a foreign key. (The COLLATE is needed because JSON_TABLE
text takes the connection's collation; without it this join fails with
error 1267.)
Indexing JSON
A plain index cannot be built on a JSON column. You have two options:
- for a scalar path, index a generated column or a functional index on
(CAST(doc->>'$.sku' AS CHAR(20))); the next lessons show both; - for array membership, a multi-valued index on
(CAST(doc->'$.sizes' AS CHAR(5) ARRAY)), which servesMEMBER OF,JSON_CONTAINSandJSON_OVERLAPS.
CREATE TABLE products ( id INT PRIMARY KEY, attrs JSON NOT NULL, INDEX idx_sizes ((CAST(attrs->'$.sizes' AS CHAR(5) ARRAY))));INSERT INTO products VALUES (1, '{"sizes": ["S", "M"]}'), (2, '{"sizes": ["M", "L"]}'), (3, '{"sizes": []}'), (4, '{"sizes": ["XL"]}'), (5, '{"sizes": ["S"]}');ANALYZE TABLE products;EXPLAIN FORMAT=JSON INTO @plan SELECT id FROM products WHERE 'M' MEMBER OF (attrs->'$.sizes'); SELECT JSON_UNQUOTE(JSON_EXTRACT(@plan, '$.query_block.table.access_type')) AS access_type, JSON_UNQUOTE(JSON_EXTRACT(@plan, '$.query_block.table.key')) AS index_used, (SELECT GROUP_CONCAT(id ORDER BY id) FROM products WHERE 'M' MEMBER OF (attrs->'$.sizes')) AS ids_with_m;JSON and normalisation
First normal form asks that each column hold a single value and that a
list be stored as rows. A JSON array of line items is a list packed into
one field, so it breaks first normal form, and it gives up what a child
table would enforce: foreign keys (the orphan Q above), NOT NULL,
types, CHECK constraints and a key that stops one SKU appearing twice in
an order. The normalised design is a table:
CREATE TABLE order_items (
order_id INT NOT NULL,
sku VARCHAR(10) NOT NULL,
qty INT NOT NULL CHECK (qty > 0),
PRIMARY KEY (order_id, sku),
FOREIGN KEY (order_id) REFERENCES orders (id),
FOREIGN KEY (sku) REFERENCES products (sku)
);
JSON_TABLE rebuilds that table on every query, with none of its
guarantees. Second and third normal form go further: a non-key column
depends on the whole primary key and nothing else, so each fact lives in
one place (a product's name in products, not in every order line). JSON
earns its place for genuinely variable attributes or a payload stored and
returned whole; a path that most queries filter or join on wants to be a
real column or a child table.
Common mistakes
- Building text from
->.CONCAT('color: ', doc->'$.color')includes the JSON quotes; use->>. - Comparing a JSON number with a JSON string.
"10"and10are different JSON values, as product 3 shows. - Expecting an index on the JSON column itself. It is not allowed; index a generated column, an expression, or a multi-valued array.
Exercise · Pro
Units sold per SKU from JSON orders
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade