Migrations
Safely evolve your schema with @convex-dev/migrations
Migrations
The @convex-dev/migrations component enables stateful, resumable migrations. Use it for backfills, required-field changes, or data transforms across existing documents.
Why Migrations?
Convex validates your schema on deploy. Adding a required field to a table with existing documents fails unless you backfill first.
Migrations let you:
- Deploy with a safe schema (optional/legacy fields allowed)
- Backfill or transform data
- Tighten the schema and clean up
Installation
bun add @convex-dev/migrationsSetup
1. Register the Component
// convex/convex.config.ts
import { defineApp } from "convex/server";
import migrations from "@convex-dev/migrations/convex.config";
const app = defineApp();
app.use(migrations);
export default app;2. Create the Migrations File
// convex/migrations.ts
import { Migrations } from "@convex-dev/migrations";
import { components } from "./_generated/api.js";
import type { DataModel } from "./_generated/dataModel.js";
export const migrations = new Migrations<DataModel>(components.migrations);
// Export the runner for CLI usage
export const run = migrations.runner();Defining Migrations
Basic Field Backfill
Add a default value to existing documents:
export const addIsPinnedToChats = migrations.define({
table: "chats",
migrateOne: async (ctx, doc) => {
if ((doc as Record<string, unknown>).isPinned === undefined) {
await ctx.db.patch(doc._id, { isPinned: false });
}
},
});Returning a Patch
If migrateOne returns an object, it is applied as a patch:
export const setDefaultPriority = migrations.define({
table: "todos",
migrateOne: (ctx, doc) => {
if (doc.priority === undefined) {
return { priority: "medium" };
}
},
});Clearing a Field
Set a field to undefined to remove it:
export const removeDeprecatedField = migrations.define({
table: "users",
migrateOne: () => ({ legacyField: undefined }),
});Data Transformation
Transform data during migration:
export const normalizeEmails = migrations.define({
table: "users",
migrateOne: async (ctx, doc) => {
if (doc.email !== doc.email.toLowerCase()) {
await ctx.db.patch(doc._id, {
email: doc.email.toLowerCase(),
});
}
},
});Running Migrations
Run a specific migration from the CLI:
bunx convex run migrations:run '{"fn": "migrations:addIsPinnedToChats"}' --prodChain migration execution with deployment:
bunx convex deploy -y && bunx convex run migrations:run '{"fn": "migrations:addIsPinnedToChats"}' --prodMigration Status
The CLI returns migration status:
{
"Name": "migrations:addIsPinnedToChats",
"Status": "Migration was started and finished in one batch.",
"lastFinished": "2025-01-26T00:17:10.465Z",
"lastStarted": "2025-01-26T00:17:10.465Z",
"processed": 2
}Required Field Workflow (Correct)
Use this sequence any time you add a required field or remove a legacy field.
Step 1: Make the Change Safe
Allow the old and new shapes to coexist.
// convex/tables/chats.ts
export const chatsZodSchema = {
organizationId: z.string(),
memberId: z.string().optional(),
title: z.string(),
isPinned: z.boolean().optional(),
updatedAt: z.number(),
};Step 2: Deploy the Safe Schema + Migration Code
bunx convex deploy -yStep 3: Run the Migration (Dev, then Prod)
bunx convex run migrations:run '{"fn": "migrations:addIsPinnedToChats"}'
bunx convex run migrations:run '{"fn": "migrations:addIsPinnedToChats"}' --prodStep 4: Tighten the Schema
Make the field required and remove legacy fields after backfill.
export const chatsZodSchema = {
organizationId: z.string(),
memberId: z.string(),
title: z.string(),
isPinned: z.boolean(),
updatedAt: z.number(),
};Step 5: Deploy Final Schema
bunx convex deploy -yStep 6: Cleanup
Delete temporary migrations and legacy-field handling once all environments are updated.
Batch Processing
Migrations run in batches to avoid timeouts. Large tables are processed across multiple function invocations:
export const heavyMigration = migrations.define({
table: "largeTable",
batchSize: 100, // Process 100 docs per batch (default)
migrateOne: async (ctx, doc) => {
// Complex transformation
},
});Best Practices
1. Always Check for Existing Values
migrateOne: async (ctx, doc) => {
// Check before patching to make migrations idempotent
if ((doc as Record<string, unknown>).newField === undefined) {
await ctx.db.patch(doc._id, { newField: "default" });
}
}2. Test on Dev First
# Run on dev deployment first
bunx convex run migrations:run '{"fn": "migrations:myMigration"}'
# Then on prod
bunx convex run migrations:run '{"fn": "migrations:myMigration"}' --prod3. Keep Migrations Small
Split large changes into multiple migrations:
// Good: Separate migrations
export const addFieldA = migrations.define({ ... });
export const addFieldB = migrations.define({ ... });
// Bad: One massive migration
export const addEverything = migrations.define({ ... });4. Document Migration Order
If migrations depend on each other, document the order:
// Run in order:
// 1. migrations:addIsPinned
// 2. migrations:setPinnedIndex
// 3. migrations:migrateOldPinnedData5. Clean Up Old Migrations
After migrations have run on all environments, remove them to keep the codebase clean.
Troubleshooting
Schema Validation Failed
If deployment fails with schema validation errors:
- Make the new field optional
- Deploy
- Run the migration
- Make the field required
- Deploy again
Migration Timeout
For large tables, migrations run in batches automatically. If you still hit timeouts, reduce batchSize:
migrations.define({
table: "hugeTable",
batchSize: 50, // Smaller batches
migrateOne: async (ctx, doc) => { ... },
});Partial Migration
If a migration fails partway through, it can be safely rerun. The migration tracks progress and continues from where it left off.
Next, learn about Pagination.