Schema migration strategies for evolving applications including adding new fields, backfilling data, removing deprecated fields, index migrations, and zero-downtime migration patterns
Evolve your Convex database schema safely with patterns for adding fields, backfilling data, removing deprecated fields, and maintaining zero-downtime deployments.
Before implementing, do not assume; fetch the latest documentation:
Convex handles schema evolution differently than traditional databases:
npx convex devStart 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
}),
})
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 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(),
}),
})
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!,
}))
},
})
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(),
}),
})
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
},
})
// 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']),
})
npx convex deploy unless explicitly instructed