$npx -y skills add jezweb/claude-skills --skill d1-migrationCloudflare D1 migration workflow: generate with Drizzle, inspect SQL for gotchas, apply to local and remote, fix stuck migrations, handle partial failures. Use when running migrations, fixing migration errors, or setting up D1 schemas.
| 1 | # D1 Migration Workflow |
| 2 | |
| 3 | Guided workflow for Cloudflare D1 database migrations using Drizzle ORM. |
| 4 | |
| 5 | ## Standard Migration Flow |
| 6 | |
| 7 | ### 1. Generate Migration |
| 8 | |
| 9 | ```bash |
| 10 | pnpm db:generate |
| 11 | ``` |
| 12 | |
| 13 | This creates a new `.sql` file in `drizzle/` (or your configured migrations directory). |
| 14 | |
| 15 | ### 2. Inspect the SQL (CRITICAL) |
| 16 | |
| 17 | **Always read the generated SQL before applying.** Drizzle sometimes generates destructive migrations for simple schema changes. |
| 18 | |
| 19 | #### Red Flag: Table Recreation |
| 20 | |
| 21 | If you see this pattern, the migration will likely fail: |
| 22 | |
| 23 | ```sql |
| 24 | CREATE TABLE `my_table_new` (...); |
| 25 | INSERT INTO `my_table_new` SELECT ..., `new_column`, ... FROM `my_table`; |
| 26 | -- ^^^ This column doesn't exist in old table! |
| 27 | DROP TABLE `my_table`; |
| 28 | ALTER TABLE `my_table_new` RENAME TO `my_table`; |
| 29 | ``` |
| 30 | |
| 31 | **Cause**: Changing a column's `default` value in Drizzle schema triggers full table recreation. The INSERT SELECT references the new column from the old table. |
| 32 | |
| 33 | **Fix**: If you're only adding new columns (no type/constraint changes on existing columns), simplify to: |
| 34 | |
| 35 | ```sql |
| 36 | ALTER TABLE `my_table` ADD COLUMN `new_column` TEXT DEFAULT 'value'; |
| 37 | ``` |
| 38 | |
| 39 | Edit the `.sql` file directly before applying. |
| 40 | |
| 41 | ### 3. Apply to Local |
| 42 | |
| 43 | ```bash |
| 44 | pnpm db:migrate:local |
| 45 | # or: npx wrangler d1 migrations apply DB_NAME --local |
| 46 | ``` |
| 47 | |
| 48 | ### 4. Apply to Remote |
| 49 | |
| 50 | ```bash |
| 51 | pnpm db:migrate:remote |
| 52 | # or: npx wrangler d1 migrations apply DB_NAME --remote |
| 53 | ``` |
| 54 | |
| 55 | **Always apply to BOTH local and remote before testing.** Local-only migrations cause confusing "works locally, breaks in production" issues. |
| 56 | |
| 57 | ### 5. Verify |
| 58 | |
| 59 | ```bash |
| 60 | # Check local |
| 61 | npx wrangler d1 execute DB_NAME --local --command "PRAGMA table_info(my_table)" |
| 62 | |
| 63 | # Check remote |
| 64 | npx wrangler d1 execute DB_NAME --remote --command "PRAGMA table_info(my_table)" |
| 65 | ``` |
| 66 | |
| 67 | ## Fixing Stuck Migrations |
| 68 | |
| 69 | When a migration partially applied (e.g. column was added but migration wasn't recorded), wrangler retries it and fails on the duplicate column. |
| 70 | |
| 71 | **Symptoms**: `pnpm db:migrate` errors on a migration that looks like it should be done. `PRAGMA table_info` shows the column exists. |
| 72 | |
| 73 | ### Diagnosis |
| 74 | |
| 75 | ```bash |
| 76 | # 1. Verify the column/table exists |
| 77 | npx wrangler d1 execute DB_NAME --remote \ |
| 78 | --command "PRAGMA table_info(my_table)" |
| 79 | |
| 80 | # 2. Check what migrations are recorded |
| 81 | npx wrangler d1 execute DB_NAME --remote \ |
| 82 | --command "SELECT * FROM d1_migrations ORDER BY id" |
| 83 | ``` |
| 84 | |
| 85 | ### Fix |
| 86 | |
| 87 | ```bash |
| 88 | # 3. Manually record the stuck migration |
| 89 | npx wrangler d1 execute DB_NAME --remote \ |
| 90 | --command "INSERT INTO d1_migrations (name, applied_at) VALUES ('0013_my_migration.sql', datetime('now'))" |
| 91 | |
| 92 | # 4. Run remaining migrations normally |
| 93 | pnpm db:migrate |
| 94 | ``` |
| 95 | |
| 96 | ### Prevention |
| 97 | |
| 98 | - `CREATE TABLE IF NOT EXISTS` — safe to re-run |
| 99 | - `ALTER TABLE ADD COLUMN` — SQLite has no `IF NOT EXISTS` variant; check column existence first or use try/catch in application code |
| 100 | - **Always inspect generated SQL** before applying (Step 2 above) |
| 101 | |
| 102 | ## Bulk Insert Batching |
| 103 | |
| 104 | D1's parameter limit causes silent failures with large multi-row INSERTs. Batch into chunks: |
| 105 | |
| 106 | ```typescript |
| 107 | const BATCH_SIZE = 10; |
| 108 | for (let i = 0; i < allRows.length; i += BATCH_SIZE) { |
| 109 | const batch = allRows.slice(i, i + BATCH_SIZE); |
| 110 | await db.insert(myTable).values(batch); |
| 111 | } |
| 112 | ``` |
| 113 | |
| 114 | **Why**: D1 fails when rows x columns exceeds ~100-150 parameters. |
| 115 | |
| 116 | ## Column Naming |
| 117 | |
| 118 | | Context | Convention | Example | |
| 119 | |---------|-----------|---------| |
| 120 | | Drizzle schema | camelCase | `caseNumber: text('case_number')` | |
| 121 | | Raw SQL queries | snake_case | `UPDATE cases SET case_number = ?` | |
| 122 | | API responses | Match SQL aliases | `SELECT case_number FROM cases` | |
| 123 | |
| 124 | ## New Project Setup |
| 125 | |
| 126 | When creating a D1 database for a new project, follow this order: |
| 127 | |
| 128 | 1. **Deploy Worker first** — `npm run build && npx wrangler deploy` |
| 129 | 2. **Create D1 database** — `npx wrangler d1 create project-name-db` |
| 130 | 3. **Copy database_id** to `wrangler.jsonc` `d1_databases` binding |
| 131 | 4. **Redeploy** — `npx wrangler deploy` |
| 132 | 5. **Run migrations** — apply to both local and remote |