All posts
6 min read

What Are Database Migrations? A Plain-English Guide

Migrations are how an app's database changes shape over time without losing data. What they are, why AI-built apps get them wrong, how to make a risky change safely, and the rules that stop a schema change from becoming a data-loss incident.

databasesdeploymentgetting startedAI agents

Your app stores data in tables with a fixed shape: a users table with columns for name, email, and password. Then you want to add a phone number. Or split "name" into first and last. Or rename a table. The database already has real users' data in it — how do you change its shape without losing any?

That's what migrations are for. They're one of the least glamorous parts of building software and one of the most important, because they're where data gets lost.

What a migration is

A migration is a small file containing one change to your database's structure, written as code:

-- 20260924_add_phone_to_users.sql
ALTER TABLE users ADD COLUMN phone text;

Your app keeps these files in order, in the repository, alongside the code. The database keeps a record of which ones have already run. When you deploy, a migration tool runs any that haven't run yet, in order.

That gives you three things:

  1. Every database is built the same way. Your laptop, a teammate's machine, staging, and production all get the same changes in the same order.
  2. Changes are reviewable. A migration is in a pull request like any other code.
  3. There's a history. You can see exactly when and why the database changed.

Most frameworks have migration tools built in or a standard choice: Prisma and Drizzle for TypeScript, Django and Alembic for Python, ActiveRecord for Rails, Laravel's migrations, and so on. Many can generate migration files automatically when you change your data model in code.

Why AI-built apps get this wrong

AI tools are often very comfortable changing a database directly: run a CREATE TABLE here, an ALTER TABLE there, until the app works. That's fine on a throwaway prototype, and a problem the moment real data exists:

  • The changes aren't recorded. The development database has a column that production doesn't, and nobody knows which changes were made.
  • Production drifts. The app deploys, queries a column that doesn't exist in production, and crashes. (One of the classics in Why Does My App Work Locally but Not in Production?.)
  • Destructive changes happen casually. An AI "cleaning up" the schema drops a column it thinks is unused, and the data in it is gone.
  • Some tools rebuild the schema from scratch. Commands that "push" or "sync" a data model directly — convenient in development — can drop and recreate things to make the database match. Pointed at production, that's a disaster.

The fix: once the app has real users, every change to the database goes through a migration file, reviewed like code, and run the same way everywhere.

Safe changes and dangerous ones

Not all migrations carry the same risk.

Generally safe:

  • Adding a new table.
  • Adding a new column that allows empty values, or has a default.
  • Adding an index (though on a large table, create it in a way that doesn't lock the table — most databases have an option for this).

Needs care:

  • Renaming a column or table. Every piece of code using the old name breaks the instant the migration runs.
  • Changing a column's type. Existing data may not convert cleanly.
  • Adding a "required" constraint to a column that already has empty values in some rows.

Destroys data:

  • Dropping a column or table. The data is gone. The only way back is a backup.

How to make a risky change safely: expand, then contract

The standard technique for risky changes is to split them into steps that are each safe on their own. Say you're renaming name to full_name:

  1. Expand. Add the new column full_name. Deploy code that writes to both columns.
  2. Backfill. Copy existing data from name into full_name.
  3. Switch. Deploy code that reads from full_name.
  4. Contract. Once you're sure nothing uses name any more — days later, not minutes — drop it.

At every step, the running app works, and until the final step, nothing is lost. If step 3 reveals a problem, you switch back. This is also what makes deploying without downtime possible — see Zero-Downtime Deploys for a Small App.

It's more work than a single rename. That's the price of never losing data.

"Down" migrations and rolling back

Many migration tools let you write a "down" migration that reverses each change. They're useful in development. Be realistic about them in production, though: the "down" for DROP COLUMN can recreate the column, but not the data that was in it. Your real rollback plan for destructive changes is a backup — taken right before the migration runs, and one you've tested restoring. How to Back Up a Postgres Database covers how.

Rules for letting an AI agent write migrations

Agents write perfectly good migrations — with some guardrails:

  1. Migrations only, no direct schema changes outside development. Put this in CLAUDE.md or your agent's instructions.
  2. Never against production. The agent should work against a development database. Migrations reach production through your deploy process, after review. (How to Stop an AI Agent From Deleting Your Production Database explains why the agent shouldn't hold production credentials at all.)
  3. Review every migration file before merging, especially anything with DROP, RENAME, ALTER ... TYPE, or DELETE. It's the part of an AI-written pull request most worth your attention — see How to Review a Pull Request Written by an AI Agent.
  4. Ask for the plan first. "Before writing the migration, explain what happens to existing rows" catches most problems. Plan mode is built for exactly this.
  5. Don't edit migrations that have already run. If a migration was wrong, write a new one that fixes it. Editing history makes environments disagree about what happened.
  6. Back up before running anything destructive. Every time.

The checklist

  • Every schema change is a migration file in the repository
  • Migrations run automatically, in order, as part of deploying
  • No schema "push"/"sync" commands against production
  • Destructive changes use expand-then-contract
  • A fresh, tested backup exists before any destructive migration
  • Agents work against development databases only
  • Every migration is reviewed before it merges

Migrations are boring when they're done right. That's the goal.


EasySpawn gives every project a managed Postgres database with daily backups, and lets Claude Code build and test migrations against a development database before anything reaches production. See how it works or join the waitlist.

Related: Which Database Should an AI-Built App Use? · Claude Code Checkpoints: What /rewind Can't Undo · SQL for Beginners · Postgres Migrations on Large Tables

Keep reading