# Practical Schema Migrations for Solo SaaS

> A practical approach to database schema changes for solo founders: expand-contract patterns, safe deployment order, and checklists that keep production running while you ship.

Source: https://indiefounder.me/engineering/practical-schema-migrations-for-solo-saas/  
Published: 2026-11-07

Solo founders ship schema changes under time pressure and with no dedicated DBA. A migration that works on a laptop can lock a table, break a running deploy, or leave data in a half-migrated state that is expensive to reverse. The goal is not zero-downtime perfection; it is a repeatable process that keeps the product available while the change rolls out and can be undone if something unexpected appears.

This guide covers the practical patterns that work for early SaaS products with a single primary database. It assumes you already have automated backups and a way to roll back the application code.

## Prefer expand-contract over big-bang changes

Most production incidents from migrations come from trying to change the schema and the application code in the same release. The expand-contract (or expand-migrate-contract) pattern separates the steps so the running application always sees a compatible schema.

1. **Expand**: Add the new column, table, or index in a backward-compatible way. The old application continues to work.
2. **Migrate**: Deploy application code that writes to both the old and new shape (or backfills data) while still reading from the old shape.
3. **Contract**: Once every running instance has the new code and data has been moved, remove the old column or table in a later release.

Illustrative example only: you need to rename `user.email` to `user.primary_email` and add a verified flag. First migration adds `primary_email` and `email_verified` as nullable columns. Application code is updated to write both the old and new columns. A background job or follow-up migration copies existing values. After a full deploy cycle and a check that no code still reads the old column, a later migration drops `email`.

This multi-release approach is slower than a single ALTER TABLE, but it removes the window where a rolling deploy leaves some instances reading a column that no longer exists.

## Make migrations backward-compatible by default

Rules that keep a migration safe while old application versions are still running:

- Add columns as nullable or with a safe default. Avoid NOT NULL on a new column until the application is writing the value.
- Never rename or drop a column in the same release that starts using the new name.
- Create indexes concurrently when the database supports it (PostgreSQL `CREATE INDEX CONCURRENTLY`) so the table is not locked for writes.
- Prefer new tables or new columns over altering the type of an existing heavily used column.
- Keep foreign keys and constraints that the old code still expects.

If your migration tool generates a lock or a long transaction by default, override it for production. Test the exact SQL against a copy of production-sized data when possible.

## Order of operations on deploy

The safest order for most solo setups is:

1. Run the expand migration against the database (new nullable columns, new tables, concurrent indexes).
2. Deploy the new application code that understands both shapes.
3. Run any backfill or dual-write verification.
4. In a later, separate deploy, run the contract migration that removes the old shape only after you have confirmed no old code is running and the data is consistent.

If your hosting platform runs migrations automatically on deploy, configure it so the migration happens before the new instances receive traffic, or keep the migration step manual until you trust the automation. A failed migration that leaves the database ahead of the code is usually harder to recover than the reverse.

## Backfills and dual writes

When you move data, choose the simplest method that finishes before you need the old column gone:

- For small tables, a one-time SQL UPDATE or a short migration script is enough.
- For larger tables, a background job that processes batches (with a progress marker) avoids long locks and can be paused.
- Dual-write in application code for a period ensures new rows are correct even if the backfill is still running.

Verify after the backfill: count rows where the new column is null, sample a few records, and check that application metrics or error rates did not change. Record the verification in your deploy notes.

## Testing migrations before production

Run the migration against a realistic dataset:

- Restore a recent production backup to a staging database (or use a sanitized copy).
- Apply the migration and measure duration and locks.
- Deploy the new application code against that database and exercise the critical paths (signup, core write, background jobs).
- Confirm you can still roll back the application code and that the old code continues to function with the expanded schema.

If the migration takes minutes or locks writes, plan a maintenance window or split the work. Document the expected runtime so you are not surprised at 2 a.m.

## Rollback and recovery

Every expand migration should be reversible in practice:

- The application can run on the expanded schema with the previous code version.
- You have a recent backup taken before the migration.
- For the contract step, you only proceed after the expand has been live and stable; reversing a drop requires the backup or a carefully prepared reverse migration.

Keep the exact migration files and the deploy commit in version control. If something goes wrong, the fastest recovery is usually "roll back the application, leave the expanded schema, restore from backup only if data was corrupted."

## Practical checklist before a schema change

- [ ] Change is split into expand and later contract steps.
- [ ] New columns are nullable or have safe defaults; no immediate NOT NULL on existing tables.
- [ ] Indexes are created concurrently where supported.
- [ ] Migration tested on a recent backup or production-sized staging database.
- [ ] Application code dual-writes or is compatible with both old and new schema during the transition.
- [ ] Deploy order documented (migration then code, or code that tolerates both).
- [ ] Backfill plan and verification query prepared.
- [ ] Recent backup confirmed and rollback path for the application is clear.
- [ ] Monitoring or a simple health check will show if error rates rise after the deploy.
- [ ] Contract migration is scheduled for a later release, not the same one.

## Common pitfalls

- Renaming a column in the same release that deploys code expecting the new name, so rolling instances fail.
- Adding a NOT NULL column without a default on a large table, causing a full rewrite and long lock.
- Running a non-concurrent index build during peak traffic.
- Assuming the migration tool's default is safe for production data volumes.
- Dropping the old column before every instance has been updated and the backfill verified.
- Having no recent backup, so a bad migration forces a longer outage while you rebuild.
- Treating staging as identical to production when staging has 100 rows and production has 2 million.

## When the simple approach is no longer enough

Revisit the process when:

- Migrations regularly take more than a few minutes or cause noticeable lock waits.
- You need true zero-downtime changes across multiple regions or read replicas.
- Schema changes are frequent enough that dual-write periods overlap and become hard to track.
- Compliance or audit requirements demand a formal change record beyond git history.
- You add sharding or multiple primary databases.

Until then, expand-contract, concurrent indexes, a tested backup, and a deliberate deploy order will cover the majority of early solo-SaaS schema work. The migration should leave the database in a state the currently running (and the previous) application can use, and the verification should be something you can run in under a minute.

Safe schema changes are mostly about sequencing and reversibility. When the expand is live, the application is writing both shapes, and you can still roll the code back, you can ship the feature instead of recovering from an avoidable lock or missing column.
