Migration

Method

Moving a storage structure or the shape of stored values to a new form while the data already collected stays alive, carried out alongside a code deploy.

Why it exists

Deploying code replaces the old version; data doesn't work that way. The moment a new build goes up, the previous code leaves the server, while a million rows written in the previous structure stay exactly where they were. That asymmetry is what makes every change to a database awkward.

This used to be handled by hand. On deploy day someone connected to the production database and typed the command that added the column, and that command was recorded nowhere. Each developer's local database drifted into its own state, and a new hire who wanted to know which tables existed and what columns they held had to go ask somebody.

Pagedeck lived through this. Theo added a column for sharing scope to the documents table, applied it to production, and skipped staging. A week later someone running tests on staging spent two days looking through the code before discovering that the two structures differed.

Migrations turn the change from a keystroke into a record. The command that alters the structure is written to a file, the file lives in the same repository as the code, and running those files in order against any environment produces the same structure. The shape of the database is now written down rather than remembered.

How to run it

Even adding one column goes through the procedure, because the work has to face both the rows already stored and the code currently running. There are six steps.

  1. Write the change file. One file holds one change and its name is prefixed with a timestamp. The database keeps its own list of which files have run, so the same file never executes twice.
  2. Write the reversing command into the same file. A change that adds a column pairs with one that drops it. If only the forward path is recorded, anything that goes wrong has to be cleaned up by hand.
  3. Run it locally and on staging first. What you're watching for here isn't whether the command succeeds but how long it takes. A command that finishes in 0.1 seconds against a small development database can hold a production table with twenty million rows for minutes.
  4. Apply it to production adding the new structure while leaving the old one in place. The order matters because a deploy isn't instantaneous. New code and old code run side by side for several minutes, and old code that reaches for a column that no longer exists throws errors.
  5. Fill in the values for rows already stored. A new column arrives empty, so a backfill — reprocessing a past range to populate it — follows. Updating a million rows in one statement locks the table and stops the service, so the work is usually sliced into batches of ten thousand with pauses between them.
  6. Drop the old column once only the new code remains. Days can pass between step 4 and step 6, and that gap is deliberate rather than slow: it leaves room to notice that something reads the old column before the column is gone.

Splitting steps 4 through 6 is how a table changes shape without the service going down. Do it in one shot and the few minutes between the deploy and the structure change become an outage window.

When it fits and when it doesn't

The procedure fits structural change: adding, renaming or dropping a column, creating a table, or changing the boundary of a transaction, the bundle of operations that either all succeed or all get cancelled. A one-off job that only corrects values is better handled by a separate script — that's content rather than structure, and it runs once and gets thrown away.

There are three limits.

  1. On a large table, a single statement locks it. While a column is being added, every read and write against that table waits. That pushes the work to low-traffic hours, or to tools that rewrite the table in small pieces without holding a lock. Adding an index, the lookup structure built in advance to make searches fast, calls for the same caution.
  2. Reversing is not as cheap as it is for code. A rollback to the previous build takes minutes, but reversing a change that dropped a column brings back the structure and not the values that were inside it. Teams therefore tend to leave columns unused for a while instead of dropping them.
  3. Old code has to keep working against the new structure. That requirement is called backward compatibility, and migrations honor it by not renaming a column outright: the new name is added, both are written for a period, and only then does the old one go.

How it sounds at work

"There's a migration in this release. Watch the order."

A database change is bundled in, so the team needs to agree on whether code or structure goes first.

"How long will this migration take in production? Let's start with the row count."

A statement that finished instantly in development may hold a production table for a while, so measure before running it.

"The column is there but the existing rows are empty. When do we run the backfill?"

The structure changed, but historical data hasn't been filled into the new shape yet.

All three came up in the same week at Pagedeck, when documents started storing a sharing scope. Theo put up the file that added the column, confirmed it took four seconds on staging, and shared the arithmetic that scaled it to roughly three minutes of lock time against production row counts. The team scheduled it overnight, and Mia checked whether any enterprise customers were active in that window.

What people get wrong

The most common mistake is treating a migration and a deploy as the same event. They usually happen together, so the assumption is natural. But they reverse differently. Code returns to its previous state when you ship the old build; a structural change that has already run stays run. Bundle the two mentally and an incident ends with the code rolled back and the recovery declared complete, while data written on the assumption of the new structure trips the old code into a second incident.

Believing the migration files keep the data safe is the other frequent error. Those files record how the structure changed; they are not a copy of the values. Protecting values is the job of backups, kept separately from replication, which copies the same data across several servers for availability rather than for recovery. Before a large change it's worth confirming that a backup actually restores, since a backup nobody has ever read back is an assumption rather than a safety net.

Where to go next

  • Database

    see what kind of system a migration operates on

  • Replication

    copies of the same data change where a structural change has to land

  • Backfill

    the companion job that fills historical data after the structure moves

Last updated 2026-09-16