Safe Database Schema Migrations for Small Applications

A database migration changes an interface your application is already using. During a rolling deploy, older and newer application instances may run at the same time; background workers, scheduled jobs, and scripts can also keep using the old schema. A change that works on a fresh database can still break a live release if those versions disagree.

For small applications, the safest default is to make schema changes in small, reviewable steps. Keep the old and new application versions compatible while data moves, verify the result, and remove old structures only in a later release. This is often called the expand, migrate, contract pattern. It reduces deployment risk, but it does not guarantee zero downtime: locks, data volume, database behavior, and application traffic still matter.

Decide how much compatibility the change needs

Start by listing every reader and writer for the affected data. Include web processes, queue workers, scheduled jobs, admin tools, reporting scripts, and any other service that shares the database. Then classify the change:

Change Main risk Safer starting point
Add an optional field New code may assume existing rows already have a value Add it as nullable, deploy compatible code, then backfill and add constraints if needed
Rename or split a field Old and new code expect different names or meanings Add the new structure alongside the old one and migrate in stages
Change a field's type or meaning Existing values may not convert cleanly Define and test an explicit transformation before changing reads
Remove a field or table Deployed code may still read or write it; data may be lost Stop using it first, observe, then remove it in a later migration

These are planning prompts, not universal safety ratings. An ADD COLUMN, index, default, or constraint can behave differently across database engines and versions. Review the SQL and the database-specific operational impact before deploying.

Use expand, migrate, and contract for a field rename

Suppose your application stores a user's name in users.name and you want to move to users.display_name. Renaming the column and deploying the new code together may seem simple, but an older application instance that still queries name will fail as soon as the database stops providing it.

Instead, make each release compatible with the schema that exists at that stage:

  1. Expand the schema. Add display_name without removing name. Keep the new field optional at first so existing rows and old application versions remain valid.
  2. Deploy compatible application code. Continue reading name, and write changes to both columns. Where possible, write both values in the same database transaction. Do not start the backfill while older application versions that only write name are still running.
  3. Migrate existing rows. Copy or transform old values into display_name. Run a large backfill separately from the schema change, in bounded batches that can safely resume. Avoid overwriting a newer value written by the application.
  4. Verify and switch reads. Check that the backfill completed and that the new values meet the application's requirements. Change reads to use display_name; keep writing both columns while you observe the release and retain an application rollback option.
  5. Contract the schema later. After all web processes, workers, scheduled tasks, and rollback versions have stopped using name, stop writing it. Remove the old column in a separate migration after the observation and rollback window.

For a real split or conversion, copying a value may not be enough. Define what happens when the source is missing, malformed, or ambiguous; keep a count of rows that need manual handling rather than guessing. If dual writes cannot be made atomic, use a design that can reconcile missed updates and verify that reconciliation before switching reads.

The order is important. A new application version should not require a column before the migration adds it. A migration should not remove a column before old application versions stop using it. Treat the deploy plan as a sequence of compatible states, not as one code change followed by one database command.

Make backfills resumable and observable

A data migration that updates a few rows may be manageable in one transaction. A large backfill can hold locks, create substantial write load, run longer than a deployment window, or be difficult to recover if it stops partway through. Consider running it as a separate job instead of making every application startup perform the work.

For a resumable backfill:

  • Process a bounded number of rows at a time and record progress, such as a cursor or completed range.
  • Make each batch safe to run again. For example, update only rows that still need the new value rather than blindly replacing values written by the application.
  • Keep the transformation deterministic and test how it handles nulls, invalid values, and concurrent updates.
  • Monitor errors, database load, and the number of rows remaining. Choose batch size and pacing based on the real database and workload.
  • Compare old and new values using checks that reflect your data rules. A row count alone may miss incorrect conversions.

Do not assume that a data migration can be rolled back by running a reverse function. If the operation discards information or merges distinct values, the original data may not be reconstructable. Plan a restore path before making destructive changes; see our guide to database backup and restore plans for small web apps.

Review the generated migration, not just the model change

Frameworks help create repeatable migration files, but their output still needs review. Django's migration guide recommends keeping migration files in version control and provides sqlmigrate to inspect the SQL it will run. Django also documents backend-specific caveats: schema changes are transactional on some backends but not all, and its SQLite schema-alteration approach can rebuild a table by copying its data.

Prisma's migration documentation separates planning, reviewing, and applying changes. Its production guidance recommends checking the migration files and previewing what will run before applying a migration manually; the exact CLI workflow depends on the Prisma version you use. In either framework, keep the reviewed migration files with the application version that expects the resulting schema.

Also inspect what the database will do:

  • Locks: Some schema statements can block concurrent reads or writes. PostgreSQL documents that ALTER TABLE takes an ACCESS EXCLUSIVE lock unless a specific subcommand documents otherwise. Check the exact operation, table size, transaction duration, and expected traffic.
  • Table rewrites and scans: Adding a default, changing a type, validating a constraint, or rebuilding an index can do significant work. Behavior varies by engine and version.
  • Transactions and partial failure: Do not assume a failed migration leaves every database unchanged. Confirm your engine and migration tool's behavior, inspect the migration history after a failure, and follow the tool's recovery procedure.
  • Multiple deploys: Prevent two deployment jobs from applying schema changes to the same database at once unless your migration system explicitly coordinates them.

The documentation for your database and framework is the authority for the SQL and guarantees of your exact version. A staging run helps, but it may not reveal production lock behavior if the staging table is much smaller or receives no concurrent traffic.

Test an upgrade path that resembles production

Before deploying, test both a fresh setup and an upgrade from the schema your live application currently has. Use sanitized or synthetic data with a realistic shape and volume; do not copy private customer data into a test environment without a clear, approved need.

At minimum, check that:

  1. The migration applies from the expected prior state and the application starts afterward.
  2. The old application version can still run during the expand phase.
  3. The backfill can resume after interruption and produces the expected values.
  4. The new application version works before the old structure is removed.
  5. The final contract migration runs only after no deployed process needs the old structure.

For production, review the migration plan, confirm the restore procedure and recovery owner, and schedule around the likely impact of locks or long-running work. A backup is useful only if you know how to restore it; use a tested backup-and-restore process rather than treating a successful backup job as a rollback plan.

Roll back the application separately from the database

Rolling back application code does not automatically undo a schema migration, and reversing a migration does not necessarily restore deleted or transformed data. During the expand phase, keeping both structures usually leaves room to roll application code back. After switching reads or removing old data, that may no longer be true.

Before each phase, write down what rollback means:

  • Can the previous application version run against the current schema?
  • Does the migration tool support reversing the operation on this database?
  • Would a reverse migration restore the original values, or only recreate an empty column?
  • If the database cannot be reversed safely, can you deploy a forward fix or restore from a known recovery point?

If a migration fails, stop and inspect the database schema, data, and migration history before retrying. Some systems roll a whole migration back; others can leave earlier operations applied. Avoid manually marking a migration as complete or rerunning a destructive step until you know what has already changed.

A compact pre-deployment checklist

  • List every application, worker, job, and script that reads or writes the affected data.
  • Separate additive changes, backfills, read cutovers, and removals when old and new versions may overlap.
  • Review the generated SQL, table locks, scans, rewrites, and engine-specific transaction behavior.
  • Test from both an empty database and a production-like prior schema with representative non-sensitive data.
  • Make large data changes resumable, bounded, and safe to repeat.
  • Verify the new values before switching reads or enforcing constraints.
  • Confirm the backup restore path, rollback limits, and migration-failure recovery steps.
  • Remove old fields only after every deployed process and rollback version has stopped using them.

For a small application, this staged approach adds a few deliberate releases, but each step is easier to inspect and recover than a single destructive schema change. Use a one-step migration only when you have verified that the application versions, data, database engine, and deployment process make it safe.

References

Leave a Reply