Skip to content
System nominalUTC --:--:--Articles 080
MODULE 05 Lesson 14 / 18 35 MIN

Schema evolution

Adding and Dropping Columns Without an Outage

What each ALTER TABLE really costs on MySQL: instant metadata changes, index rebuilds, full table copies, and the metadata lock that stalls even the cheap ones.

A deploy runs a migration, the migration runs an ALTER TABLE, and every request that touches that table waits until it finishes. Nothing is broken. The surprise is that "add a column" and "change a column" are not the same kind of operation, and the cheap one is not free either.

The numbers below were measured on MySQL 9.6.0 against tables of 1,000 to 64,000 rows, with a fresh copy of the table for every run and the median of five to ten runs reported. Read them as ratios; the shape is what transfers to your hardware, and the shape is the point.

Three algorithms, and only one leaves the rows alone

MySQL implements ALTER TABLE in three ways, and you can ask for one with ALGORITHM=. INSTANT changes only the data dictionary. INPLACE rebuilds the table's contents in place, so its indexes are rewritten but no second copy of the table exists. COPY builds a new table, copies every row and swaps it in, and it is the only algorithm available when a row's layout on the page has to change.

operation                1k rows   4k rows  16k rows  64k rows
ADD COLUMN INSTANT        4.4 ms    2.5 ms    2.5 ms    2.9 ms
ADD COLUMN COPY           7.6 ms   12.9 ms   36.2 ms  104.0 ms
ADD INDEX INPLACE        14.8 ms   20.2 ms   33.9 ms   86.6 ms

Read the first row and the second row against each other. The instant add is the same cost at 64,000 rows as at 1,000, because it did not touch a row. The copy grows with the data, which is why an ALTER TABLE that takes a second on a staging database takes twenty minutes on the table that matters. An index build sits between them: it reads every row but does not write a new copy of the table.

You can make the server tell you which algorithm it chose, which is useful when a statement has changed behaviour between versions. Ask for the cheaper algorithm and read the error:

ALTER TABLE bench MODIFY email VARCHAR(64) NOT NULL, ALGORITHM=INPLACE, LOCK=NONE;
ERROR 1846 (0A000): ALGORITHM=INPLACE is not supported.
  Reason: Cannot change column type INPLACE. Try ALGORITHM=COPY.

That message is the server saying it cannot do this without rewriting every row. Run the same statement without the ALGORITHM clause and it succeeds, having silently done the copy.

Adding a column with a default is cheap, and it was not always

One consequence follows directly: the default value is not stored in each row, which is why the measurement above is flat, and why dropping the same column again is instant. A table that has taken several instant changes carries several row versions, and InnoDB's manual documents a ceiling of 64 row versions per row before the table has to be rebuilt. On the server used here, information_schema.INNODB_TABLES reported TOTAL_ROW_VERSIONS climbing by one per instant add, reaching 70 across 70 changes without an error, and returning to 0 after ALTER TABLE ... FORCE. The counter is worth watching, but the documented limit is not a wall you hit without warning:

SELECT INSTANT_COLS, TOTAL_ROW_VERSIONS
  FROM information_schema.INNODB_TABLES
 WHERE NAME = 'mydb/orders';

The "cheap" was also not always true. Before MySQL 8.0 an ADD COLUMN in any position other than the end of the table rebuilt it, which is what the ALGORITHM=COPY row above reproduces. If your experience of adding a column is that it locks a table for minutes, that experience is from an engine that behaved differently.

PostgreSQL reaches the same place by a different route. Its manual states that an ALTER TABLE subform acquires an ACCESS EXCLUSIVE lock unless the documentation says otherwise, and that a column added with a non-volatile DEFAULT has the value "stored in the table's metadata, where it will be returned when any existing rows are accessed". The lock is stronger than MySQL's metadata lock, so the statement is fast but the window in which it blocks readers is real. No PostgreSQL server was available on the machine this lesson was written on, so that claim is taken from the manual rather than measured here.

Adding a NOT NULL column to a populated table

This is the case where the obvious expectation is wrong in the helpful direction, and the real problem is quieter. MySQL does not refuse to add a NOT NULL column with no default to a populated table. It picks the implicit default for the type and writes that everywhere:

ALTER TABLE p ADD COLUMN b INT NOT NULL;
SELECT * FROM p;
+----+---+---+
| id | a | b |
+----+---+---+
|  1 | 1 | 0 |
|  2 | 2 | 0 |
+----+---+---+

No warning, no error, and the column is instant. The problem is the value. Every row that existed before the change now claims b = 0, which is indistinguishable from a row where somebody deliberately set zero, and a VARCHAR column gets the empty string for the same reason. If the column means "how many times this was retried", zero is a fine guess. If it means "which plan the customer is on", the schema has invented an answer, and every report that groups by it will be wrong in a way that looks like data.

So the decision is about the placeholder rather than the constraint. Add the column nullable, backfill it with values you can defend, and tighten it later, which is the shape the next lesson builds.

Dropping a column an older deploy still selects

Dropping a column is instant, and it is the operation that causes outages, because the schema is the one interface every deploy shares. While the new version is rolling out, the previous version is still serving traffic and still contains the old column name in its queries. On a table with an is_active column and a new one called status:

old deploy: SELECT id FROM accounts WHERE is_active = 1        ok: 1
contract: DROP COLUMN is_active                                ok:
old deploy after the drop  FAILED: ERROR 1054 (42S22): Unknown column
                           'is_active' in 'where clause'

Nothing in the database is broken. Requests fail because a query names a column that no longer exists. The rule that follows is not "never drop a column" but "drop it only after the last build that reads it is gone", which means the drop belongs in a release after the one that stopped reading it. Many teams keep a dated comment beside the migration recording which release dropped the code, so the second release is a scheduled task rather than an act of memory.

A rename is two changes, not one

RENAME COLUMN old TO new is instant since MySQL 8.0, and it is still the most dangerous statement in this lesson, because it changes the interface atomically. The instant the rename lands, every build that still says email fails:

rename: RENAME COLUMN email TO email_address                 ok
old deploy: SELECT id FROM accounts WHERE email = '...'      FAILED:
  ERROR 1054 (42S22): Unknown column 'email' in 'where clause'

Expand and contract instead. Add the new column, write to both for one release, move reads to the new one, then drop the old one in a later release. With a trigger keeping the two in step, an old build that writes only the old column still populates the new one:

ALTER TABLE accounts ADD COLUMN email_address VARCHAR(255) NULL, ALGORITHM=INSTANT;

CREATE TRIGGER accounts_sync_email
BEFORE INSERT ON accounts
FOR EACH ROW SET NEW.email_address = COALESCE(NEW.email_address, NEW.email);

The trigger needs a BEFORE UPDATE twin, or updates through the old column will leave the new one stale. That is the cost of the pattern: for one release you maintain two representations and a mechanism to keep them equal, and you pay it in exchange for never requiring both builds to agree on a name.

Changing a type may rewrite the table, and the direction decides

Width changes are asymmetric, and the asymmetry is visible in the algorithm the server insists on. On 20,000 rows, widening VARCHAR(255) to VARCHAR(300) measured 2.2 ms, and the same statement with FORCE added to demand a rebuild measured 45.3 ms. The first did not read the rows; the second did. Narrowing VARCHAR(255) to VARCHAR(64) measured 40.7 ms and refused ALGORITHM=INPLACE outright, and changing CHAR(2) to VARCHAR(2) refused it too. Widening past 255 characters, where the length prefix itself has to grow, measured 1.9 ms on this data, which is the version-specific part of this section rather than something to rely on.

Do not turn that into a rule about character counts. The reliable question is whether the server will accept ALGORITHM=INPLACE for the change, and the way to find out is to ask it, on a copy of the table, with the same MySQL version you run in production. A change that refuses in-place is a change that needs a rebuild, and a rebuild needs a plan for the window it takes.

Narrowing also risks truncation: the 40.7 ms measurement above is what happens when the existing values fit. When they do not, MySQL fails the statement and leaves the table alone, which is the outcome you want:

ALTER TABLE nar MODIFY v VARCHAR(16) NOT NULL;
ERROR 1406 (22001): Data too long for column 'v' at row 1   -- column is still varchar(255)

Even an instant change waits for a metadata lock

Before any ALTER TABLE touches the data dictionary it takes an exclusive metadata lock, and it waits for every transaction that has touched the table to finish. The wait is the part that takes a table out of service, and it happens to instant changes too. With one session holding an open transaction that has read a single row:

metadata_locks while the reader's transaction is open:
| LOCK_TYPE   | LOCK_STATUS | n |
| SHARED_READ | GRANTED     | 1 |

ALTER with lock_wait_timeout=2s returned after 2.06s
  server said: ERROR 1205 (HY000): Lock wait timeout exceeded
after the reader commits, the same ALTER: ok

The ALTER was an instant column add. It did not need to read or write one row of the table, and it still could not proceed while a transaction held a shared metadata lock, because granting it would change the table's definition underneath a statement that had already resolved its columns. The isolation lesson covered the mechanism; what is new here is that the DDL side applies to the cheap statements too, so "this migration is instant" is not the same claim as "this migration will not block".

Set lock_wait_timeout on the migration connection so a stuck deploy fails loudly instead of queueing behind a long report. The wait above was charged against that variable, not innodb_lock_wait_timeout, which is the one people reach for first. And keep the statements that read schema_migrations in their own short transaction, so the runner's bookkeeping is not what a later ALTER waits for.

Lab 30 minutes

Create a table with 20,000 rows and a VARCHAR(255) column. Then measure these four statements on a fresh copy of the table each time, using the server's own clock rather than your shell's:

SET @t0 = NOW(6);
ALTER TABLE bench ADD COLUMN note VARCHAR(40) NULL, ALGORITHM=INSTANT;
SELECT TIMESTAMPDIFF(MICROSECOND, @t0, NOW(6)) / 1000 AS ms;

Repeat for the same add with ALGORITHM=COPY, for MODIFY varchar_column VARCHAR(64), and for that same narrowing with ALGORITHM=INPLACE, LOCK=NONE. Record which one the server refuses and which one costs a full pass over the rows.

Then open a second session, run BEGIN; SELECT id FROM bench WHERE id = 1; and leave it open. Run an instant ADD COLUMN in the first session with SET SESSION lock_wait_timeout = 3 and time how long it waits. Commit the second session and run it again.

Show the solution and the reasoning

The instant add and the copy are distinguishable by how they scale, not by their absolute time on a small table. Run the lab at 20,000 rows and at 80,000 and compare: the instant add stays within a millisecond or two, the copy roughly quadruples. If your two measurements look similar, the table is too small for the difference to show, which is itself the lesson about testing an ALTER on a staging database.

The narrowing with ALGORITHM=INPLACE, LOCK=NONE is the statement that fails, with ERROR 1846 ... Cannot change column type INPLACE. The failure is the useful result: it tells you the change needs a copy before you run it against production, and it is a much better discovery than watching a deploy stall. Removing the ALGORITHM clause does not make the statement inconsistent with what you verified; it only means the server picks the copy for you.

The metadata lock experiment waits for the full lock_wait_timeout, then fails with ERROR 1205, then succeeds once the reader commits. Two details matter. The wait is charged against lock_wait_timeout, not innodb_lock_wait_timeout, because this is a metadata lock rather than a row lock. And a plain SELECT, which takes no row lock at all, is enough to block the DDL, because it holds a shared metadata lock for the life of its transaction.

To see the queue rather than just the timeout, run the reader in one session, the ALTER in a second, and query performance_schema.metadata_locks from a third. The alter's request sits in PENDING while the reader holds GRANTED, and every query that arrives afterwards queues behind it. That is how one slow reader becomes a table-wide outage.

Official reference

The MySQL manual and the PostgreSQL and SQLite documentation for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.