Sechno
Backend

Zero-downtime schema changes with Drizzle ORM: practical patterns and examples

A practical guide to performing zero-downtime Postgres schema changes with Drizzle ORM. Learn expand-then-contract migrations, safe indexing, batched backfills, and deployment patterns that minimize locks and service interruption.

SSechno Team 5 min read 158 views
Zero-downtime schema changes with Drizzle ORM: practical patterns and examples

Why zero-downtime schema changes matter

Database schema changes are a frequent source of production outages: long locks, blocking queries, and unexpected nulls can break running services. With Drizzle ORM and Postgres, you can adopt established patterns to evolve schemas safely without taking your service offline.

High-level strategy: expand, backfill, switch, contract

The proven approach is an expand-then-contract sequence:

  • Expand: add new columns/indexes in a backwards-compatible way (nullable columns, new tables).
  • Backfill: populate new columns in batches to avoid long-running updates.
  • Switch: update application code to start using the new column/shape behind feature flags.
  • Contract: add constraints (NOT NULL), remove old columns once all traffic uses the new shape.

Checklist before you start

  • Identify long-running operations (mass UPDATE, index builds).
  • Plan backfill in batches and measure per-batch latency.
  • Prefer CREATE INDEX CONCURRENTLY to avoid exclusive locks (Postgres).
  • Use feature flags to roll out code that depends on new schema columns.
  • Run schema changes outside of transactions when required by Postgres (e.g., CONCURRENTLY).

Example: safely adding a NOT NULL column used by a new feature

Scenario: add bio TEXT NOT NULL to users, but avoid downtime. Steps below show migration files, backfill loop, and final constraint step.

1) Expand: add nullable column (fast, low-lock)

-- 2026xx_add_bio.sql
ALTER TABLE users ADD COLUMN bio TEXT;

Run this as a normal migration. Adding a nullable column is cheap in Postgres.

2) Backfill in small batches

Use a batched update loop to populate the column. This example uses SQL that selects a limited set of rows and updates them. Repeat until no rows remain. Run from a job runner or admin process to avoid blocking web threads.

-- Batched backfill (Postgres)
-- repeat this until the row count is zero
WITH c AS (
  SELECT id FROM users WHERE bio IS NULL LIMIT 1000
)
UPDATE users u
SET bio = COALESCE(p.bio_source, '')
FROM profiles p
WHERE u.id = (SELECT id FROM c WHERE c.id = u.id)
  AND p.user_id = u.id;

If your backfill logic is more complex, implement batching in your application code and monitor affected-row counts. Example Node pseudo-loop (run as a maintenance script):

-- Pseudo SQL-run loop (replace with your migration runner calls)
-- execute the batched UPDATE repeatedly until affected_rows < batch_size

3) Switch: deploy code that writes the new column and reads it when present

Deploy application changes that write to bio while still tolerating absent values (keep reading old values or defaults until backfill completes). Use feature flags during rollout.

// Example: Node/Drizzle-style pseudocode
// New code writes bio for new/updated users, and reads bio if present
const user = await db.select().from(users).where(users.id.eq(userId));
const bio = user.bio ?? fallbackProfileBio(userId);

4) Contract: add NOT NULL constraint and drop legacy columns

After backfill completes and all code paths use the new column, make the constraint change:

ALTER TABLE users ALTER COLUMN bio SET DEFAULT '';
UPDATE users SET bio = '' WHERE bio IS NULL;
ALTER TABLE users ALTER COLUMN bio SET NOT NULL;
-- now it's safe to remove old columns or migration artifacts

Creating indexes without blocking writes

Large tables can block writes during index creation. On Postgres, use CONCURRENTLY:

-- Create index without taking an exclusive lock. Must be run outside a transaction.
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

Note: CREATE INDEX CONCURRENTLY cannot run inside a transaction block. If your migration tool wraps statements in transactions by default, execute that statement through a non-transactional runner or a dedicated maintenance job.

Drizzle ORM considerations

Drizzle's migration tooling can execute raw SQL, which you should use for operations that require control (CONCURRENTLY, separate backfill jobs). Common patterns:

  • Keep schema-changing statements idempotent and small.
  • Use raw SQL for index CONCURRENTLY and long-running backfills.
  • Run backfills as separate jobs (cron, background worker) instead of inside the migration runner.

See the Drizzle docs for migration configuration and best practices: Drizzle migrations docs.

Safe rename pattern

  1. Create the new column
  2. Backfill the new column
  3. Deploy code to read from new column and write both old and new
  4. When stable, switch to writing only the new column
  5. Drop old column

Operational tips

  • Monitor long-running queries using pg_stat_activity before and during migrations.
  • Use connection-pools with short statement_timeouts for non-critical maintenance jobs.
  • Run heavy DDL (CONCURRENTLY index builds, large backfills) during low-traffic windows where possible.
  • Automate rollback plans: record the exact migration steps so you can revert safely.

Tradeoffs

  • Performance vs complexity: zero-downtime migrations add operational steps (backfill jobs, feature flags) versus a simple single-step migration that may cause locks.
  • Time to completion: batched backfills take longer but reduce risk; urgent changes may still require short maintenance windows.
  • Tooling limits: some ORMs/migration tools wrap everything in transactions; you may need raw SQL execution or out-of-band jobs for CONCURRENTLY and long-running operations.

Conclusion

Zero-downtime schema changes are achievable with a disciplined expand-then-contract approach, batched backfills, and careful use of non-blocking operations like CREATE INDEX CONCURRENTLY. With Drizzle ORM, favor raw SQL for operations that must run outside transactions and treat backfills as separate maintenance jobs. Combine these DB-side techniques with feature flags and staged deployments to evolve schemas safely in production.

Further reading: Drizzle ORM Migrations in Production: Zero-Downtime Schema Changes

Was this helpful?

Share this post

Comments (0)

Want to join the conversation?

Log in or sign up to leave a comment and share your thoughts.

Log in to Comment