A settings JSON column added in 2019 for “flexible per-customer configuration”, containing forty keys three years later, four of which appear in WHERE clauses. The flexibility had done its job and had quietly become a schema that the database could not help with.
The symptom
$ mysql -e "EXPLAIN SELECT id FROM customers
WHERE settings->>'$.tier' = 'enterprise'G"
type: ALL
possible_keys: NULL
rows: 2088104
Extra: Using where
$ mysql -e "SELECT JSON_LENGTH(settings) n FROM customers LIMIT 1"
40
$ ./bin/json-key-frequency settings
tier in WHERE: 41,208 queries/day
region in WHERE: 12,884
billing_day in WHERE: 4,102
feature_flags in WHERE: 902
... 36 keys, never filtered onFour keys out of forty carry every filter. That distribution is the whole finding: the column is not being used as a document, it is being used as four columns and thirty-six pieces of loosely structured data.
Why it happens
A JSON column is the right choice for data whose shape is genuinely unknown, and every key added to it is a small bet that nothing will need to filter on it. Some of those bets lose, and there is no moment at which the loss becomes visible.
The fix
Deciding by query log rather than by opinion
the four promoted, and why:
tier 41,208 filters/day, 6 distinct values.
→ an ENUM column.
region 12,884/day, 4 values, and a foreign key
candidate.
→ a CHAR(2) with a constraint.
billing_day 4,102/day, integer 1-28.
→ TINYINT.
feature_flags 902/day, but an ARRAY — filtered with
"does it contain X".
→ stays JSON, with a multi-valued index.
the 36 that stayed: read as a block, never filtered.Generated columns, for the one that stays JSON
-- a stored generated column, indexed
ALTER TABLE customers
ADD COLUMN tier_g VARCHAR(32)
GENERATED ALWAYS AS (settings->>'$.tier') STORED,
ADD INDEX idx_tier_g (tier_g);
-- STORED, not VIRTUAL: a virtual generated column can be
-- indexed too, and the index is maintained on write
-- either way. STORED costs disk and makes the read cheap.
-- and the multi-valued index for the array
ALTER TABLE customers ADD INDEX idx_flags (
(CAST(settings->'$.feature_flags' AS CHAR(32) ARRAY))
);
The generated column was the interim step — it made the query fast in one migration without touching any application code, which bought time for the proper promotion. The multi-valued index is the permanent answer for the flags, because that key genuinely is a set and does not want to be a column.
The promotion, with a dual-write window
release 1 add the columns, nullable. write BOTH the
column and the JSON key. read JSON.
backfill batched, 50k at a time, from the JSON.
release 2 read the column. still write both.
release 3 stop writing the JSON key. add NOT NULL.
cleanup remove the key from existing rows:
UPDATE customers SET settings =
JSON_REMOVE(settings, '$.tier')
— batched, and last.
four releases for one field. three fields at once, so
four releases total rather than twelve.// the dual-write, and the assertion that they agree
public function setTier(Tier $tier): void
{
$this->tier = $tier->value; // column
$this->settings = [...$this->settings, 'tier' => $tier->value];
}
// a nightly job during the window
$mismatched = Customer::whereRaw("settings->>'$.tier' <> tier")->count();
// alerted if > 0. it was 0 throughout except for one
// day, caused by a bulk update that bypassed the model.
The nightly agreement check is what makes a dual-write window safe, and it caught exactly the failure it exists for — a bulk UPDATE written as raw SQL that touched the JSON and not the column. Without the check that divergence would have been discovered at cutover.
A CHECK constraint on what remains
ALTER TABLE customers ADD CONSTRAINT chk_settings CHECK (
JSON_SCHEMA_VALID(
'{"type":"object",
"required":["notifications","locale"],
"properties":{
"locale":{"type":"string","pattern":"^[a-z]{2}_[A-Z]{2}$"},
"feature_flags":{"type":"array","items":{"type":"string"}}
},
"additionalProperties":true}',
settings
)
);
additionalProperties: true keeps the flexibility that made a JSON column right in the first place while constraining the keys that have acquired meaning. Turning it to false would make this a schema in a text column, at which point the columns should just be columns.
Verifying it worked
$ mysql -e "EXPLAIN SELECT id FROM customers
WHERE tier = 'enterprise'G"
type: ref
key: idx_tier
rows: 412
# the endpoint that filters by tier
p50 410ms → 8ms
p95 2,140ms → 22ms
$ mysql -e "SELECT AVG(JSON_LENGTH(settings)) FROM customers"
36.0 # was 40
$ mysql -e "SELECT COUNT(*) FROM customers
WHERE JSON_CONTAINS_PATH(settings,'one','$.tier')"
0 # the cleanup completedFour hundred and twelve rows examined instead of two million is the whole result, and the zero at the end is the assertion that the migration actually finished — a cleanup pass that stops halfway leaves the column and the key both present, which is the state that produces a divergence a year later.
What this costs
Two places a field can live, and a rule about which. The rule is that a key filtered on becomes a column, and applying it requires somebody to be watching the query log — which nobody does until an endpoint is slow.
The CHECK constraint is also a schema definition in a string literal inside a migration, which no tooling validates and no IDE understands. Changing it means a new migration with the whole schema restated, and there is no diff that shows what changed except a careful read of two large strings.