Choosing MySQL 8.0 JSON Columns vs TEXT vs Relational Tables for Semi-Structured Data
Compare MySQL 8.0 native JSON columns, TEXT storage, and relational tables for semi-structured data. Includes a concrete validation example with generated-column indexes and JSON_TABLE for analytics.
08 Jun 2026, 02:45 UTC

The Decision: Where to Put Semi-Structured Data in MySQL 8.0
You have incoming data that doesn't fit a fixed schema—API payloads, event logs, user preferences that vary by tenant. MySQL 8.0 gives you three viable storage approaches: native JSON columns, TEXT columns holding JSON strings, or normalizing into relational tables. Each has distinct costs for validation, indexing, storage, and query flexibility. This guide compares them so you can pick the right one for your workload.
Constraints That Shape the Choice
- MySQL version: Native JSON indexing and generated columns require 8.0.13+. Older 8.0 releases fall back to full-text search on TEXT or manual parsing.
- Payload size: JSON columns add metadata overhead (~1 KB per document). Documents over 16 MB hit the
max_allowed_packetlimit unless configured higher. - Query patterns: Frequent key lookups favor JSON with generated-column indexes. Ad-hoc analytics favor
JSON_TABLEor relational normalization. - Write throughput: JSON validation on insert adds CPU cost. High-volume ingest may prefer TEXT with deferred validation.
Option Comparison
| Factor | Native JSON Column | TEXT Column (JSON string) | Relational Tables |
|---|---|---|---|
| Insert validation | Automatic (rejects malformed JSON) | None (application must validate) | Schema-enforced (strict types) |
| Key lookup performance | Generated column + B-tree index (8.0.13+) | Full-text index or custom function | Standard B-tree index on column |
| Storage overhead | ~1 KB metadata per row + binary encoding | Raw UTF-8 bytes only | Minimal (fixed-width types) |
| Schema evolution | Flexible (add keys anytime) | Flexible (no enforcement) | Requires ALTER TABLE |
| Analytic queries | JSON_TABLE (8.0.4+) for relational projection | Parse in application or UDF | Native SQL, fastest aggregation |
| Partial updates | JSON_SET, JSON_REMOVE (in-place) | Full document rewrite | Row-level column updates |
Trade-offs in Practice
When JSON Columns Win
Use native JSON when you need validated storage with indexed key access and the schema varies per row. The binary format enables JSON_EXTRACT without full parsing, and generated columns let you index specific paths:
CREATE TABLE events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
payload JSON NOT NULL,
event_type VARCHAR(50) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(payload, '$.type'))) STORED,
INDEX idx_event_type (event_type)
) ENGINE=InnoDB;
The generated column event_type materializes the $.type path at write time. Reads use a standard B-tree index. This works on MySQL 8.0.13+; on earlier versions you'd need a trigger-maintained column.
When TEXT Is Acceptable
Choose TEXT when write throughput dominates and you can validate in the application layer. You avoid JSON's parse-on-insert cost and metadata overhead. The downside: every key lookup requires a full-table scan or a full-text index (which matches tokens, not exact key paths). Example full-text approach:
CREATE TABLE raw_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
payload TEXT NOT NULL,
FULLTEXT idx_payload (payload)
) ENGINE=InnoDB;
Querying MATCH(payload) AGAINST('"type":"click"' IN BOOLEAN MODE) finds rows containing that token, but cannot distinguish $.type from a nested $.meta.type.
When Relational Tables Win
Normalize when the schema is stable and query patterns are relational—joins, aggregations, foreign keys. Storage is smallest and query plans are most predictable. The cost is ALTER TABLE for new fields and potential sparse columns for optional attributes.
Concrete Validation: JSON Column with Generated Index
Run this sequence on a MySQL 8.0.13+ instance (requires CREATE TABLE, INSERT, INDEX privileges) to verify the indexing benefit.
- Create the table with a JSON column and generated index:
CREATE TABLE orders (
order_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
data JSON NOT NULL,
customer_email VARCHAR(255) GENERATED ALWAYS AS (
JSON_UNQUOTE(JSON_EXTRACT(data, '$.customer.email'))
) STORED,
INDEX idx_customer_email (customer_email)
) ENGINE=InnoDB;
- Insert test rows (run from any client with insert permission):
INSERT INTO orders (data) VALUES
('{"customer": {"email": "[contact removed]"}, "total": 120.50}'),
('{"customer": {"email": "[contact removed]"}, "total": 89.00}'),
('{"customer": {"email": "[contact removed]"}, "total": 45.00}');
- Verify the generated column populates correctly:
SELECT order_id, customer_email FROM orders WHERE customer_email = '[contact removed]';
Expected: two rows with order_ids 1 and 3.
- Check the query plan uses the index:
EXPLAIN SELECT order_id FROM orders WHERE customer_email = '[contact removed]';
-- Look for "ref" access type and "idx_customer_email" in key column
- Compare against TEXT + full-text (optional benchmark):
CREATE TABLE orders_text (
order_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
data TEXT NOT NULL,
FULLTEXT idx_data (data)
) ENGINE=InnoDB;
INSERT INTO orders_text (data) SELECT data FROM orders;
-- Full-text search (token-based, not path-aware)
EXPLAIN SELECT order_id FROM orders_text
WHERE MATCH(data) AGAINST('+[contact removed]' IN BOOLEAN MODE);
Observe the access type: JSON path index uses ref (index lookup), full-text uses fulltext (different cost model). For exact key equality, the generated-column index is typically faster and more precise.
Using JSON_TABLE for Analytics
When you need to join JSON data with relational tables or run aggregations, JSON_TABLE (8.0.4+) projects JSON into a virtual relational result set:
SELECT o.order_id, jt.item_sku, jt.qty, jt.price
FROM orders o,
JSON_TABLE(o.data, '$.items[*]' COLUMNS (
item_sku VARCHAR(50) PATH '$.sku',
qty INT PATH '$.quantity',
price DECIMAL(10,2) PATH '$.unit_price'
)) AS jt
WHERE jt.qty > 5;
This avoids materialized views. The optimizer can push predicates into the JSON_TABLE operation. Verify with EXPLAIN FORMAT=TREE to see the JSON_TABLE node.
Limitations and Verification Checklist
- Metadata overhead: For millions of small JSON documents (< 500 bytes), the ~1 KB per-row overhead can double table size. Monitor with
SHOW TABLE STATUSand compareData_lengthagainst a TEXT equivalent. - Version gate: Generated-column indexes and native JSON indexes require 8.0.13+. Run
SELECT VERSION();to confirm. - Max document size:
max_allowed_packet(default 64 MB) limits single JSON document size. Increase inmy.cnfif needed; restart required. - Validation cost: Insert benchmarks should compare JSON vs TEXT throughput at your payload size. Use
sysbenchor a custom script withINSERT ... VALUESbatches. - Index maintenance: Generated columns are updated on every write. High-update workloads on indexed paths add write amplification.
Decision Summary
| Choose | If |
|---|---|
| Native JSON | You need validated storage, indexed key lookups, and partial updates on 8.0.13+ |
| TEXT | Write volume is extreme, validation happens upstream, and key lookups are rare or token-based |
| Relational | Schema is stable, queries are relational (joins, aggregates), and you want minimal storage |
Start with the JSON column if you're on 8.0.13+ and the schema varies. Add generated-column indexes for the two or three most-queried paths. Use JSON_TABLE for analytic queries. Revisit TEXT only if insert latency or storage measurements show a problem.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.