
Convex Migrations
- 2.3k installs
- 401 repo stars
- Updated February 6, 2026
- waynesutton/convexskills
convex-migrations provides zero-downtime Convex schema evolution patterns with optional fields, backfills, and tracked runners.
About
The convex-migrations skill teaches safe Convex schema evolution without traditional migration files. Schema changes deploy instantly via npx convex dev while existing documents are not auto-transformed. Add new fields as optional first, update handlers to tolerate undefined, then backfill with paginated internalMutation batches scheduled through ctx.scheduler. After backfill completes, promote fields to required in schema.ts. Removing fields means stopping code usage, making optional if needed, then cleaning data with db.replace to strip deprecated keys. Renames copy to a new optional field with read fallbacks, backfill, then drop the old field. Index additions deploy before queries call withIndex. Type changes follow add-new-field, convert, backfill, remove-old pattern with mapping tables. A migration runner table tracks name, status, processed counts, and completion to avoid duplicate runs. Best practices warn against required fields before backfill, large batch timeouts, and removing fields before code updates. Fetch latest docs from docs.convex.dev before implementing rather than assuming Postgres-style migration commands.
- Convex has no migration files; schema deploys instantly, data is not auto-transformed.
- Add optional fields first, backfill in paginated batches, then make required.
- Renames and type changes use parallel fields with fallback reads during transition.
- Migration runner table tracks running, completed, and failed jobs by name.
- Index additions must deploy before queries reference new withIndex definitions.
Convex Migrations by the numbers
- 2,293 all-time installs (skills.sh)
- +33 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #48 of 911 Databases skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 3, 2026 (Skillselion catalog sync)
convex-migrations capabilities & compatibility
- Capabilities
- optional to required field rollout · paginated scheduler driven backfills · field rename and type conversion patterns · index before query migration sequencing · migration status tracking table and runner
- Use cases
- database · api development · devops
npx skills add https://github.com/waynesutton/convexskills --skill convex-migrationsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.3k |
|---|---|
| repo stars | ★ 401 |
| Security audit | 3 / 3 scanners passed |
| Last updated | February 6, 2026 |
| Repository | waynesutton/convexskills ↗ |
How do I change Convex schemas and backfill existing documents without breaking production data?
Evolve Convex database schemas safely with optional fields, batched backfills, index migrations, field renames, type changes, and tracked migration runners.
Who is it for?
Convex apps adding fields, indexes, renames, type changes, or deprecating columns safely.
Skip if: Skip for initial greenfield schemas with no existing documents requiring backfill.
When should I use this skill?
User asks about Convex schema migration, backfill mutation, optional fields, or index changes.
What you get
Deployed schema with batched backfill, compatible handlers, and optional migration tracking to completion.
- Updated Convex schema
- Migration functions
- Backfilled document data
Files
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:
- Primary: https://docs.convex.dev/database/schemas
- 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 deployunless 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
1. Making new fields required immediately - Breaks existing documents 2. Not handling undefined values - Causes runtime errors 3. Large batch sizes - Causes function timeouts 4. Forgetting to update indexes - Queries fail or perform poorly 5. Running migrations without tracking - May run multiple times 6. Removing fields before code update - Breaks existing functionality 7. Not testing on development - Production data issues
References
- Convex Documentation: https://docs.convex.dev/
- Convex LLMs.txt: https://docs.convex.dev/llms.txt
- Schemas: https://docs.convex.dev/database/schemas
- Database Overview: https://docs.convex.dev/database
- Migration Patterns: https://stack.convex.dev/migrate-data-postgres-to-convex
interface:
icon_small: "./assets/small-logo.svg"
icon_large: "./assets/large-logo.png"
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3_23)">
<g clip-path="url(#clip1_3_23)">
<path d="M10.0643 12.5735C12.3769 12.3166 14.5572 11.0843 15.7577 9.02756C15.1892 14.1148 9.62646 17.3302 5.08583 15.356C4.66743 15.1746 4.30728 14.8728 4.06013 14.4848C3.03973 12.8825 2.7043 10.8437 3.18626 8.99344C4.56327 11.37 7.3632 12.8267 10.0643 12.5735Z" fill="#F3B01C"/>
<path d="M3.1018 7.50072C2.16436 9.66714 2.12376 12.2034 3.27303 14.2907C-0.771507 11.2479 -0.72737 4.7362 3.2236 1.72378C3.58904 1.44535 4.02333 1.2801 4.47881 1.25494C6.3519 1.15614 8.25501 1.88006 9.58963 3.22909C6.87799 3.25604 4.23695 4.99308 3.1018 7.50072Z" fill="#8D2676"/>
<path d="M10.8974 3.89562C9.52924 1.98794 7.38779 0.68921 5.04156 0.649695C9.57686 -1.40888 15.1555 1.92867 15.7629 6.86314C15.8194 7.32119 15.7452 7.78824 15.5421 8.20138C14.6948 9.92223 13.1236 11.2569 11.2876 11.7508C12.6328 9.25579 12.4668 6.20748 10.8974 3.89562Z" fill="#EE342F"/>
</g>
</g>
<defs>
<clipPath id="clip0_3_23">
<rect width="16" height="16" fill="white"/>
</clipPath>
<clipPath id="clip1_3_23">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>
Related skills
How it compares
Pick convex-migrations for Convex-specific document schema rollouts rather than generic SQL migration skills that do not model Convex functions and indexes.
FAQ
Are there Convex migration CLI files?
No explicit migration files; schema changes deploy with convex dev and data transforms run via internal mutations.
When can a new field become required?
Only after backfill completes and all documents have the field; starting required breaks existing rows.
How avoid re-running backfills?
Use a migrations table with by_name index and hasMigrationRun checks before starting jobs.
Is Convex Migrations safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.