MySQL JSON works best for optional or evolving attributes that belong to one row and are not central to joins, constraints, or frequent filters. Stable business keys, money, status, ownership, timestamps, and foreign-key relationships usually belong in typed columns. MySQL 8.4 stores validated JSON in a binary format and can index extracted values, but a JSON document is not directly a normal B-tree key. Performance depends on making hot paths explicit and measuring the resulting plan.
Choose JSON or Relational Columns Deliberately
| Requirement | Prefer JSON | Prefer typed columns or child tables |
|---|---|---|
| Shape | Sparse, optional attributes that vary by product or event type | Stable fields shared by most rows |
| Access | Usually retrieved with the parent row | Frequently filtered, sorted, joined, grouped, or updated |
| Integrity | Validation can be enforced by the application or a JSON schema check | Requires foreign keys, unique constraints, exact SQL types, or simple check constraints |
| Cardinality | Small bounded metadata object | Unbounded arrays or repeating entities that need independent queries |
| Change rate | Mostly written and read as one document | Hot fields updated independently at high frequency |
A hybrid model is normal: keep identity, tenancy, lifecycle state, and common predicates as columns; keep a bounded extension object in JSON. Document allowed keys, units, null semantics, and size limits even if the database accepts a wider shape.
Build a Typed, Indexable Contract
The safest MySQL 8.4 pattern for a frequently queried JSON scalar is a generated column with an intentional SQL type, then an ordinary index. Querying that generated column directly makes the application contract visible and avoids small expression differences that can prevent optimizer substitution.
CREATE TABLE catalog_item (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
attributes JSON NOT NULL,
color VARCHAR(32)
GENERATED ALWAYS AS (
JSON_VALUE(attributes, '$.color' RETURNING CHAR(32)
NULL ON EMPTY NULL ON ERROR)
) STORED,
weight_kg DECIMAL(10,3)
GENERATED ALWAYS AS (
JSON_VALUE(attributes, '$.weight_kg' RETURNING DECIMAL(10,3)
NULL ON EMPTY NULL ON ERROR)
) STORED,
PRIMARY KEY (id),
INDEX ix_catalog_tenant_color (tenant_id, color),
INDEX ix_catalog_tenant_weight (tenant_id, weight_kg)
);Decide whether missing, JSON null, malformed business values, or overlong strings should become SQL NULL or reject the write. The example returns NULL so it is suitable only when the application monitors rejected semantics separately. For required fields, validate before insert or use an appropriate constraint and migration strategy. Generated indexes add storage and write work, so create them only for measured access patterns.
MySQL also supports functional key parts. With JSON_VALUE(), the indexed and queried expression can be explicit:
CREATE INDEX ix_catalog_sku
ON catalog_item ((JSON_VALUE(attributes, '$.sku' RETURNING CHAR(64))));
EXPLAIN ANALYZE
SELECT id
FROM catalog_item
WHERE JSON_VALUE(attributes, '$.sku' RETURNING CHAR(64)) = 'SKU-1042';The query expression must match the indexed expression closely, including path, return type, length, and relevant collation. EXPLAIN ANALYZE executes the query, so use a read-only statement and a safe environment or bounded predicate. Check the selected key, actual rows, loops, and elapsed work after loading production-shaped data. An index existing in the schema is not proof that it is selective or chosen.
Use JSON_TABLE for Bounded Expansion
JSON_TABLE() turns an array into relational rows for a query. It is useful for controlled extraction and migration, but repeatedly expanding large arrays across many rows can be expensive and does not provide the integrity of a child table.
SELECT i.id, tag_row.tag
FROM catalog_item AS i
JOIN JSON_TABLE(
i.attributes,
'$.tags[*]' COLUMNS (
tag VARCHAR(64) PATH '$' NULL ON ERROR
)
) AS tag_row
WHERE i.tenant_id = 42
AND tag_row.tag = 'fragile';If tags need independent uniqueness, ownership, joins, counts, or frequent lookup, model catalog_item_tag(item_id, tag) instead. MySQL multi-valued indexes can accelerate supported predicates on JSON arrays, but they have specific syntax and limitations; choose them only after validating the exact operator and plan in the MySQL 8.4 reference.
Understand Partial JSON Updates
It is inaccurate to say every single-path update always rewrites the entire document. InnoDB can perform a partial in-place update when a column declared as JSON is updated with JSON_SET(), JSON_REPLACE(), or JSON_REMOVE() and all documented conditions are satisfied. The input and target must be the same column; replacements must target existing values; and a replacement normally cannot require more space unless previous partial updates left enough room. A full assignment or an ineligible change falls back to a full-document update.
UPDATE catalog_item
SET attributes = JSON_SET(attributes, '$.color', 'navy')
WHERE id = 1042;
SELECT JSON_STORAGE_SIZE(attributes) AS stored_bytes,
JSON_STORAGE_FREE(attributes) AS freed_bytes
FROM catalog_item
WHERE id = 1042;Do not make correctness depend on the optimization. Measure redo, binary-log volume, latency, page changes, and replica behavior with representative document sizes. Compact binary-log encoding for partial JSON is a separate configuration decision and must be tested with replicas, PITR, CDC consumers, and recovery tooling.
Profile Before Changing the Schema
- Inventory path presence and types with bounded queries. Treat strings containing numbers, numeric JSON values, missing paths, and JSON
nullas different cases. - Capture top statement digests and actual plans. Identify predicates, joins, ordering, update frequency, rows examined, and result cardinality.
- Create a production-shaped test set with the real document-size and key-frequency distribution. Remove unsupported latency anecdotes.
- Add one generated or functional index at a time, run
ANALYZE TABLEwhere appropriate, and compare write cost as well as read latency. - Set a rollback condition for DDL duration, replication lag, storage growth, or plan regression. Online DDL capability depends on the exact operation, table, and release; test it rather than assuming an algorithm.
The MySQL JSON operators and generated-index guide covers extraction syntax. Keep this page focused on the decision between flexible storage and a durable relational contract.
Migrate Hot Paths to Typed Columns Safely
Use expand-and-contract instead of one unlimited update. First add nullable typed columns without changing readers. Deploy dual-write logic that writes both representations in one transaction. Backfill by stable primary-key ranges, with small commits, observable progress, and replication-lag throttling:
UPDATE catalog_item
SET color_typed = JSON_VALUE(
attributes, '$.color' RETURNING CHAR(32)
NULL ON EMPTY NULL ON ERROR
)
WHERE id > ? AND id <= ?
AND color_typed IS NULL;Compare both representations, including null and type edge cases. Add the new index only after data is clean, switch reads behind a reversible release, and monitor plans and mismatches. Stop writing the JSON path after every writer is upgraded; remove old data only after the rollback window and backup retention policy permit it. Never use UPDATE ... LIMIT without deterministic key ranges as the only progress mechanism. The MySQL expand-and-contract migration guide provides the broader rollout pattern.
Official Primary Documentation
- MySQL 8.4 JSON data type and partial updates
- MySQL 8.4 JSON search functions and JSON_VALUE indexes
- MySQL 8.4 optimizer use of generated-column indexes
- MySQL 8.4 JSON_TABLE function
- MySQL 8.4 multi-valued indexes
Schema Decision Summary
- Keep stable keys, constraints, joins, and hot predicates in typed columns.
- Index an extracted scalar with an intentional SQL type and verify the actual plan.
- Treat partial in-place updates as a conditional optimization, not a guarantee.
- Normalize arrays that have independent relationships or frequent lookup needs.
- Migrate with dual writes, deterministic batches, reconciliation, and a rollback window.