{"post":{"slug":"how-to-write-a-database-migration-2026","title":"How to write a database migration in 2026: safe, reversible, and zero-downtime","excerpt":"Database migrations fail when they lock tables in production or cannot be rolled back. This guide covers the 2026 patterns for safe, zero-downtime schema changes in Postgres.","category":"code","date":"April 6, 2026","author":"ByteLedger Team","content":"<span class=\"dropcap\">D</span>atabase migrations are where careful engineering pays off most. A bad migration in production locks your table, drops half your data, or corrupts records in a way that's invisible until users start filing bugs. In 2026, the tooling is excellent — the mistakes are still human. This guide covers the patterns that make migrations safe, fast, and reversible.\n\n## What changed in 2026\n\n- **Drizzle ORM** is the dominant TypeScript migration tool, with a `drizzle-kit generate` → `drizzle-kit migrate` workflow that tracks schema drift precisely.\n- **Alembic** remains the standard for Python/Django projects; the `--autogenerate` feature is reliable for detecting ORM model changes.\n- **Postgres 16/17** improved lock management; concurrent index builds are more reliable, and `SKIP LOCKED` is widely used for batch backfills.\n- **Neon branching** lets you test a migration on a copy of production data before running it — a major safety net for destructive changes.\n\n## Tool comparison\n\n| Tool | Language | Migration tracking | Auto-generate |\n| --- | --- | --- | --- |\n| Drizzle Kit | TypeScript | SQL files | Yes (from schema) |\n| Prisma Migrate | TypeScript | SQL + shadow DB | Yes (from schema) |\n| Alembic | Python | Python scripts | Yes (from models) |\n| Flyway | Java/any SQL | SQL files | No |\n| Liquibase | Java/any SQL | XML/YAML/SQL | No |\n\nFor TypeScript projects, Drizzle Kit or Prisma Migrate. For Python, Alembic.\n\n## Basic migration anatomy (Alembic)\n\n```python\n# migrations/versions/2026_06_02_add_status_to_posts.py\n\"\"\"add status to posts\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\nrevision = \"3f9a1e2c8b4d\"\ndown_revision = \"1a2b3c4d5e6f\"\nbranch_labels = None\ndepends_on = None\n\ndef upgrade() -> None:\n    op.add_column(\"posts\", sa.Column(\"status\", sa.String(20), nullable=True))\n    # Backfill after adding the column\n    op.execute(\"UPDATE posts SET status = 'published' WHERE status IS NULL\")\n    op.alter_column(\"posts\", \"status\", nullable=False)\n\ndef downgrade() -> None:\n    op.drop_column(\"posts\", \"status\")\n```\n\n## The zero-downtime column rename (expand-contract)\n\nNever do this in one migration:\n```sql\nALTER TABLE users RENAME COLUMN username TO display_name;  -- breaks running app code\n```\n\nInstead, use three separate deploys:\n\n**Deploy 1 — expand:** Add the new column, write to both.\n```sql\nALTER TABLE users ADD COLUMN display_name TEXT;\n-- App code writes to both `username` and `display_name`\n```\n\n**Deploy 2 — contract:** Backfill the new column, switch reads to the new column.\n```sql\nUPDATE users SET display_name = username WHERE display_name IS NULL;\n-- App code now reads only `display_name`\n```\n\n**Deploy 3 — drop:** Remove the old column.\n```sql\nALTER TABLE users DROP COLUMN username;\n```\n\n## Adding a column with a default (safe pattern)\n\nOn Postgres 11+, adding a column with a constant default is instant (no table rewrite). On older versions, or with volatile defaults (`NOW()`), it rewrites the table:\n\n```sql\n-- Safe on Postgres 11+ (constant default)\nALTER TABLE orders ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT FALSE;\n\n-- Risky on any version (volatile default)\nALTER TABLE orders ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();\n-- Instead: add nullable, backfill in batches, then set NOT NULL\n```\n\nFor large tables with volatile defaults:\n```sql\n-- Step 1\nALTER TABLE orders ADD COLUMN created_at TIMESTAMPTZ;\n-- Step 2: batch backfill (outside migration, in a script)\nUPDATE orders SET created_at = NOW() WHERE id BETWEEN 1 AND 10000;\n-- ... repeat in batches\n-- Step 3\nALTER TABLE orders ALTER COLUMN created_at SET NOT NULL;\n```\n\n## Creating an index without locking\n\n```sql\n-- Blocks all writes for the duration — avoid on large tables\nCREATE INDEX idx_posts_user_id ON posts(user_id);\n\n-- Non-blocking: runs in the background\nCREATE INDEX CONCURRENTLY idx_posts_user_id ON posts(user_id);\n```\n\nIn Alembic:\n```python\nop.create_index(\"idx_posts_user_id\", \"posts\", [\"user_id\"], postgresql_concurrently=True)\n```\n\nNote: `CREATE INDEX CONCURRENTLY` cannot run inside a transaction. Use `with op.get_context().autocommit_block():` in Alembic or run the migration outside a transaction.\n\n## How to run migrations safely in production\n\n1. **Test on a branch or staging** with production-scale data before touching production.\n2. **Run as a pre-deploy step**, not inside app startup, so only one process runs it.\n3. **Back up before destructive operations** — `DROP COLUMN`, `DROP TABLE`, type changes.\n4. **Monitor lock waits** with `pg_stat_activity` and `pg_locks` during the migration run.\n5. **Have a rollback plan** — know which `downgrade()` command to run and test it.\n\n## Common mistakes\n\n**No `down` migration.** You'll need it at 2am when the deploy breaks something. Write it in the same commit as `up`.\n\n**`DROP COLUMN` without a data backup.** Column data is gone immediately. Even if you can reverse the schema, the data is gone.\n\n**Long-running `UPDATE` in a migration.** Updating millions of rows in one transaction holds locks for minutes. Batch it in a script.\n\n**Running migrations in parallel.** Multiple app instances each running `alembic upgrade head` on startup causes race conditions. Use a migration lock or a dedicated step.\n\n**Autogenerating without reviewing.** `--autogenerate` misses some changes (custom types, constraints without names) and occasionally generates spurious diffs. Always review the generated SQL.\n\n## What to skip\n\n- **Hand-editing migration files after they've been applied** to any environment — the history diverges and future autogenerates break.\n- **Squashing migrations in production** unless you have a dedicated maintenance window and have pruned all environments first.\n- **`nullable=False` columns with no default on large tables** in a single migration — that's a full table rewrite.\n\n## FAQ\n\n**How do I handle a migration that takes hours on a large table?**\nBreak it into phases: add nullable column, backfill in batches with a script (outside Alembic/Drizzle), then set constraints. Each phase is a separate migration.\n\n**Should I use `--autogenerate` or write migrations by hand?**\nUse autogenerate as a starting point, then review the diff manually. It handles routine changes well; complex cases (renaming, custom constraints) need manual edits.\n\n**How do I test a migration before production?**\nRun it against a fresh restore of the production database on a staging server or Neon branch. Check query plans before and after for index usage regressions.\n\n**What is a shadow database (Prisma)?**\nPrisma creates a temporary empty DB to detect schema drift — it compares the shadow DB state to your migration history to catch edited migration files.\n\n## Where to go next\n\nSee [How to set up Postgres locally in 2026](/blog/how-to-set-up-postgres-locally-2026), [How to add full-text search in 2026](/blog/how-to-add-full-text-search-2026), and [How to seed test data in 2026](/blog/how-to-seed-test-data-2026).","hue":305,"metaTitle":"How to Write a Database Migration 2026","metaDescription":"Write safe, reversible database migrations in 2026. Covers Postgres locking, zero-downtime patterns, column renames, Alembic, Drizzle, and common mistakes.","keywords":["how to write a database migration 2026","zero downtime database migration","postgres migration patterns","alembic migration tutorial","drizzle orm migration","safe column rename postgres","add column default postgres locking","database migration best practices 2026","reversible migrations","schema migration rollback"],"tldr":["**Every migration must be reversible** — write the `down` migration before shipping the `up`; an untested rollback is no rollback at all.","**Adding a column with a default locks large tables** — use `ADD COLUMN` with no default, then `UPDATE` in batches, then `SET DEFAULT` separately.","**Never rename a column in one step** — add the new column, backfill, migrate app code, then drop the old column across three separate deploys.","**Use transactions for DDL** — Postgres supports transactional DDL; wrap every migration in a transaction so partial failures roll back cleanly.","**Skip:** running migrations inside application startup code. Use a separate step before traffic is routed to the new app version."],"readTime":"5 min read","wordCount":1045},"related":[{"slug":"how-to-write-a-migration-script-2026","title":"How to write a migration script in 2026","excerpt":"Migration scripts move data safely between schemas or systems without downtime. This guide covers patterns, tooling, and rollback strategies for 2026.","category":"code","date":"March 18, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":313},{"slug":"expand-contract-migrations-2026","title":"Expand and Contract: Schema Changes Without Downtime","excerpt":"Renaming a column in one deploy breaks every running instance of the old code. Splitting the change into three deploys makes it invisible to users.","category":"code","date":"August 13, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":320},{"slug":"database-migration-strategies-2026","title":"Database Migration Strategies for Production (2026)","excerpt":"Schema changes fail in production for the same handful of reasons every time: locks, incompatible deploy ordering, and no rollback path. Here are the migration strategies that avoid all three in 2026.","category":"code","date":"July 31, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":320}],"sameCategory":[{"slug":"bazel-build-explained-2026","title":"Bazel Explained: What Hermetic Builds Actually Cost","excerpt":"Bazel delivers reproducible, precisely cached builds and demands that you describe everything. Here is what you get, what it costs, and who should say no.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":145},{"slug":"graphql-federation-2026","title":"GraphQL Federation: One Graph, Many Teams","excerpt":"Federation composes independently-owned subgraphs into a single API. Here is what it solves, the operational cost it adds, and when a simpler gateway is enough.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":300},{"slug":"hermetic-builds-explained-2026","title":"Hermetic Builds Explained: Same Inputs, Same Output","excerpt":"A hermetic build depends only on declared inputs. Here is what that property gives you, the common leaks that break it, and how to move toward it incrementally.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":130},{"slug":"monorepo-tooling-compared-2026","title":"Monorepo Tooling Compared: What You Actually Need","excerpt":"Monorepo tools solve task orchestration and caching, not repository layout. Here is what each tier provides and how to tell when you have outgrown the simple option.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":150}],"newer":{"slug":"how-to-write-a-bash-script-2026","title":"How to write a Bash script in 2026: the safe and practical guide","excerpt":"Bash scripting is still the fastest way to automate repetitive shell work, but most scripts are written unsafely. Here is the 2026-correct approach.","category":"code","date":"April 6, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":306},"older":{"slug":"how-to-write-clean-code-2026","title":"How to write clean code in 2026: principles that actually matter","excerpt":"Clean code is not about style guides or linters — it is about writing code that your future self and teammates can understand and change safely.","category":"code","date":"April 6, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":316}}