How to Run Database Migrations on Deploy Safely: The Expand/Contract Pattern
TL;DR: Running schema migrations when your app starts is fine, as long as every migration leaves the database usable by both the old and the new version of your code. The expand/contract pattern does exactly that: add new things first, switch the code over, and only remove old things in a later deploy.
This guide covers why startup migrations break, how to split a risky change into safe steps, and a small startup script that runs migrations before your server starts listening.
Why do migrations on startup break deploys?
Most rolling deploys share one property: for a short time, two versions of your app are running against the same database. The new version has to come up before the old one goes away.
On Candor Deploy, that’s how every rollout works. The builds and configuration docs describe it: the new version starts alongside the old one and must accept connections on its port before traffic switches, and only then is the old version retired.
Now picture a migration that renames users.name to users.full_name. The new version starts, runs the rename, and begins listening. Meanwhile the old version is still serving traffic, and every query it sends for users.name now fails. You get errors in production, and the new code hasn’t done anything wrong.
The same thing happens if the new version fails to start. A failed new version never replaces a working old one on Candor Deploy, which is what you want. But if the migration already ran before the crash, the old version is now running against a schema it doesn’t understand.
The rule that follows: every migration must be compatible with the code that is already running.
Where should migrations run on Candor Deploy?
In your app’s own startup. The databases docs are direct about it: schema migrations are your app’s job, so run them when your app starts, from your Dockerfile’s entrypoint or your repo’s own start command. There’s no separate release-phase hook.
There’s also a practical reason. Your database is reachable only from your own services, never from the internet, so you can’t run migrations from your laptop against production. The app container is where migrations belong. Your app finds the database through DATABASE_URL, which the Attach a database control writes into its environment (same page).
You can set the start command in your repo (for example the start script in package.json) or in a .candorfile, which overrides the repo default.
What does a safe startup script look like?
Keep it boring: migrate, then start the server. If the migration fails, exit non-zero so the new version never starts serving.
#!/bin/sh
# scripts/start.sh
set -e
echo "Running migrations..."
npx prisma migrate deploy # or: alembic upgrade head, python manage.py migrate,
# bundle exec rails db:migrate, goose up, ...
echo "Starting server on port ${PORT}"
exec node dist/server.js
Then point your start command at it:
{
"scripts": {
"start": "sh scripts/start.sh"
}
}
A few details matter here:
set -estops the script at the first failure, so a broken migration never leads to a half-started app.execreplaces the shell with your server process, so signals reach your app directly.- Use the “deploy” or “apply” command, not the “dev” one. Development commands in many ORMs generate new migrations or reset data. In production you only want to apply migrations that are already committed.
- Keep startup migrations short. Your server doesn’t listen until migrations finish. On Candor Deploy the deployment stays in Deploying until the new version answers, while the old version keeps serving (troubleshooting docs). Long data backfills belong in a separate job, not in the boot path.
If you ever run more than one instance of the same app, two of them may try to migrate at the same time. Most migration tools take a lock in the database to prevent this, but check yours. If it doesn’t, wrap the step in a Postgres advisory lock (pg_advisory_lock) or the equivalent for your database.
What is the expand/contract pattern?
Expand/contract (sometimes called “parallel change”) splits a breaking schema change into steps that are each backward compatible:
- Expand. Add the new column, table or index. Don’t remove or rename anything. Old code ignores the new parts, and new code can start using them.
- Migrate. Ship code that writes to both the old and new shape and reads from the new one. Backfill existing rows.
- Contract. Once no running code touches the old shape, remove it in a separate, later deploy.
Each step is its own deploy. That feels slower, but each deploy is small, and at every point the code that’s running agrees with the schema.
How do you rename a column without breaking the running app?
Here’s the name → full_name rename done safely, in four deploys.
Deploy 1: expand.
ALTER TABLE users ADD COLUMN full_name text;
The old code doesn’t know full_name exists, so it keeps working.
Deploy 2: dual-write. The app writes both columns on every insert and update, and reads full_name, falling back to name if it’s empty:
await db.query(
"UPDATE users SET name = $1, full_name = $1 WHERE id = $2",
[newName, userId]
);
Deploy 3: backfill, then read only the new column. Copy the old values over in batches so you don’t lock the table for long:
UPDATE users SET full_name = name
WHERE full_name IS NULL AND id IN (
SELECT id FROM users WHERE full_name IS NULL LIMIT 1000
);
Run it in a loop from a one-off script or a small job until no rows are left. Then switch reads to full_name only, and stop writing name.
Deploy 4: contract. When you’re confident nothing reads name anymore:
ALTER TABLE users DROP COLUMN name;
If you want a NOT NULL constraint on full_name, add it here too, after the backfill. Adding it earlier would reject writes from the old code.
Which schema changes are safe in a single deploy?
As a rough guide:
Usually safe in one step
- Adding a new table.
- Adding a nullable column, or one with a simple default.
- Adding an index, preferably with
CREATE INDEX CONCURRENTLYon Postgres so writes aren’t blocked.
Needs expand/contract
- Renaming a column or table.
- Changing a column’s type.
- Adding
NOT NULLto an existing column. - Dropping a column or table the current code still reads.
- Splitting or merging tables.
When in doubt, ask one question: if the previous version of my code ran against this new schema for ten minutes, would it still work? If the answer is no, split the change.
How do expand/contract migrations make rollbacks safer?
Rolling back code doesn’t roll back your schema. On Candor Deploy, a rollback rebuilds an earlier commit from source and rolls it out like any other deploy (builds and configuration docs). Your database stays exactly as the latest migrations left it.
That’s another argument for expand/contract. If every migration keeps the previous version working, the previous version is always a safe place to roll back to. Treat migrations as forward-only. If a change was wrong, fix it with a new migration rather than trying to run “down” scripts in production.
What should you do next?
Pick the next schema change on your list and run it through the ten-minute question before you write the migration. Then put migrations in your start command and make sure your app gets its connection string. The Candor Deploy databases guide walks through creating a database and attaching it to your app.
Written with help from our AI assistant.