#DevOps

Database Migration Management in Practice

Changing a database schema is high-risk: production data cannot be lost and the system cannot stop. This article covers migration tools (Prisma migrate/Flyway/Liquibase), forward/backward compatible migration strategies, zero-downtime migrations (expand → migrate → contract), rollbacks, and the pitfalls of changing schema on large tables.

Key Points

  • Topic Focus: DevOps
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Database Migration Management in Practice

On a database running in production, changing a single field type can paralyze the entire system for half an hour. This is not alarmism—I once saw an ALTER TABLE lock the core orders table dead, directly causing a cascading avalanche of payment timeouts. The fundamental risk of database migrations is this: it is operating on a "running system," and the data is non-renewable.

This article discusses how to safely manage schema changes in production environments.

1. What Exactly Makes Schema Changes Hard

Think of code deployment as changing a tire; database migration is replacing the engine of an airplane in flight. Four pain points are nearly unavoidable:

  1. Data cannot be lost: Drop the wrong column, and user data is gone; restoring from a backup may lose several minutes of transactions.
  2. No downtime: Many businesses have no "maintenance window"—99.9% availability means you can only be down 8.7 hours per year.
  3. Compatibility: New code + old schema, old code + new schema—either combination can blow up.
  4. Rollback is hard: Code rollback is git revert; data rollback is "turning already-deleted rows back into existence"—impossible.

This is why mature teams treat schema changes as an independent first-class citizen: they use migration tools, write versioned scripts, and establish discipline.

2. Migration Tools: Codifying Schema Changes

All mainstream relational migration tools share the same core model: versioned SQL files + sequential execution + a state table to record progress.

Prisma migrate

The first choice in the Node.js/TypeScript ecosystem. You declare your schema declaratively in schema.prisma, and the tool automatically computes the diff to generate migration files:

bash
npx prisma migrate dev --name add_user_avatar
npx prisma migrate deploy   # Sequential execution in production

It creates a _prisma_migrations table in the database to record which migrations have already been applied. The upside is schema as code; the downside is that complex changes (such as custom index options) require hand-writing migration.sql.

Flyway

The de facto standard in the Java/JVM ecosystem, simple to the extreme. Migration files are named V1__create_users.sql, V2__add_email_index.sql, where the version number is the order:

sql
-- V3__add_user_status.sql
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active';
CREATE INDEX CONCURRENTLY idx_users_status ON users(status);

Flyway maintains a flyway_schema_history table that records each version's checksum; modifying an already-applied migration file will directly throw an error—this forces you to "only move incrementally forward."

Liquibase

Heavier and more enterprise-grade. It describes changes in XML/YAML/JSON, abstracting away dialect differences (the same change can run on Postgres, MySQL, and Oracle):

xml
<changeSet id="add-user-status" author="alice">
  <addColumn tableName="users">
    <column name="status" type="varchar(20)" defaultValue="active"/>
  </addColumn>
</changeSet>

Suitable for enterprise scenarios with cross-database products that need auditing and rollback tags. Small teams will find it verbose.

Selection principle: follow your language ecosystem. Use Prisma for TS, Flyway for Java, and Liquibase for large multi-database enterprises. Don't chase "the optimal"—the core value of a migration tool is discipline, not flashy features.

3. The Iron Rules of Migration Files

Regardless of which tool you use, the following principles are universal:

  1. One change per file: Don't cram "add column + change type + drop table" into one migration; when it fails, you can neither locate nor roll back.
  2. Only move incrementally forward: A migration file already merged into the main branch must never be modified, even if it is wrong—write a new migration to fix it instead. Changing the checksum will corrupt the state of every environment.
  3. Put it in version control: Migration files live in the same repo, same PR, and same review as the code. The root cause of schema drift is scripts scattered across various wikis and local machines.
  4. Be re-entrant friendly: Try to write DDL idempotently (with IF NOT EXISTS), so you don't panic when rebuilding the local database.

4. Forward Compatibility: The Lifeline of Migration Safety

The truly expert approach is to decouple "schema changes" from "code changes." The core idea: first make the code compatible with both the new and old schema, then migrate in steps.

Take "delete the username column" as an example. A novice would run a single migration that directly does DROP COLUMN, but at that moment old code is still running, and queries will immediately fail. The correct approach is split across two releases:

First release (code + migration A):

  • Migration: Don't delete anything; first ensure that no read path depends on username anymore.
  • Code: Remove all references to username.

Second release (migration B, after the code has been stable for a few days):

  • Migration: ALTER TABLE users DROP COLUMN username;

Adding a column is the same—don't immediately go NOT NULL: first add it as nullable or with a default value; once data is backfilled and all read paths are healthy, add the constraint. Changing a field type requires even more care, with a dual-write transition (detailed later).

This "two-step release" is the golden rule of schema safety. Combined with a feature flag, you can disable the new code path when something goes wrong without rolling back the database at all.

5. Zero-Downtime Migration: Expand → Migrate Data → Switch → Contract

For large tables or critical changes (such as splitting email into a separate table, or replacing an integer ID with a UUID), the previous two-step approach is insufficient. You need a more formal zero-downtime pattern that the industry calls Expand-Contract (expand → contract), with data migration in between:

1. Expand: Add the new structure; don't delete the old one.

sql
-- Migration 1: Add a new column that coexists with the old one
ALTER TABLE users ADD COLUMN email_v2 TEXT;

2. Migrate data: Dual-write + backfill.

sql
-- Code layer: every write path writes to both email and email_v2
-- Backfill historical data (in batches to avoid long transactions)
UPDATE users SET email_v2 = email WHERE email_v2 IS NULL AND id BETWEEN 0 AND 100000;
-- 10,000 rows at a time, loop until done

Backfill must be done in batches; otherwise, a single long transaction will lock the whole table and blow up the WAL.

3. Switch: Switch the code's read path to the new column and observe for a week. Gradually switch via a feature flag; if something goes wrong, roll back to reading the old column immediately—this is exactly why the old structure can't be deleted at this point.

4. Contract: Once stable, delete the old structure.

sql
-- Migration 2: After confirming no rollback is needed
ALTER TABLE users DROP COLUMN email;
ALTER TABLE users RENAME COLUMN email_v2 TO email;

The core of this pattern: deletion is the last step, and it is irreversible. All previous stages can be rolled back; only the last step, once done, cannot be undone—so before contracting, ensure the new code has been stable for a sufficiently long time.

6. Rollback: Down Migrations Are Not a Silver Bullet

Many tools support down migrations (reverse scripts), such as Rails's db:rollback and Liquibase's rollback tags:

sql
-- down: drop the column added by up
ALTER TABLE users DROP COLUMN avatar_url;

This sounds wonderful, but in practice down migrations are often an illusion:

  • Data migrations are irreversible: If up splits full_name into first_name + last_name, how does down merge them back? Information may already be lost (e.g., case, whitespace).
  • No data after dropping a column: If up drops a column, down adds it back empty.
  • Irreversible DDL: After DROP TABLE, down cannot save you either.

Therefore, in production environments I never rely on down migrations as a fallback. More reliable rollback strategies are:

  1. Forward compatibility + feature flags: Roll back the code; leave the database untouched.
  2. Pre-migration backup: Run pg_dump on the table to be changed, or create a _backup_yyyymmdd shadow table.
  3. Design reversible migrations first: Adding a column is always reversible (just DROP it in down); dropping a column is irreversible—so dropping a column always goes through Expand-Contract.

7. Three Deep Pitfalls of Changing Schema on Large Tables

Production tables routinely have tens of millions or even hundreds of millions of rows; you can't just run DDL whenever you want.

Pitfall 1: Adding an index must use CONCURRENTLY

A plain CREATE INDEX locks table writes. PostgreSQL provides the CONCURRENTLY option:

sql
-- Wrong: blocks all writes
CREATE INDEX idx_users_email ON users(email);

-- Correct: doesn't block writes; takes longer but the business is unaffected
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

Note that CONCURRENTLY cannot run inside a transaction, and on failure leaves behind an INVALID index that must be cleaned up manually.

Pitfall 2: Changing a column type must be done in steps

Changing VARCHAR(50) to VARCHAR(100) is a metadata operation in most databases and completes in seconds. But the reverse (shortening) or changing the type (e.g., varchar → int) rewrites the entire table and locks it forever. The step-by-step approach:

sql
-- 1. Add a new column
ALTER TABLE users ADD COLUMN phone_e164 TEXT;
-- 2. Dual-write + backfill (convert the format)
UPDATE users SET phone_e164 = normalize(phone) WHERE id BETWEEN ? AND ?;
-- 3. Switch read paths
-- 4. Drop the old column

Pitfall 3: Online DDL can still trigger lock escalation

MySQL's ADD COLUMN supports the INSTANT algorithm from 8.0 onward, but adding it in the middle position, changing the primary key, or adding a full-text index still goes through INPLACE or even COPY, with uncontrollable lock duration. Before going live, always load-test on staging with an equal amount of data and check SHOW PROCESSLIST for any "Waiting for table metadata lock."

8. The Discipline Checklist for Production Migrations

Converging the above patterns into actionable discipline:

  1. Back up first: Before migrating, take a full pg_dump/snapshot of the target database—this is the last line of defense.
  2. Execute during off-peak hours: Run DDL during business troughs (early morning, weekends) to reduce the impact of lock contention.
  3. Gradual rollout: Stage code + migrations—pre-prod first, then 10%, then full rollout—watching monitoring at each stage.
  4. Monitoring metrics: Database connections, slow queries, lock waits, replication lag, application error rate—stop advancing immediately if any one is abnormal.
  5. Write a Runbook: Every migration has written steps, including "expected duration," "rollback actions," and "the person responsible." When things go wrong, the human brain is unreliable.

One small personal habit of mine: any migration beyond the "add a nullable column" level gets a dry run on staging against a desensitized copy of production data, recording the time spent, before deciding whether to run it during a production trough.

Summary

  • The core value of migration tools is discipline, not syntactic sugar: versioning, sequential execution, and state recording. Just pick the tool from your language ecosystem.
  • Forward compatibility + two-step release is the lifeline of schema safety: make the code compatible with both new and old schemas first, then migrate in steps; combined with feature flags, rollback becomes a code action rather than a data action.
  • Zero-downtime large changes follow Expand-Migrate-Switch-Contract: deletion always goes last; all previous stages are rollback-able.
  • Down migrations are an illusion: data migrations are fundamentally irreversible; the real fallback is compatibility design + backups.
  • Large-table DDL is full of pitfalls: use CONCURRENTLY for indexes, dual-write for type changes, shortening/type-swapping means rewriting the table, and always load-test on equal-sized data before production.

Breaking code is an incident; breaking the database is a disaster. Treat every schema change as "the last chance"—reversibility is designed in, not rescued back.

Related

Related Content