A JSON column is flexible, and flexibility has a price. This lesson helps you decide where JSON belongs in a relational design.
What a normal column gives you that JSON does not
| Feature | Normal column | Value inside JSON |
|---|---|---|
Type enforcement (INT, DATE, DECIMAL) | Yes | Only with a JSON Schema |
NOT NULL, DEFAULT, CHECK | Yes | Only by schema or expression |
| Foreign key | Yes | No |
| Plain index and fast range scan | Yes | Via generated column or functional index |
| Statistics for the optimizer (cardinality, histograms) | Yes | Limited |
Simple SQL (WHERE price > 10) | Yes | Needs path expressions |
| Storage efficiency | Compact | Keys repeated in every document |
| Updating one value | Rewrites one column | Rewrites (or partially updates) the document |
| Joins and group by | Yes | Via JSON_TABLE or extraction |
What JSON gives you that columns do not
| Feature | Notes |
|---|---|
| Flexible, changing structure | No ALTER TABLE to add an attribute |
| Different shapes per row | A T-shirt has a size, a laptop has RAM, a mug has a capacity |
| Nested and list-like data in one value | Addresses, preferences, a list of tags |
| Storing external documents as received | A webhook payload, an API response |
| Schema-less prototyping | Faster to start |
Compare the same data both ways
Product attributes that differ by product type:
-- Approach A: one wide table with many nullable columns
CREATE TABLE products_wide (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(40) NOT NULL,
size VARCHAR(5) NULL,
color VARCHAR(20) NULL,
capacity_ml INT NULL,
ram_gb INT NULL,
storage_gb INT NULL
);
-- Approach B: stable columns, flexible attributes in JSON
CREATE TABLE products_json (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(40) NOT NULL,
category VARCHAR(20) NOT NULL,
price DECIMAL(8,2) NOT NULL,
attributes JSON NOT NULL DEFAULT (JSON_OBJECT())
);
INSERT INTO products_json (name, category, price, attributes) VALUES
('T-shirt', 'clothing', 499, '{"size": "M", "color": "blue"}'),
('Mug', 'kitchen', 199, '{"capacity_ml": 350, "color": "red"}'),
('Laptop', 'computers', 54999, '{"ram_gb": 16, "storage_gb": 512}');
SELECT name, price, attributes->>'$.color' AS color FROM products_json ORDER BY price;
+---------+----------+-------+
| name | price | color |
+---------+----------+-------+
| Mug | 199.00 | red |
| T-shirt | 499.00 | blue |
| Laptop | 54999.00 | NULL |
+---------+----------+-------+
3 rows in set
Approach A grows a new nullable column for every kind of product, and most cells are NULL. Approach B keeps what every product has (name, category, price) in real columns, and puts the varying details in JSON.
The hybrid rule
> Columns for what you query, join, sort, count and constrain. JSON for the rest.
Facts that every row has and that your queries use (price, category, status, created_at, foreign keys) belong in columns. Descriptive, optional, variable details (attributes, preferences, raw_payload) can go in JSON.
Good uses of JSON
- User preferences and settings (
{"theme": "dark", "emails": false}): loaded as a whole, rarely queried. - Product attributes that differ by type, filtered by a few indexed paths.
- Raw payloads from webhooks and APIs, kept for audit and replay:
payload JSON. - Event logs with a type-specific body.
- Snapshots: the exact order data at checkout time, kept as it was.
- Form builders and CMS blocks with user-defined fields.
- API output: building responses with
JSON_OBJECTandJSON_ARRAYAGG.
Poor uses of JSON
- Core business data you filter on in most queries (customer, order total, status).
- Anything you join on. A list of ids in a JSON array is not a foreign key. Use a junction table (Many-to-Many).
- Data you aggregate in reports every day.
- Data with strict relationships and integrity needs.
- “We might need it someday” structure. Model what you know.
- A huge document that is updated often by many users (every update rewrites a big value, and concurrent edits clash).
The migration path
It is easy to start with JSON and promote a value to a column when it becomes important:
ALTER TABLE products_json ADD COLUMN color VARCHAR(20) GENERATED ALWAYS AS (attributes->>'$.color') STORED, ADD INDEX idx_color (color);
SELECT name, color FROM products_json WHERE color IS NOT NULL ORDER BY name;
+---------+-------+
| name | color |
+---------+-------+
| Mug | red |
| T-shirt | blue |
+---------+-------+
2 rows in set
A STORED generated column keeps the value as a real, indexable column derived from the JSON. Later you can turn it into a plain column, fill it from the JSON, and remove the key from the document.
Performance notes
- Reading a value inside a JSON document is fast (the binary format allows direct access), but slower than a plain column. Reading a whole big document is slower than reading a few columns.
- Large documents (over a few KB) are stored outside the row page, like
TEXT. Reading them costs an extra lookup. - Writes to a JSON column with an index on a path update the index entries.
- Storage: keys are repeated in every row. 10 million rows with a 20-character key repeated in each is 200 MB for the keys alone.
A decision guide
Ask these questions in order:
- Do I filter, sort, group or join on it in common queries? -> Column.
- Does it need a foreign key, a unique rule or a strict type? -> Column.
- Does the structure vary from row to row, or change often? -> JSON is a good fit.
- Is it read and written as a whole and rarely queried by its parts? -> JSON.
- Not sure? Start with columns for what you know, and one JSON column for the variable rest.
Document databases
If most of your data is flexible documents with nested structures, a document database (MongoDB) may fit better than forcing MySQL into the role. If most of your data is structured and relational with a few flexible parts, MySQL with a JSON column is excellent.
Promote what you query
WHERE or ORDER BY clauses, move it to a column (or a stored generated column). Your queries, indexes and constraints will get simpler.