Web development
Safe migrations in production
On 200,000 rows SQLite adds a column in 0.17 ms and rebuilds the table in 243.4 ms; between those numbers sits the lock a user waits on.
Key points
- On 200,000 rows SQLite 3.51.3 adds a column in 0.17 to 0.64 ms and rebuilds the table in 243.4 ms.
- The page count stays at 3,847 before and after an ADD COLUMN: the database rewrites nothing.
- SQLite rejects a non-constant DEFAULT, and that case needs the twelve-step recipe.
- Every rebuild holds the write lock for the whole copy: duration matters, not the command.
A migration is safe when you know how long it holds the lock, not when the command looks short. On 200,000 rows, SQLite 3.51.3 adds a column in 0.64 ms and rebuilds the table in 243.4 ms: between those numbers sits the difference between a metadata update and a full copy.
What an ALTER TABLE really does
Adding a nullable column does not touch the rows: the database writes a catalogue entry and leaves the values absent. PostgreSQL 11, released on 18 October 2018, extended the same idea to a constant NOT NULL DEFAULT; before that version the table was rewritten. SQLite behaves the same way, and the page count proves it: 3,847 before, 3,847 after.
Three classes, three risks
The first class is metadata: add a column, rename an object, create an index on an empty table. The second is a rewrite: change a column type, add a CHECK, drop NULL. The third is a backfill: an UPDATE over millions of rows, which writes a lot and generates WAL. The first two can be measured in a lab; the third is measured in production and must be split into batches.
The command that says whether the table is rewritten
The page count is the simplest signal, and it needs no staging environment: if it grows, the database copied the data. Run the comparison on a copy of the database, never on the live one.
node -e "const {DatabaseSync}=require('node:sqlite');const db=new DatabaseSync('app.db');const before=db.prepare('PRAGMA page_count').get().page_count;const t=process.hrtime.bigint();db.exec('ALTER TABLE orders ADD COLUMN archived INTEGER NOT NULL DEFAULT 0');console.log({ms:Number(process.hrtime.bigint()-t)/1e6,before,after:db.prepare('PRAGMA page_count').get().page_count})"
Expand, backfill, contract
Changing a column without stopping the service takes three releases. The first adds the new nullable column, and the code writes to both. The second copies data in batches, with a stable order and a pause between them. The third makes the code read only the new column, adds the constraint and drops the old one. Every step is reversible, and none holds a long lock.
The limit: the copy is not free
The 243.4 ms measured here covers 200,000 rows with a warm cache: on a table of fifty million rows the same recipe takes minutes, and SQLite has no online DDL, so the write lock stays closed for the whole copy. PostgreSQL allows CREATE INDEX CONCURRENTLY, but an ALTER TABLE that changes a type still needs an exclusive lock, short only when the table is small. No migration tool changes that arithmetic.
Where to start
Frequently asked questions
Does adding a column with a DEFAULT rewrite the table?
No, when the value is a constant and the database is current. PostgreSQL has done it since 11 and SQLite always has: the page count stays identical. Before PostgreSQL 11 the table was rewritten in full.
Can I drop a column without rebuilding the table?
It depends on the version. SQLite supports DROP COLUMN from 3.35 and PostgreSQL always has, under a brief exclusive lock. If the column is part of an index or a constraint, you still need the new-table recipe.
How do I backfill without blocking writes?
In batches, with a stable order and a pause between them. Update a few thousand rows at a time, watch replication lag and stop if the write lock grows.
Should I write migrations by hand or generate them?
Generate them with the tool and read them before applying. Prisma and drizzle-kit produce correct SQL but they do not know your table size, and size decides whether a migration is metadata or a rewrite.
What do I do when a migration goes wrong?
Restore the backup and restart from the previous step of expand, backfill, contract. Rolling back an ALTER TABLE does not restore data already copied, so every step must stay compatible with the running code.