Just-Use-Convex Docs
Advanced Patterns

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:

  1. Deploy with a safe schema (optional/legacy fields allowed)
  2. Backfill or transform data
  3. Tighten the schema and clean up

Installation

bun add @convex-dev/migrations

Setup

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"}' --prod

Chain migration execution with deployment:

bunx convex deploy -y && bunx convex run migrations:run '{"fn": "migrations:addIsPinnedToChats"}' --prod

Migration 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 -y

Step 3: Run the Migration (Dev, then Prod)

bunx convex run migrations:run '{"fn": "migrations:addIsPinnedToChats"}'
bunx convex run migrations:run '{"fn": "migrations:addIsPinnedToChats"}' --prod

Step 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 -y

Step 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"}' --prod

3. 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:migrateOldPinnedData

5. 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:

  1. Make the new field optional
  2. Deploy
  3. Run the migration
  4. Make the field required
  5. 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.