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:
-
Schema Overview: https://docs.convex.dev/database
-
Migration Patterns: https://stack.convex.dev/migrate-data-postgres-to-convex
-
For broader context: https://docs.convex.dev/llms.txt
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:
// 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
-
Convex Documentation: https://docs.convex.dev/
-
Convex LLMs.txt: https://docs.convex.dev/llms.txt
-
Database Overview: https://docs.convex.dev/database
-
Migration Patterns: https://stack.convex.dev/migrate-data-postgres-to-convex