Loading skill
Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Plan and execute Convex schema migrations safely, including adding fields, creating tables, and data transformations. Use when schema changes affect existing data.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-07 | ✗→✓ | ▲ Improved | 110% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 109% | 0% |
| case-08 | ✓→✓ | = Same ✓ | 75% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 81% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 147% | 0% |
Safely migrate Convex schemas and data when making breaking changes.
typescript// Before users: defineTable({ name: v.string(), }) // After - Safe! New field is optional users: defineTable({ name: v.string(), bio: v.optional(v.string()), })
typescript// Safe to add completely new tables posts: defineTable({ userId: v.id("users"), title: v.string(), }).index("by_user", ["userId"])
typescript// Safe to add indexes at any time users: defineTable({ name: v.string(), email: v.string(), }) .index("by_email", ["email"]) // New index
Problem: Existing documents won't have the new field.
Solution: Add as optional first, backfill data, then make required.
typescript// Step 1: Add as optional users: defineTable({ name: v.string(), email: v.optional(v.string()), // Start optional }) // Step 2: Create migration import { internalMutation } from "./_generated/server"; import { v } from "convex/values"; export const backfillEmails = internalMutation({ args: {}, handler: async (ctx) => { const users = await ctx.db.query("users").collect(); for (const user of users) { if (!user.email) { await ctx.db.patch(user._id, { email: `user-${user._id}@example.com`, // Default value }); } } }, }); // Step 3: Run migration via dashboard or CLI // npx convex run migrations:backfillEmails // Step 4: Make field required (after all data migrated) users: defineTable({ name: v.string(), email: v.string(), // Now required })
Example: Change tags: v.array(v.string()) to separate table
typescript// Step 1: Create new structure (additive) tags: defineTable({ name: v.string(), }).index("by_name", ["name"]), postTags: defineTable({ postId: v.id("posts"), tagId: v.id("tags"), }) .index("by_post", ["postId"]) .index("by_tag", ["tagId"]), // Keep old field as optional during migration posts: defineTable({ title: v.string(), tags: v.optional(v.array(v.string())), // Keep temporarily }) // Step 2: Write migration export const migrateTags = internalMutation({ args: { batchSize: v.optional(v.number()) }, handler: async (ctx, args) => { const batchSize = args.batchSize ?? 100; const posts = await ctx.db .query("posts") .filter(q => q.neq(q.field("tags"), undefined)) .take(batchSize); for (const post of posts) { if (!post.tags || post.tags.length === 0) { await ctx.db.patch(post._id, { tags: undefined }); continue; } // Create tags and relationships for (const tagName of post.tags) { // Get or create tag let tag = await ctx.db .query("tags") .withIndex("by_name", q => q.eq("name", tagName)) .unique(); if (!tag) { const tagId = await ctx.db.insert("tags", { name: tagName }); tag = { _id: tagId, name: tagName }; } // Create relationship const existing = await ctx.db .query("postTags") .withIndex("by_post", q => q.eq("postId", post._id)) .filter(q => q.eq(q.field("tagId"), tag._id)) .unique(); if (!existing) { await ctx.db.insert("postTags", { postId: post._id, tagId: tag._id, }); } } // Remove old field await ctx.db.patch(post._id, { tags: undefined }); } return { migrated: posts.length }; }, }); // Step 3: Run in batches via cron or manually // Run multiple times until all migrated // Step 4: Remove old field from schema posts: defineTable({ title: v.string(), // tags field removed })
typescript// Step 1: Add new field (optional) users: defineTable({ name: v.string(), displayName: v.optional(v.string()), // New name }) // Step 2: Copy data export const renameField = internalMutation({ handler: async (ctx) => { const users = await ctx.db.query("users").collect(); for (const user of users) { await ctx.db.patch(user._id, { displayName: user.name, }); } }, }); // Step 3: Update schema (remove old field) users: defineTable({ displayName: v.string(), }) // Step 4: Update all code to use new field name
For large tables, process in batches:
typescriptexport const migrateBatch = internalMutation({ args: { cursor: v.optional(v.string()), batchSize: v.number(), }, handler: async (ctx, args) => { const batchSize = args.batchSize; let query = ctx.db.query("largeTable"); // Use cursor for pagination if needed const items = await query.take(batchSize); for (const item of items) { await ctx.db.patch(item._id, { // migration logic }); } return { processed: items.length, hasMore: items.length === batchSize, }; }, });
Use cron jobs for gradual migration:
typescript// convex/crons.ts import { cronJobs } from "convex/server"; import { internal } from "./_generated/api"; const crons = cronJobs(); crons.interval( "migrate-batch", { minutes: 5 }, // Every 5 minutes internal.migrations.migrateBatch, { batchSize: 100 } ); export default crons;
For zero-downtime migrations:
typescript// Write to both old and new structure during transition export const createPost = mutation({ args: { title: v.string(), tags: v.array(v.string()) }, handler: async (ctx, args) => { const user = await getCurrentUser(ctx); // Create post const postId = await ctx.db.insert("posts", { userId: user._id, title: args.title, // Keep writing old field during migration tags: args.tags, }); // ALSO write to new structure for (const tagName of args.tags) { let tag = await ctx.db .query("tags") .withIndex("by_name", q => q.eq("name", tagName)) .unique(); if (!tag) { const tagId = await ctx.db.insert("tags", { name: tagName }); tag = { _id: tagId }; } await ctx.db.insert("postTags", { postId, tagId: tag._id, }); } return postId; }, }); // After migration complete, remove old writes
typescriptexport const verifyMigration = query({ args: {}, handler: async (ctx) => { const total = (await ctx.db.query("users").collect()).length; const migrated = (await ctx.db .query("users") .filter(q => q.neq(q.field("newField"), undefined)) .collect() ).length; return { total, migrated, remaining: total - migrated, percentComplete: (migrated / total) * 100, }; }, });
typescript// 1. Current schema export default defineSchema({ users: defineTable({ name: v.string(), }), }); // 2. Add optional field export default defineSchema({ users: defineTable({ name: v.string(), role: v.optional(v.union( v.literal("user"), v.literal("admin") )), }), }); // 3. Migration function export const addDefaultRoles = internalMutation({ handler: async (ctx) => { const users = await ctx.db.query("users").collect(); for (const user of users) { if (!user.role) { await ctx.db.patch(user._id, { role: "user" }); } } }, }); // 4. Run migration: npx convex run migrations:addDefaultRoles // 5. Verify: Check all users have role // 6. Make required export default defineSchema({ users: defineTable({ name: v.string(), role: v.union( v.literal("user"), v.literal("admin") ), }), });
Other measured skills in the registry, with their headline benchmark lift.