D1's migration system was designed for simplicity, not security. Files .sql numbered in sequence, applied via wrangler d1 migrations apply, without rollback. Compared to tools like Flyway, Liquibase or even the Rails migration system — which allow down migrations and bidirectional versioning — D1 offers a one-way flow. This works well when the migration is well tested and goes exactly as planned. When it doesn't, recovery options are limited and need to be prepared before the problem happens, not during.
How D1's migration system works
wrangler d1 migrations create NOME creates a file .sql numbered in the project directory migrations/. The number is sequential and determines the order of application — 0001_create_users.sql, 0002_add_status_column.sql and so on. wrangler tracks which migrations have already been applied in an internal table called D1_MIGRATIONS within the database itself.
wrangler d1 migrations apply NOME_DO_BANCO applies all pending migrations in order. wrangler d1 migrations list --remote shows the current status — which have been applied and which are pending. This flow is enough for most cases.
The structural limitation: there is no --rollback. A migration applied against a D1 database does not have automatic undo. If migration 0015 introduced a bug in the schema and you need to revert, there are two options. The first is to write migration 0016 that manually undoes what 0015 did — possible for non-destructive operations like adding a column, but impossible for operations that delete data, like DROP COLUMN or DROP TABLE. The second is to use D1's Time Travel to restore the database to a point before migration, available for paid plans with a 30-day window.
The schema consistency problem at the edge
D1 replication creates a deployment risk that traditional banks do not have: the migration can be on the primary while the replicas still load the old schema. A Worker that reads from an outdated replica will execute queries against a different schema than what it expects.
The concrete scenario: you add column status to table orders with migration 0020. The migration runs on the primary. Simultaneously, you deploy the updated Worker that makes SELECT status FROM orders. A request that hits a replica that has not yet received the migration will fail with an unknown column error — even if the deployment went well and the primary already has the correct schema.
The propagation interval can reach 60 seconds. The solution is to separate the deployment of the migration from the deployment of the code. The correct flow: apply the migration, wait explicitly for at least 60 seconds, then deploy the new version of the Worker. This wait needs to be built into the CI/CD pipeline — the wrangler deploy doesn't have a "wait for schema propagation" flag, so you need to build this delay manually. A sleep 60 between the two pipeline steps is sufficient for the vast majority of cases.
Playbook for safe migrations in production
Before applying any migration to a production database, export a complete dump: wrangler d1 export --remote --database NOME_DO_BANCO --output backup-$(date +%Y%m%d-%H%M).sql. This file is your manual recovery path in case something goes wrong and Time Travel is not available or not sufficient.
Test the migration on a staging database with data of equivalent volume to production. A migration that runs in 50 ms against a table with 1,000 rows can take 20 seconds against a table with 5 million rows — and during these 20 seconds, the database may be unavailable for queries that depend on the table being changed. D1 does not have background reindexing like PostgreSQL with CREATE INDEX CONCURRENTLY.
For schema changes that require zero downtime, use the phased deployment pattern. To rename a column, process the process in independent steps: first, add the new column as nullable; second, deploy the code you write in both columns (old and new); third, a backfill job that copies data from the old to the new column in existing records; fourth, update the code to read only from the new column; fifth, remove the old column in a subsequent migration. Combining all these steps into a single migration creates a window of inconsistency and does not allow for partial rollback.
Testing migrations locally before going into production
wrangler dev --local creates a real SQLite file in .wrangler/state/v3/d1/. You can apply migrations against this file with wrangler d1 migrations apply NOME --local, inspect it directly with the sqlite3 client, and run EXPLAIN QUERY PLAN against the queries to verify that the indexes are used correctly before any deployment.
The gap between local and remote is replication. The local environment is a pure SQLite instance without the primary layer and replicas — eventual consistency issues will not show up in local tests. To cover this gap, create a separate D1 staging bank in your Cloudflare account, apply migrations there first, and validate the behavior with data that approximates production volume.
Time Travel is plan B when all else fails. wrangler d1 time-travel restore --timestamp="2026-10-07T08:00:00Z" restores the bank to the state of that point in time, within a 30-day window on paid plans. This recovers data deleted by destructive migrations and reverts schema changes that cannot be undone by a forward migration. The operation replaces the state of the entire database — there is no selective restore of specific tables. If the migration corrupted a table but other tables had normal activity in the interval, the restore will also revert these other tables to their previous state.
Also read
- Cloudflare D1: The SQLite database at the edge — and why 'edge' doesn't mean what it seems
- D1 in production: performance, limits and what doesn’t scale alone
- Slow queries in D1: how to diagnose and optimize
- Workers and Pages: deployment, routing and what each model hides
- Modern GraphQL APIs: Schema Design, Performance, and Patterns that Work
- Cloudflare Load Balancing and Geo Steering: When DNS becomes an intelligent traffic layer
