MY.L4.EXPAND_WITHOUT_CONTRACT — A column was added and back-filled, and the one it replaces never dropped
- Category: safety
- Level: 4
- Confidence: heuristic
- Downtime class:
online - Stability: stable
- Suites: lint
- Applies to: MySQL 8.4
This rule is not telling you that you did something wrong. Adding a column and filling it from the data already in the table is the expand step of an expand/contract deploy, and it is the correct way to change a column without a maintenance window: for a while both columns are written, so the old code and the new code can run side by side.
It is also only half of the pattern, and this rule is the account of the other half.
What an unfinished expand leaves behind
Two things are true from the moment the back-fill returns:
- Every reader that knows the new column is correct.
- The old column stops being maintained the moment the last deploy moves over — and it is still there, still readable, still joinable, holding whatever it held at that moment.
Nothing breaks, nothing is slow, and no error is raised. That is precisely why the contract step is the one that gets forgotten: there is no symptom to notice. What is left is a table where two columns hold one fact, one of them quietly stale, and nothing in the schema says which is authoritative. The next person to write a query picks one — and has no way to know which.
The second half
ALTER TABLE orders DROP COLUMN total;
It is a catalog operation, not a rewrite; the space comes back on a later rewrite. The reason it belongs in its own migration is not cost, it is ordering: it is only safe once every deployed reader has stopped using the old column, and that is a fact about your deploy, not about your schema.
Not transferable to MariaDB
The rule is registered for MySQL 8.4, and SQLens refuses a MariaDB connection outright rather than
applying MySQL-shaped reasoning to another product. That the concern here is engine-neutral — an
expand/contract deploy is a deployment pattern, not a MySQL feature — does not make the connection
supported. The grammar decision above is not neutral: which ALTER TABLE forms exist, and which
of them create a second column, is exactly the kind of thing the two products can differ on.
Flagged
// Migration 1 — expand, and back-fill from the column being replaced:
DB::statement('ALTER TABLE orders ADD COLUMN total_cents bigint');
DB::statement('UPDATE orders SET total_cents = total * 100');
// …and no migration ever drops `total`. The table now carries both, and the older one
// stops being written the moment the last deploy moves over — silently.
Preferred
// Migration 1 — expand and back-fill, unchanged. This half was always right:
DB::statement('ALTER TABLE orders ADD COLUMN total_cents bigint');
DB::statement('UPDATE orders SET total_cents = total * 100');
// Migration 2, AFTER every deployed reader uses the new column — contract:
DB::statement('ALTER TABLE orders DROP COLUMN total');
What makes this a finding, and what does not
The back-fill is the evidence. "The column this one replaces" is not a fact a linter can read — it is a decision, and it does not appear in the statement being judged. So the rule does not guess it. It asks for something that IS in the SQL: a column was added, and this same run wrote to it using the rows that were already there.
A column added for genuinely new data is never written from existing rows, so it is not reported. That is the whole reason this rule is narrow enough to leave switched on.
Two consequences follow, and both are deliberate:
- A back-fill in a queued job is invisible. If the data move happens outside the migration, this rule sees an ordinary column addition and says nothing. It would rather stay silent than guess which additions were replacements.
- The replaced column is never named. It sits inside the expression on the right of the assignment, and reading it out would mean parsing expressions in the layer that exists to be free of grammar. The finding names the column that was added, and the table it sits on.
Which ALTER TABLE forms count, and which cannot
MySQL offers four ways to change a column, and only two of them can leave this debt behind:
| Form | Role |
|---|---|
ALTER TABLE t ADD COLUMN c … | expand |
ALTER TABLE t DROP COLUMN c | contract |
ALTER TABLE t CHANGE old new … | neither |
ALTER TABLE t MODIFY c … | neither |
ALTER TABLE t RENAME COLUMN a TO b | neither |
The last three are in-place changes, and that is why they are out of scope rather than merely
unhandled: none of them makes a second column exist, so there is never a moment when two columns
hold one fact and nothing can be left half finished. CHANGE renames and retypes in one statement,
MODIFY retypes, RENAME COLUMN renames. Their hazard is a different one — a table rebuild, or a
value that no longer fits — and it belongs to the rules that judge that.
Why the whole run, and what that costs on the fast path
The contract step belongs in a later migration — that separation is the pattern, not a mistake. So the question is asked of every migration in the run, and a run that drops a column from the same table answers it: no finding.
On the single-file fast path (sqlens:lint --file=…) the run is one file, so a contract step
elsewhere is out of view and a finished sequence can read as unfinished. That is the fast path's
standing limit — it sees what it was given — and it is why this rule reports heuristic confidence
rather than certainty.
Why it is level 4 and carries no security severity
Level 4 is the backward-compatibility band — the axis on which a table holding two columns for one fact genuinely is a gap. There is no security question here at all, so the rule declares no security severity and cannot breach that gate.
What makes one open end more urgent than another is its age, and age is not a property of a migration. It is what the debt account adds later, from the ledger.
Sources
- MySQL 8.4 —
ALTER TABLE—ADD COLUMNandDROP COLUMNare the only two forms that make a second column exist, and many of these operations run withALGORITHM=INPLACE, which is what makes the expand half cheap enough to leave standing while readers migrate — and also why nothing forces the contract half to happen
The fix material this rule carries
A finding from this rule carries machine-readable fix material, using this sequence:
expand_contract— Add the new shape, move readers and writers across, remove the old one — over three deploys.
The payload is material for you or an agent to apply. SQLens writes no migration and runs no DDL. See the remediation payload for every field, the placeholder semantics, and the version rules a consumer has to follow.