Use when making database schema changes, generating migrations, or resolving migration conflicts after merging upstream branches. Handles the full workflow from schema modification through conflict resolution using preserve-and-restore approach.
Drizzle auto-generates migrations from schema diffs, tracking state via snapshot files. Migration conflicts after upstream merges require a preserve-and-restore workflow to avoid breaking the snapshot chain.
migrations/meta/_journal.json| Task | Command | Notes |
|---|---|---|
| Generate migration | bun db:generate | Auto-generates from schema diff |
| Generate custom SQL |
bun db:generate --custom |
| Opens SQL file for manual migration |
| Apply migrations | bun db:migrate | Runs prep-extensions first |
| Check migration status | git diff master...HEAD -- migrations/ | See what changed |
| List migrations | cat migrations/meta/_journal.json | See numbered sequence |
| Detect conflicts | git status | Look for "both added" in migrations/ |
Modify your schema in src/db/schema.ts following these patterns:
Naming conventions:
"FirmPlans", "JobPacks")"firm_name", "created_at")firmPlans, jobPacks)Standard patterns:
// Use timestamps helper for created_at/updated_at
const timestamps = {
createdAt: timestamp("created_at", { withTimezone: true, mode: "string" })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true, mode: "string" })
.defaultNow()
.notNull(),
} as const;
// Define enums before using in tables
export const firmPlanState = pgEnum("firm_plan_state", [
"new",
"freemium",
"trial",
"paid",
"archived",
]);
// Standard table with UUID PK, timestamps, foreign keys, indexes
export const firmPlans = pgTable(
"FirmPlans",
{
id: uuid().defaultRandom().primaryKey().notNull(),
...timestamps,
firmsId: uuid("firms_id")
.notNull()
.references(() => firms.id, { onDelete: "cascade" }),
state: firmPlanState("state").notNull(),
planName: text("plan_name"),
startDate: timestamp("start_date", { withTimezone: true, mode: "string" }).notNull(),
},
(table) => ({
firmsIdStateIdx: index("FirmPlans_firms_id_state_idx").on(table.firmsId, table.state),
})
);
Standard workflow:
src/db/schema.tsbun db:generatemigrations/XXXX_name.sql exists with DDL statementsmigrations/meta/XXXX_snapshot.json exists with matching numbermigrations/meta/_journal.json has new entry with matching tagbun db:generate again - it should generate NOTHING (confirms no drift)# Tell user to run these commands:
git add migrations/
git status # Review what's being staged
git commit -m "Add migration: XXXX_description"
Wait for user confirmation before proceedingFor custom SQL (backfills, data migrations):
bun db:generate --custom-- Auto-generated DDL first
CREATE TABLE IF NOT EXISTS "FirmPlans" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
...
);
-- Then add custom backfill in DO $$ block
DO $$
BEGIN
INSERT INTO "FirmPlans" (firms_id, state, start_date)
SELECT f.id, 'new'::"firm_plan_state", f.created_at
FROM "Firms" f
WHERE NOT EXISTS (
SELECT 1 FROM "FirmPlans" fp WHERE fp.firms_id = f.id
)
ON CONFLICT DO NOTHING;
END $$;
When this happens: After merging upstream, git shows conflicts because two branches created the same migration number (e.g., your 0028_your_feature.sql vs master's 0028_different_feature.sql).
The preserve-and-restore workflow:
First, note your original migration name to preserve it:
# Note the descriptive name (e.g., 0028_man_under_water.sql)
# You'll use this name with the new number later (0029_man_under_water.sql)
ls migrations/0028_*.sql
Check if your migration has custom SQL beyond auto-generated schema DDL:
# Look for custom DO $$ blocks, backfills, data migrations
grep -A 20 "DO \$\$" migrations/0028_your_migration.sql
# If found, save to temporary file
grep -A 50 "DO \$\$" migrations/0028_your_migration.sql > /tmp/custom_sql_backup.txt
# Remove your migration SQL file
rm migrations/0028_your_migration.sql
# Remove your snapshot file
rm migrations/meta/0028_snapshot.json
Open migrations/meta/_journal.json and remove your conflicting entry:
{
"entries": [
...
{
"idx": 27,
"version": "7",
"when": 1234567890,
"tag": "0027_previous_migration",
...
},
// DELETE THIS ENTRY (your 0028):
{
"idx": 28,
"version": "7",
"when": 1767593590704,
"tag": "0028_your_migration",
...
}
// Keep master's entries (they'll be added during merge)
]
}
Save the file after removing ONLY your conflicting entry.
STOP: Prompt user to merge (do not run git operations yourself):
Tell user to run:
# Complete your merge/rebase
git pull origin master
# OR
git rebase master
Wait for user confirmation that merge is complete before proceeding to Step 5.
# This creates the next available number (0029, 0030, etc.) with a random name
bun db:generate
# Preserve your original descriptive name
# If original was 0028_man_under_water.sql, rename to 0029_man_under_water.sql
mv migrations/0029_random_generated_name.sql migrations/0029_man_under_water.sql
mv migrations/meta/0029_snapshot.json migrations/meta/0029_snapshot.json # Snapshot keeps same number
Update the journal entry to match your preserved name:
# Edit migrations/meta/_journal.json
# Find the newest entry (idx: 29) and update the "tag" field:
# Change: "tag": "0029_random_generated_name"
# To: "tag": "0029_man_under_water"
STOP: Verify migration integrity (triple-check - DO NOT skip):
migrations/0029_man_under_water.sql existsmigrations/meta/0029_snapshot.json exists with matching numbermigrations/meta/_journal.json has entry with tag "0029_man_under_water"bun db:generate again - should generate NOTHING (confirms no drift)Why preserve the name: The descriptive name helps track what changes are in the migration. Using the same name (with updated number) maintains continuity and makes it clear this is the same feature that was renumbered due to conflicts.
If you had custom backfills or data migrations:
migrations/0029_new_name.sql)-- Auto-generated DDL (already in file)
CREATE TABLE IF NOT EXISTS "YourTable" (...);
CREATE INDEX IF NOT EXISTS ...;
-- Manually restored custom SQL (add this)
DO $$
BEGIN
-- Your backfill logic here
INSERT INTO "YourTable" ...
END $$;
STOP: Triple-check everything (DO NOT skip):
bun db:generate - should generate NOTHINGSTOP: Prompt user for git operations (NEVER auto-commit):
Tell user to run:
git add migrations/
git status # Review what's being staged
git commit -m "Resolve migration conflict: renumber to 0029_man_under_water"
Wait for user confirmation before proceeding.
These thoughts mean you're about to corrupt the migration chain:
| Thought | Reality |
|---|---|
| "I'll skip verification, user is waiting" | 30 seconds prevents hours of debugging schema corruption |
| "The tool worked, files must be fine" | Tools generate files, not validate logic. Verify ALWAYS |
| "I'll commit this for the user" | Migrations require human review. NEVER auto-commit |
| "I'll just move files around manually" | Snapshots are cryptographically linked. Regenerate, don't rename |
| "I'll check if it breaks later" | Schema corruption is undetectable until production fails |
| "One file exists, others must too" | Journal/snapshot/SQL can be out of sync. Check ALL three |
All of these mean: STOP. Follow the verification checklist.
DO NOT do any of these:
| Mistake | Why It Breaks | Instead |
|---|---|---|
| Manually renumber migration to 0029 | Snapshot IDs won't match the SQL file. Drizzle can't track state. | Delete and regenerate via bun db:generate |
| Merge migration SQL files | Each snapshot.json is tied to specific SQL content. Merging corrupts the link. | Follow preserve-and-restore workflow |
| Edit snapshot.json manually | Snapshots are generated artifacts. Manual edits create undetectable corruption. | Never touch snapshot files |
| Forget to remove journal entry | Journal is append-only. Stale entries cause numbering conflicts. | Always manually remove your conflicting entry |
| Delete migration without saving custom SQL | Data migration logic is permanently lost. Can't be auto-regenerated. | ALWAYS grep for "DO $$" and save first |
Run bun db:generate before cleanup | Drizzle sees conflict state and creates malformed migration. | Clean up conflicts completely before regenerating |
Rationalization Counters:
drizzle, migration, conflict, journal, snapshot, merge, upstream, schema, generate, backfill, custom SQL, enum, foreign key, preserve-and-restore, both added, git conflict, DO $$, data migration, idempotent, verification, triple-check, drift, schema drift, auto-commit, prompt user, matching files, snapshot chain corruption