convex-migrations
Scannednpx machina-cli add skill waynesutton/convexskills/convex-migrations --openclawConvex 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
- 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
- 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
Source
git clone https://github.com/waynesutton/convexskills/blob/main/skills/convex-migrations/SKILL.mdView on GitHub Overview
Evolve your Convex schema safely with patterns for adding fields, backfilling data, removing deprecated fields, and managing index migrations. Changes deploy instantly, and existing data is not auto-transformed, so optional fields and backfill mutations are used to migrate data safely. This guide mirrors the step-by-step approach shown in the SKILL.md examples.
How This Skill Works
Convex applies schema changes instantly with no separate migration scripts. Start by adding optional fields, update application code to handle missing data, and use a scheduled internalMutation to backfill existing documents in batches. When backfill completes, you can tighten the schema, e.g., by making the field required.
When to Use It
- When introducing a new field to a live table and you want to avoid breaking existing records
- When backfilling existing documents to populate a new field
- When removing or phasing out a deprecated field without downtime
- When performing index migrations or schema-driven performance tweaks
- When you need zero-downtime deployments during schema evolution
Quick Start
- Step 1: Add optional field to schema
- Step 2: Update code to handle optional field
- Step 3: Backfill existing records in batches and schedule continuation
Best Practices
- Add new fields as optional first to avoid breaking existing data
- Backfill in small batches (e.g., 100 records) to minimize load
- Guard queries and code paths to handle missing or null fields
- Backfill with a scheduled internalMutation to avoid long-running locks
- Test migrations in a dev environment with convex dev before prod
Example Use Cases
- Step 1: Add an optional avatarUrl field to the users schema
- Step 2: Update getUser to handle the optional avatarUrl field (neutral handling)
- Step 3: Run backfillAvatarUrl to populate avatarUrl in batches via a scheduled mutation
- Step 4: Change avatarUrl to required in the users schema after backfill completes
- Then remove usage of the deprecated field from queries before deleting from the schema
Frequently Asked Questions
Related Skills
mysql
chaterm/terminal-skills
MySQL 数据库管理与运维
SEO Audit
openclaw/skills
Full website SEO audit with parallel subagent delegation. Crawls up to 500 pages, detects business type, delegates to 6 specialists, generates health score.
SEO Page
openclaw/skills
Deep single-page SEO analysis covering on-page elements, content quality, technical meta tags, schema, images, and performance.
Database Design Expert
martinholovsky/claude-skills-generator
Expert in database schema design with focus on normalization, indexing strategies, FTS optimization, and performance-oriented architecture for desktop applications
convex-component-authoring
waynesutton/convexskills
How to create, structure, and publish self-contained Convex components with proper isolation, exports, and dependency management
convex-cron-jobs
waynesutton/convexskills
Scheduled function patterns for background tasks including interval scheduling, cron expressions, job monitoring, retry strategies, and best practices for long-running tasks