| name | convex-migrations |
| displayName | Convex Migrations |
| description | Schema migration strategies for evolving applications including adding new fields, backfilling data, removing deprecated fields, index migrations, and zero-downtime migration patterns |
| version | 1.0.0 |
| author | Convex |
| tags | ["convex","migrations","schema","database","data-modeling"] |
Convex Migrations
Evolve your Convex database schema safely with patterns for adding fields, backfilling data, removing deprecated fields, and maintaining zero-downtime deployments.
Documentation Sources
Before implementing, do not assume; fetch the latest documentation:
Instructions
Migration Philosophy
Convex handles schema evolution differently than traditional databases:
- 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:
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
users: defineTable({
name: v.string(),
email: v.string(),
avatarUrl: v.optional(v.string()),
}),
});
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,
avatarUrl: user.avatarUrl ?? null,
};
},
});
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) {
if (user.avatarUrl === undefined) {
await ctx.db.patch(user._id, {
avatarUrl: generateDefaultAvatar(user.name),
});
processed++;
}
}
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)}`;
}
export default defineSchema({
users: defineTable({
name: v.string(),
email: v.string(),
avatarUrl: v.string(),
}),
});
Removing Fields
Remove field usage before removing from schema:
export default defineSchema({
posts: defineTable({
title: v.string(),
content: v.string(),
authorId: v.id('users'),
}),
});
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) {
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:
export default defineSchema({
users: defineTable({
userName: v.string(),
displayName: v.optional(v.string()),
}),
});
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,
displayName: user.displayName ?? user.userName,
};
},
});
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;
},
});
export default defineSchema({
users: defineTable({
displayName: v.string(),
}),
});
Adding Indexes
Add indexes before using them in queries:
export default defineSchema({
posts: defineTable({
title: v.string(),
authorId: v.id('users'),
publishedAt: v.optional(v.number()),
status: v.string(),
})
.index('by_author', ['authorId'])
.index('by_status_and_published', ['status', 'publishedAt']),
});
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:
export default defineSchema({
tasks: defineTable({
title: v.string(),
priority: v.string(),
priorityLevel: v.optional(v.number()),
}),
});
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;
},
});
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,
};
},
});
export default defineSchema({
tasks: defineTable({
title: v.string(),
priorityLevel: v.number(),
}),
});
Migration Runner Pattern
Create a reusable migration system:
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']),
});
import { internalMutation, internalQuery } from './_generated/server';
import { internal } from './_generated/api';
import { v } from 'convex/values';
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';
},
});
export const startMigration = internalMutation({
args: { name: v.string() },
returns: v.id('migrations'),
handler: async (ctx, args) => {
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`);
}
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,
});
},
});
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;
},
});
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;
},
});
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;
},
});
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) => {
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++;
}
}
await ctx.runMutation(internal.migrations.updateMigrationProgress, {
migrationId,
processed,
});
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
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']),
users: defineTable({
name: v.string(),
email: v.string(),
createdAt: v.optional(v.number()),
updatedAt: v.optional(v.number()),
avatarUrl: v.optional(v.string()),
settings: v.optional(
v.object({
theme: v.string(),
notifications: v.boolean(),
}),
),
})
.index('by_email', ['email'])
.index('by_createdAt', ['createdAt']),
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