| 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
bunx 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) {
ctx..(user., {
: (user.),
});
processed++;
}
}
(!result.) {
ctx..(, internal.., {
: result.,
});
}
{
processed,
: !result.,
};
},
});
(): {
;
}
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 post & { ?: };
(legacyField !== ) {
ctx..(post., rest);
}
}
(!result.) {
ctx..(, internal.., {
: result.,
});
}
;
},
});
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,
};
},
});
backfillDisplayName = ({
: { : v.(v.()) },
: v.(),
: (ctx, args) => {
result = ctx.
.()
.({ : , : args. ?? });
( user result.) {
(user. === ) {
ctx..(user., {
: user.,
});
}
}
(!result.) {
ctx..(, internal.., {
: result.,
});
}
;
},
});
({
: ({
: v.(),
}),
});
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")
.(,
q.(, )
)
.()
.();
posts
.( p. !== )
.( ({
: p.,
: p.,
: p.!,
}));
},
});
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,
: ,
};
( task result.) {
(task. === ) {
ctx..(task., {
: priorityMap[task.] ?? ,
});
}
}
(!result.) {
ctx..(, internal.., {
: result.,
});
}
;
},
});
getTask = ({
: { : v.() },
: v.({
: v.(),
: v.(),
: v.(),
}),
: (ctx, args) => {
task = ctx..(args.);
(!task) ();
: <, > = {
: ,
: ,
: ,
};
{
: task.,
: task.,
: task. ?? priorityMap[task.] ?? ,
};
},
});
({
: ({
: v.(),
: v.(),
}),
});
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) => {
existing = ctx.
.()
.(, q.(, args.))
.();
(existing) {
(existing. === ) {
();
}
(existing. === ) {
();
}
ctx..(existing., {
: ,
: .(),
: ,
: ,
});
existing.;
}
ctx..(, {
: args.,
: .(),
: ,
: ,
});
},
});
updateMigrationProgress = ({
: {
: v.(),
: v.(),
},
: v.(),
: (ctx, args) => {
migration = ctx..(args.);
(!migration) ;
ctx..(args., {
: migration. + args.,
});
;
},
});
completeMigration = ({
: { : v.() },
: v.(),
: (ctx, args) => {
ctx..(args., {
: ,
: .(),
});
;
},
});
failMigration = ({
: {
: v.(),
: v.(),
},
: v.(),
: (ctx, args) => {
ctx..(args., {
: ,
: args.,
});
;
},
});
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 ;
}
migrationId = ctx.(internal.., {
: ,
});
}
{
result = ctx.
.()
.({ : , : args. ?? });
processed = ;
( user result.) {
(user. === ) {
ctx..(user., {
: user.,
: user.,
});
processed++;
}
}
ctx.(internal.., {
migrationId,
processed,
});
(!result.) {
ctx..(, internal..., {
migrationId,
: result.,
});
} {
ctx.(internal.., {
migrationId,
});
.();
}
} (error) {
ctx.(internal.., {
migrationId,
: (error),
});
error;
}
;
},
});
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.(v.()),
: v.(v.()),
: v.(v.({
: v.(),
: v.(),
})),
})
.(, [])
.(, []),
: ({
: v.(),
: v.(),
: v.(),
: v.(
v.(),
v.(),
v.()
),
: v.(v.()),
: v.(),
: v.(),
})
.(, [])
.(, [])
.(, [, ])
.(, []),
});
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