convex-migrations

Safe schema evolution with optional fields, batched backfills, and zero-downtime migration patterns. Supports adding fields as optional, backfilling data in batches, renaming fields, removing deprecated fields, and changing field types through multi-step migrations Includes a migration tracking system to prevent re-running completed migrations and monitor progress across batches Provides patterns for index management, type conversions with fallback logic, and graceful handling of undefined values during transitions Uses Convex's scheduler for batched processing of large datasets and internal mutations to coordinate schema changes with data updates

INSTALLATION
npx skills add https://github.com/waynesutton/convexskills --skill convex-migrations
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

$2a

  • No explicit migration files or commands
  • Schema changes deploy instantly with npx convex dev
  • Existing data is not automatically transformed
  • Use optional fields and backfill mutations for safe migrations

Adding New Fields

Start with optional fields, then backfill:

// Step 1: Add optional field to schema

// convex/schema.ts

import { defineSchema, defineTable } from "convex/server";

import { v } from "convex/values";

export default defineSchema({

  users: defineTable({

    name: v.string(),

    email: v.string(),

    // New field - start as optional

    avatarUrl: v.optional(v.string()),

  }),

});
// Step 2: Update code to handle both cases

// convex/users.ts

import { query } from "./_generated/server";

import { v } from "convex/values";

export const getUser = query({

  args: { userId: v.id("users") },

  returns: v.union(

    v.object({

      _id: v.id("users"),

      name: v.string(),

      email: v.string(),

      avatarUrl: v.union(v.string(), v.null()),

    }),

    v.null()

  ),

  handler: async (ctx, args) => {

    const user = await ctx.db.get(args.userId);

    if (!user) return null;

    return {

      _id: user._id,

      name: user.name,

      email: user.email,

      // Handle missing field gracefully

      avatarUrl: user.avatarUrl ?? null,

    };

  },

});
// Step 3: Backfill existing documents

// convex/migrations.ts

import { internalMutation } from "./_generated/server";

import { internal } from "./_generated/api";

import { v } from "convex/values";

const BATCH_SIZE = 100;

export const backfillAvatarUrl = internalMutation({

  args: {

    cursor: v.optional(v.string()),

  },

  returns: v.object({

    processed: v.number(),

    hasMore: v.boolean(),

  }),

  handler: async (ctx, args) => {

    const result = await ctx.db

      .query("users")

      .paginate({ numItems: BATCH_SIZE, cursor: args.cursor ?? null });

    let processed = 0;

    for (const user of result.page) {

      // Only update if field is missing

      if (user.avatarUrl === undefined) {

        await ctx.db.patch(user._id, {

          avatarUrl: generateDefaultAvatar(user.name),

        });

        processed++;

      }

    }

    // Schedule next batch if needed

    if (!result.isDone) {

      await ctx.scheduler.runAfter(0, internal.migrations.backfillAvatarUrl, {

        cursor: result.continueCursor,

      });

    }

    return {

      processed,

      hasMore: !result.isDone,

    };

  },

});

function generateDefaultAvatar(name: string): string {

  return `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(name)}`;

}
// Step 4: After backfill completes, make field required

// convex/schema.ts

export default defineSchema({

  users: defineTable({

    name: v.string(),

    email: v.string(),

    avatarUrl: v.string(), // Now required

  }),

});

Removing Fields

Remove field usage before removing from schema:

// Step 1: Stop using the field in queries and mutations

// Mark as deprecated in code comments

// Step 2: Remove field from schema (make optional first if needed)

// convex/schema.ts

export default defineSchema({

  posts: defineTable({

    title: v.string(),

    content: v.string(),

    authorId: v.id("users"),

    // legacyField: v.optional(v.string()), // Remove this line

  }),

});

// Step 3: Optionally clean up existing data

// convex/migrations.ts

export const removeDeprecatedField = internalMutation({

  args: {

    cursor: v.optional(v.string()),

  },

  returns: v.null(),

  handler: async (ctx, args) => {

    const result = await ctx.db

      .query("posts")

      .paginate({ numItems: 100, cursor: args.cursor ?? null });

    for (const post of result.page) {

      // Use replace to remove the field entirely

      const { legacyField, ...rest } = post as typeof post & { legacyField?: string };

      if (legacyField !== undefined) {

        await ctx.db.replace(post._id, rest);

      }

    }

    if (!result.isDone) {

      await ctx.scheduler.runAfter(0, internal.migrations.removeDeprecatedField, {

        cursor: result.continueCursor,

      });

    }

    return null;

  },

});

Renaming Fields

Renaming requires copying data to new field, then removing old:

// Step 1: Add new field as optional

// convex/schema.ts

export default defineSchema({

  users: defineTable({

    userName: v.string(), // Old field

    displayName: v.optional(v.string()), // New field

  }),

});

// Step 2: Update code to read from new field with fallback

export const getUser = query({

  args: { userId: v.id("users") },

  returns: v.object({

    _id: v.id("users"),

    displayName: v.string(),

  }),

  handler: async (ctx, args) => {

    const user = await ctx.db.get(args.userId);

    if (!user) throw new Error("User not found");

    return {

      _id: user._id,

      // Read new field, fall back to old

      displayName: user.displayName ?? user.userName,

    };

  },

});

// Step 3: Backfill to copy data

export const backfillDisplayName = internalMutation({

  args: { cursor: v.optional(v.string()) },

  returns: v.null(),

  handler: async (ctx, args) => {

    const result = await ctx.db

      .query("users")

      .paginate({ numItems: 100, cursor: args.cursor ?? null });

    for (const user of result.page) {

      if (user.displayName === undefined) {

        await ctx.db.patch(user._id, {

          displayName: user.userName,

        });

      }

    }

    if (!result.isDone) {

      await ctx.scheduler.runAfter(0, internal.migrations.backfillDisplayName, {

        cursor: result.continueCursor,

      });

    }

    return null;

  },

});

// Step 4: After backfill, update schema to make new field required

// and remove old field

export default defineSchema({

  users: defineTable({

    // userName removed

    displayName: v.string(),

  }),

});

Adding Indexes

Add indexes before using them in queries:

// Step 1: Add index to schema

// convex/schema.ts

export default defineSchema({

  posts: defineTable({

    title: v.string(),

    authorId: v.id("users"),

    publishedAt: v.optional(v.number()),

    status: v.string(),

  })

    .index("by_author", ["authorId"])

    // New index

    .index("by_status_and_published", ["status", "publishedAt"]),

});

// Step 2: Deploy schema change

// Run: npx convex dev

// Step 3: Now use the index in queries

export const getPublishedPosts = query({

  args: {},

  returns: v.array(v.object({

    _id: v.id("posts"),

    title: v.string(),

    publishedAt: v.number(),

  })),

  handler: async (ctx) => {

    const posts = await ctx.db

      .query("posts")

      .withIndex("by_status_and_published", (q) =>

        q.eq("status", "published")

      )

      .order("desc")

      .take(10);

    return posts

      .filter((p) => p.publishedAt !== undefined)

      .map((p) => ({

        _id: p._id,

        title: p.title,

        publishedAt: p.publishedAt!,

      }));

  },

});

Changing Field Types

Type changes require careful migration:

// Example: Change from string to number for a "priority" field

// Step 1: Add new field with new type

// convex/schema.ts

export default defineSchema({

  tasks: defineTable({

    title: v.string(),

    priority: v.string(), // Old: "low", "medium", "high"

    priorityLevel: v.optional(v.number()), // New: 1, 2, 3

  }),

});

// Step 2: Backfill with type conversion

export const migratePriorityToNumber = internalMutation({

  args: { cursor: v.optional(v.string()) },

  returns: v.null(),

  handler: async (ctx, args) => {

    const result = await ctx.db

      .query("tasks")

      .paginate({ numItems: 100, cursor: args.cursor ?? null });

    const priorityMap: Record<string, number> = {

      low: 1,

      medium: 2,

      high: 3,

    };

    for (const task of result.page) {

      if (task.priorityLevel === undefined) {

        await ctx.db.patch(task._id, {

          priorityLevel: priorityMap[task.priority] ?? 1,

        });

      }

    }

    if (!result.isDone) {

      await ctx.scheduler.runAfter(0, internal.migrations.migratePriorityToNumber, {

        cursor: result.continueCursor,

      });

    }

    return null;

  },

});

// Step 3: Update code to use new field

export const getTask = query({

  args: { taskId: v.id("tasks") },

  returns: v.object({

    _id: v.id("tasks"),

    title: v.string(),

    priorityLevel: v.number(),

  }),

  handler: async (ctx, args) => {

    const task = await ctx.db.get(args.taskId);

    if (!task) throw new Error("Task not found");

    const priorityMap: Record<string, number> = {

      low: 1,

      medium: 2,

      high: 3,

    };

    return {

      _id: task._id,

      title: task.title,

      priorityLevel: task.priorityLevel ?? priorityMap[task.priority] ?? 1,

    };

  },

});

// Step 4: After backfill, update schema

export default defineSchema({

  tasks: defineTable({

    title: v.string(),

    // priority field removed

    priorityLevel: v.number(),

  }),

});

Migration Runner Pattern

Create a reusable migration system:

// convex/schema.ts

import { defineSchema, defineTable } from "convex/server";

import { v } from "convex/values";

export default defineSchema({

  migrations: defineTable({

    name: v.string(),

    startedAt: v.number(),

    completedAt: v.optional(v.number()),

    status: v.union(

      v.literal("running"),

      v.literal("completed"),

      v.literal("failed")

    ),

    error: v.optional(v.string()),

    processed: v.number(),

  }).index("by_name", ["name"]),

  // Your other tables...

});
// convex/migrations.ts

import { internalMutation, internalQuery } from "./_generated/server";

import { internal } from "./_generated/api";

import { v } from "convex/values";

// Check if migration has run

export const hasMigrationRun = internalQuery({

  args: { name: v.string() },

  returns: v.boolean(),

  handler: async (ctx, args) => {

    const migration = await ctx.db

      .query("migrations")

      .withIndex("by_name", (q) => q.eq("name", args.name))

      .first();

    return migration?.status === "completed";

  },

});

// Start a migration

export const startMigration = internalMutation({

  args: { name: v.string() },

  returns: v.id("migrations"),

  handler: async (ctx, args) => {

    // Check if already exists

    const existing = await ctx.db

      .query("migrations")

      .withIndex("by_name", (q) => q.eq("name", args.name))

      .first();

    if (existing) {

      if (existing.status === "completed") {

        throw new Error(`Migration ${args.name} already completed`);

      }

      if (existing.status === "running") {

        throw new Error(`Migration ${args.name} already running`);

      }

      // Reset failed migration

      await ctx.db.patch(existing._id, {

        status: "running",

        startedAt: Date.now(),

        error: undefined,

        processed: 0,

      });

      return existing._id;

    }

    return await ctx.db.insert("migrations", {

      name: args.name,

      startedAt: Date.now(),

      status: "running",

      processed: 0,

    });

  },

});

// Update migration progress

export const updateMigrationProgress = internalMutation({

  args: {

    migrationId: v.id("migrations"),

    processed: v.number(),

  },

  returns: v.null(),

  handler: async (ctx, args) => {

    const migration = await ctx.db.get(args.migrationId);

    if (!migration) return null;

    await ctx.db.patch(args.migrationId, {

      processed: migration.processed + args.processed,

    });

    return null;

  },

});

// Complete a migration

export const completeMigration = internalMutation({

  args: { migrationId: v.id("migrations") },

  returns: v.null(),

  handler: async (ctx, args) => {

    await ctx.db.patch(args.migrationId, {

      status: "completed",

      completedAt: Date.now(),

    });

    return null;

  },

});

// Fail a migration

export const failMigration = internalMutation({

  args: {

    migrationId: v.id("migrations"),

    error: v.string(),

  },

  returns: v.null(),

  handler: async (ctx, args) => {

    await ctx.db.patch(args.migrationId, {

      status: "failed",

      error: args.error,

    });

    return null;

  },

});
// convex/migrations/addUserTimestamps.ts

import { internalMutation } from "../_generated/server";

import { internal } from "../_generated/api";

import { v } from "convex/values";

const MIGRATION_NAME = "add_user_timestamps_v1";

const BATCH_SIZE = 100;

export const run = internalMutation({

  args: {

    migrationId: v.optional(v.id("migrations")),

    cursor: v.optional(v.string()),

  },

  returns: v.null(),

  handler: async (ctx, args) => {

    // Initialize migration on first run

    let migrationId = args.migrationId;

    if (!migrationId) {

      const hasRun = await ctx.runQuery(internal.migrations.hasMigrationRun, {

        name: MIGRATION_NAME,

      });

      if (hasRun) {

        console.log(`Migration ${MIGRATION_NAME} already completed`);

        return null;

      }

      migrationId = await ctx.runMutation(internal.migrations.startMigration, {

        name: MIGRATION_NAME,

      });

    }

    try {

      const result = await ctx.db

        .query("users")

        .paginate({ numItems: BATCH_SIZE, cursor: args.cursor ?? null });

      let processed = 0;

      for (const user of result.page) {

        if (user.createdAt === undefined) {

          await ctx.db.patch(user._id, {

            createdAt: user._creationTime,

            updatedAt: user._creationTime,

          });

          processed++;

        }

      }

      // Update progress

      await ctx.runMutation(internal.migrations.updateMigrationProgress, {

        migrationId,

        processed,

      });

      // Continue or complete

      if (!result.isDone) {

        await ctx.scheduler.runAfter(0, internal.migrations.addUserTimestamps.run, {

          migrationId,

          cursor: result.continueCursor,

        });

      } else {

        await ctx.runMutation(internal.migrations.completeMigration, {

          migrationId,

        });

        console.log(`Migration ${MIGRATION_NAME} completed`);

      }

    } catch (error) {

      await ctx.runMutation(internal.migrations.failMigration, {

        migrationId,

        error: String(error),

      });

      throw error;

    }

    return null;

  },

});

Examples

Schema with Migration Support

// convex/schema.ts

import { defineSchema, defineTable } from "convex/server";

import { v } from "convex/values";

export default defineSchema({

  // Migration tracking

  migrations: defineTable({

    name: v.string(),

    startedAt: v.number(),

    completedAt: v.optional(v.number()),

    status: v.union(

      v.literal("running"),

      v.literal("completed"),

      v.literal("failed")

    ),

    error: v.optional(v.string()),

    processed: v.number(),

  }).index("by_name", ["name"]),

  // Users table with evolved schema

  users: defineTable({

    // Original fields

    name: v.string(),

    email: v.string(),

    // Added in migration v1

    createdAt: v.optional(v.number()),

    updatedAt: v.optional(v.number()),

    // Added in migration v2

    avatarUrl: v.optional(v.string()),

    // Added in migration v3

    settings: v.optional(v.object({

      theme: v.string(),

      notifications: v.boolean(),

    })),

  })

    .index("by_email", ["email"])

    .index("by_createdAt", ["createdAt"]),

  // Posts table with indexes for common queries

  posts: defineTable({

    title: v.string(),

    content: v.string(),

    authorId: v.id("users"),

    status: v.union(

      v.literal("draft"),

      v.literal("published"),

      v.literal("archived")

    ),

    publishedAt: v.optional(v.number()),

    createdAt: v.number(),

    updatedAt: v.number(),

  })

    .index("by_author", ["authorId"])

    .index("by_status", ["status"])

    .index("by_author_and_status", ["authorId", "status"])

    .index("by_publishedAt", ["publishedAt"]),

});

Best Practices

  • Never run npx convex deploy unless explicitly instructed
  • Never run any git commands unless explicitly instructed
  • Always start with optional fields when adding new data
  • Backfill data in batches to avoid timeouts
  • Test migrations on development before production
  • Keep track of completed migrations to avoid re-running
  • Update code to handle both old and new data during transition
  • Remove deprecated fields only after all code stops using them
  • Use pagination for large datasets
  • Add appropriate indexes before running queries on new fields

Common Pitfalls

  • Making new fields required immediately - Breaks existing documents
  • Not handling undefined values - Causes runtime errors
  • Large batch sizes - Causes function timeouts
  • Forgetting to update indexes - Queries fail or perform poorly
  • Running migrations without tracking - May run multiple times
  • Removing fields before code update - Breaks existing functionality
  • Not testing on development - Production data issues

References

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card