$npx -y skills add waynesutton/convexskills --skill convex-migrationsSchema migration strategies for evolving applications including adding new fields, backfilling data, removing deprecated fields, index migrations, and zero-downtime migration patterns
| 1 | # Convex Migrations |
| 2 | |
| 3 | Evolve your Convex database schema safely with patterns for adding fields, backfilling data, removing deprecated fields, and maintaining zero-downtime deployments. |
| 4 | |
| 5 | ## Documentation Sources |
| 6 | |
| 7 | Before implementing, do not assume; fetch the latest documentation: |
| 8 | |
| 9 | - Primary: https://docs.convex.dev/database/schemas |
| 10 | - Schema Overview: https://docs.convex.dev/database |
| 11 | - Migration Patterns: https://stack.convex.dev/migrate-data-postgres-to-convex |
| 12 | - For broader context: https://docs.convex.dev/llms.txt |
| 13 | |
| 14 | ## Instructions |
| 15 | |
| 16 | ### Migration Philosophy |
| 17 | |
| 18 | Convex handles schema evolution differently than traditional databases: |
| 19 | |
| 20 | - No explicit migration files or commands |
| 21 | - Schema changes deploy instantly with `npx convex dev` |
| 22 | - Existing data is not automatically transformed |
| 23 | - Use optional fields and backfill mutations for safe migrations |
| 24 | |
| 25 | ### Adding New Fields |
| 26 | |
| 27 | Start with optional fields, then backfill: |
| 28 | |
| 29 | ```typescript |
| 30 | // Step 1: Add optional field to schema |
| 31 | // convex/schema.ts |
| 32 | import { defineSchema, defineTable } from "convex/server"; |
| 33 | import { v } from "convex/values"; |
| 34 | |
| 35 | export default defineSchema({ |
| 36 | users: defineTable({ |
| 37 | name: v.string(), |
| 38 | email: v.string(), |
| 39 | // New field - start as optional |
| 40 | avatarUrl: v.optional(v.string()), |
| 41 | }), |
| 42 | }); |
| 43 | ``` |
| 44 | |
| 45 | ```typescript |
| 46 | // Step 2: Update code to handle both cases |
| 47 | // convex/users.ts |
| 48 | import { query } from "./_generated/server"; |
| 49 | import { v } from "convex/values"; |
| 50 | |
| 51 | export const getUser = query({ |
| 52 | args: { userId: v.id("users") }, |
| 53 | returns: v.union( |
| 54 | v.object({ |
| 55 | _id: v.id("users"), |
| 56 | name: v.string(), |
| 57 | email: v.string(), |
| 58 | avatarUrl: v.union(v.string(), v.null()), |
| 59 | }), |
| 60 | v.null() |
| 61 | ), |
| 62 | handler: async (ctx, args) => { |
| 63 | const user = await ctx.db.get(args.userId); |
| 64 | if (!user) return null; |
| 65 | |
| 66 | return { |
| 67 | _id: user._id, |
| 68 | name: user.name, |
| 69 | email: user.email, |
| 70 | // Handle missing field gracefully |
| 71 | avatarUrl: user.avatarUrl ?? null, |
| 72 | }; |
| 73 | }, |
| 74 | }); |
| 75 | ``` |
| 76 | |
| 77 | ```typescript |
| 78 | // Step 3: Backfill existing documents |
| 79 | // convex/migrations.ts |
| 80 | import { internalMutation } from "./_generated/server"; |
| 81 | import { internal } from "./_generated/api"; |
| 82 | import { v } from "convex/values"; |
| 83 | |
| 84 | const BATCH_SIZE = 100; |
| 85 | |
| 86 | export const backfillAvatarUrl = internalMutation({ |
| 87 | args: { |
| 88 | cursor: v.optional(v.string()), |
| 89 | }, |
| 90 | returns: v.object({ |
| 91 | processed: v.number(), |
| 92 | hasMore: v.boolean(), |
| 93 | }), |
| 94 | handler: async (ctx, args) => { |
| 95 | const result = await ctx.db |
| 96 | .query("users") |
| 97 | .paginate({ numItems: BATCH_SIZE, cursor: args.cursor ?? null }); |
| 98 | |
| 99 | let processed = 0; |
| 100 | for (const user of result.page) { |
| 101 | // Only update if field is missing |
| 102 | if (user.avatarUrl === undefined) { |
| 103 | await ctx.db.patch(user._id, { |
| 104 | avatarUrl: generateDefaultAvatar(user.name), |
| 105 | }); |
| 106 | processed++; |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // Schedule next batch if needed |
| 111 | if (!result.isDone) { |
| 112 | await ctx.scheduler.runAfter(0, internal.migrations.backfillAvatarUrl, { |
| 113 | cursor: result.continueCursor, |
| 114 | }); |
| 115 | } |
| 116 | |
| 117 | return { |
| 118 | processed, |
| 119 | hasMore: !result.isDone, |
| 120 | }; |
| 121 | }, |
| 122 | }); |
| 123 | |
| 124 | function generateDefaultAvatar(name: string): string { |
| 125 | return `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(name)}`; |
| 126 | } |
| 127 | ``` |
| 128 | |
| 129 | ```typescript |
| 130 | // Step 4: After backfill completes, make field required |
| 131 | // convex/schema.ts |
| 132 | export default defineSchema({ |
| 133 | users: defineTable({ |
| 134 | name: v.string(), |
| 135 | email: v.string(), |
| 136 | avatarUrl: v.string(), // Now required |
| 137 | }), |
| 138 | }); |
| 139 | ``` |
| 140 | |
| 141 | ### Removing Fields |
| 142 | |
| 143 | Remove field usage before removing from schema: |
| 144 | |
| 145 | ```typescript |
| 146 | // Step 1: Stop using the field in queries and mutations |
| 147 | // Mark as deprecated in code comments |
| 148 | |
| 149 | // Step 2: Remove field from schema (make optional first if needed) |
| 150 | // convex/schema.ts |
| 151 | export default defineSchema({ |
| 152 | posts: defineTable({ |
| 153 | title: v.string(), |
| 154 | content: v.string(), |
| 155 | authorId: v.id("users"), |
| 156 | // legacyField: v.optional(v.string()), // Remove this line |
| 157 | }), |
| 158 | }); |
| 159 | |
| 160 | // Step 3: Optionally clean up existing data |
| 161 | // convex/migrations.ts |
| 162 | export const removeDeprecatedField = internalMutation({ |
| 163 | args: { |
| 164 | cursor: v.optional(v.string()), |
| 165 | }, |
| 166 | returns: v.null(), |
| 167 | handler: async (ctx, args) => { |
| 168 | const result = await ctx.db |
| 169 | .query("posts") |
| 170 | .paginate({ numItems: 100, cursor: args.cursor ?? null }); |
| 171 | |
| 172 | for (const post of result.page) { |
| 173 | // Use replace to remove the field entirely |
| 174 | const { legacyField, ...rest } = post as typeof post & { legacyField?: string }; |
| 175 | if (legacyField !== undefined) { |
| 176 | await ctx.db.replace(post._id, rest); |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | if (!result.isDone) { |