JSON in SQL
MySQL's JSON type, extraction with JSON_EXTRACT, -> and ->>, rows from documents with JSON_TABLE, and when a column is the better answer.
JSON_EXTRACT(json_doc, path[, path] ...) -- shorthand: json_doc -> path; unquoted: json_doc ->> pathThe JSON type stores a validated document in a binary format with sorted keys, so reading one member does not reparse the whole text. Storage is roughly that of LONGBLOB and the maximum size is bounded by max_allowed_packet. Use it for genuinely open-ended data: per-type event payloads, third-party webhook bodies, user preferences whose keys you do not control.
Reading values
JSON_EXTRACT(json_doc, path[, path] ...) returns a JSON value. Two operators are pure shorthand for it:
payload -> '$.plan' = JSON_EXTRACT(payload, '$.plan')
payload ->> '$.plan' = JSON_UNQUOTE(JSON_EXTRACT(payload, '$.plan'))The first gives back "gold" with its JSON quotes; the second gives the text gold. JSON_VALUE(doc, path RETURNING type) extracts a scalar with a real SQL type and ON EMPTY / ON ERROR handling; without RETURNING it returns VARCHAR(512).
Documents into rows
JSON_TABLE() turns an array into a derived table you can join to. Its columns are name FOR ORDINALITY, name type PATH ..., name type EXISTS PATH ..., and NESTED PATH ... COLUMNS (...) for arrays inside arrays. The table alias is required.
Indexing
Three routes: index a generated column that extracts the scalar; a functional index on the expression; or a multi-valued index over an array, which MEMBER OF(), JSON_CONTAINS() and JSON_OVERLAPS() can use.
When not to use JSON
If every row has the field and you filter, sort, join or aggregate on it, it is a column. JSON cannot be a foreign key, carries no per-key statistics for the optimizer, and hides shape errors until read time.
Example
CREATE TABLE events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
type VARCHAR(40) NOT NULL, -- present on every row: a real column
created_at DATETIME(6) NOT NULL,
payload JSON NOT NULL, -- shape varies by type
-- JSON is not indexable on its own: extract, then index the extraction.
user_id BIGINT UNSIGNED AS (CAST(payload->>'$.user.id' AS UNSIGNED)) VIRTUAL,
KEY idx_type_created (type, created_at),
KEY idx_user (user_id, created_at)
) ENGINE=InnoDB;
INSERT INTO events (type, created_at, payload) VALUES
('signup', NOW(6), '{"user": {"id": 42, "plan": "gold"}, "tags": ["eu", "beta"]}');
-- The two spellings on each row are the same expression.
SELECT
JSON_EXTRACT(payload, '$.user.plan') AS a, -- "gold"
payload->'$.user.plan' AS b, -- "gold"
JSON_UNQUOTE(JSON_EXTRACT(payload, '$.user.plan')) AS c, -- gold
payload->>'$.user.plan' AS d -- gold
FROM events
WHERE user_id = 42; -- uses idx_user, unlike a raw JSON_EXTRACT filterWhen to use it
- Store webhook or event payloads whose keys differ per provider, then index the two fields you actually query.
- Expand an array of order line items into rows so it can be joined to the products table.
- Keep user preferences in one column instead of adding a nullable column for every new setting.
- Query an audit trail of before/after snapshots that must keep whatever shape the row had at the time.
More examples
JSON_TABLE: one row per array element
This is how a stored array joins to the rest of the schema — aggregate it, join it to products, or validate it. ERROR ON ERROR on the sku column turns a malformed document into a loud failure instead of a silent NULL, which is usually what you want when the field is supposed to be mandatory.
-- payload = {"order": 8891,
-- "items": [{"sku":"A1","qty":2,"gift":true},{"sku":"B7","qty":1}]}
SELECT e.id, jt.pos, jt.sku, jt.qty, jt.is_gift
FROM events AS e,
JSON_TABLE(
e.payload, '$.items[*]'
COLUMNS (
pos FOR ORDINALITY, -- 1, 2, 3 within the document
sku VARCHAR(20) PATH '$.sku' ERROR ON ERROR,
qty INT PATH '$.qty' DEFAULT '0' ON EMPTY,
is_gift INT EXISTS PATH '$.gift' -- 1 if the key is there, else 0
)
) AS jt -- the alias is mandatory
WHERE e.type = 'order_placed';A multi-valued index over an array
The alternative to a multi-valued index is a join table, which is usually still the better design for a many-to-many relationship. Use this when the array genuinely belongs to the document and you only ever ask membership questions. Creating one is not an online operation, so it rebuilds the table.
-- One multi-valued key part per index; it serves MEMBER OF, JSON_CONTAINS
-- and JSON_OVERLAPS, but cannot cover a query or do a range scan.
ALTER TABLE events
ADD INDEX idx_viewers ((CAST(payload->'$.viewer_ids' AS UNSIGNED ARRAY)));
-- Any event whose viewer_ids array contains 42.
SELECT id FROM events
WHERE 42 MEMBER OF (payload->'$.viewer_ids');
-- Contains all of these; JSON_OVERLAPS would mean "any of these".
SELECT id FROM events
WHERE JSON_CONTAINS(payload->'$.viewer_ids', '[42, 77]');Typed extraction with JSON_VALUE
JSON_VALUE is the right tool when you want one scalar with a known SQL type: RETURNING removes the CAST, and ON EMPTY / ON ERROR let you decide per column whether a missing key is a default, a NULL, or a hard failure. Writing ON ERROR before ON EMPTY is deprecated and raises a warning, so keep them in that order.
SELECT
id,
-- Without RETURNING you get VARCHAR(512), so comparisons are textual
-- and long values are cut short.
JSON_VALUE(payload, '$.amount') AS amount_text,
JSON_VALUE(payload, '$.amount'
RETURNING DECIMAL(12,2)
DEFAULT 0 ON EMPTY
ERROR ON ERROR) AS amount_num,
JSON_VALUE(payload, '$.occurred_at' RETURNING DATETIME NULL ON ERROR) AS occurred_at
FROM events
WHERE type = 'payment';
Discussion