convex-migrations
Convex Migrations — Online Data Migration Component
Batch-process existing documents to match new schema requirements — no downtime, resumable, state-tracked.
When to Use This
Use @convex-dev/migrations whenever you need to transform existing data in a Convex database — adding fields, renaming fields, converting types, backfilling computed values, or any bulk document update. Migrations run asynchronously in batches so the app stays available throughout.
Installation & Setup
npm install @convex-dev/migrations
Register the component
// convex/convex.config.ts
import { defineApp } from "convex/server";
import migrations from "@convex-dev/migrations/convex.config.js";
const app = defineApp();
app.use(migrations);
export default app;
Initialize the client
// convex/migrations.ts
import { Migrations } from "@convex-dev/migrations";
import { components } from "./_generated/api.js";
import { DataModel } from "./_generated/dataModel.js";
export const migrations = new Migrations<DataModel>(components.migrations);
export const run = migrations.runner();
The DataModel generic gives you type safety on table names and document shapes inside migrateOne.
Schema Evolution Workflow
This is the safe, standard sequence for changing a schema in a live Convex app:
- Add new field as optional in schema (
v.optional(...)) - Update app code to handle both old and new data shapes
- Deploy schema + code changes
- Define & run the migration to populate/transform the new field
- Make field required in schema once migration completes
- Remove backward-compatibility code
Following this order prevents schema validation errors during the transition window.
Defining Migrations
Basic — full control with ctx
export const setDefaultValue = migrations.define({
table: "users",
migrateOne: async (ctx, user) => {
if (user.optionalField === undefined) {
await ctx.db.patch(user._id, { optionalField: "default" });
}
},
});
Shorthand — return a patch object
When your migration simply patches the document, return the patch directly instead of calling ctx.db.patch():
export const clearField = migrations.define({
table: "myTable",
migrateOne: () => ({ optionalField: undefined }),
});
// Equivalent to: ctx.db.patch(doc._id, { optionalField: undefined })
Subset migration with customRange
Use customRange to migrate only documents matching an index condition — much faster than scanning the whole table:
export const fixEmptyRequired = migrations.define({
table: "myTable",
customRange: (query) =>
query.withIndex("by_requiredField", (q) => q.eq("requiredField", "")),
migrateOne: async (_ctx, doc) => {
return { requiredField: "<unknown>" };
},
});
Multi-table — read/write across tables
migrateOne has full ctx access, so you can query and write to other tables:
export const backfillUserStats = migrations.define({
table: "users",
migrateOne: async (ctx, user) => {
const postCount = await ctx.db
.query("posts")
.withIndex("by_author", (q) => q.eq("authorId", user._id))
.collect()
.then((posts) => posts.length);
return { postCount };
},
});
Running Migrations
Single migration runner
export const runSetDefault = migrations.runner(internal.migrations.setDefaultValue);
npx convex run migrations:runSetDefault
npx convex run migrations:runSetDefault --prod # production
Generic runner (specify by name)
export const run = migrations.runner();
npx convex run migrations:run '{"fn": "migrations:setDefaultValue"}'
Serial runner (ordered dependencies)
export const runAll = migrations.runner([
internal.migrations.setDefaultValue,
internal.migrations.fixEmptyRequired,
internal.migrations.backfillUserStats,
]);
npx convex run migrations:runAll
Programmatic execution
// Single
await migrations.runOne(ctx, internal.migrations.setDefaultValue);
// Serial
await migrations.runSerially(ctx, [
internal.migrations.setDefaultValue,
internal.migrations.fixEmptyRequired,
]);
Runner behavior
| Feature | Detail |
|---|---|
| Duplicate prevention | Refuses to start if already running |
| Resume from failure | Continues from last successful batch cursor |
| Explicit cursor | Pass cursor: null to restart from beginning |
| Dry run | Pass dryRun: true to validate without committing |
Advanced Configuration
Batch size
Default is 100. Reduce for large documents or high OCC contention:
// Per-migration
export const clearField = migrations.define({
table: "myTable",
batchSize: 10,
migrateOne: () => ({ optionalField: undefined }),
});
// Per-run override
await migrations.runOne(ctx, internal.migrations.clearField, { batchSize: 1 });
Parallelize batches
Run migrateOne calls concurrently within each batch. Only use when callbacks don't read-then-write overlapping data:
export const clearField = migrations.define({
table: "myTable",
parallelize: true,
migrateOne: () => ({ optionalField: undefined }),
});
Custom internalMutation
Swap the internal mutation for one with custom DB behavior (validation, triggers via convex-helpers):
import { internalMutation } from "./functions"; // your custom builder
import { Migrations } from "@convex-dev/migrations";
import { components } from "./_generated/api";
export const migrations = new Migrations(components.migrations, {
internalMutation,
});
Shorthand prefix
Avoid typing full paths when using the generic runner:
export const migrations = new Migrations(components.migrations, {
migrationsLocationPrefix: "migrations:",
});
npx convex run migrations:run '{"fn": "myNewMutation"}'
Synchronous execution (tests & actions)
runToCompletion blocks until the migration finishes — use in tests or actions, never in mutations:
import { runToCompletion } from "@convex-dev/migrations";
export const myAction = internalAction({
args: {},
handler: async (ctx) => {
await runToCompletion(
ctx,
components.migrations,
internal.example.setDefaultValue
);
},
});
Monitoring & Control
Check status
npx convex run --component migrations lib:getStatus --watch
const status = await migrations.getStatus(ctx, {
migrations: [internal.migrations.setDefaultValue],
});
Cancel a migration
npx convex run --component migrations lib:cancel '{"name": "migrations:myMigration"}'
await migrations.cancel(ctx, internal.migrations.myMigration);
Restart from beginning
npx convex run migrations:runIt '{"cursor": null}'
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| OCC conflicts | High write contention | Reduce batchSize |
| Transaction too large | Large documents in batch | Reduce batchSize |
| Migration stuck | Worker crashed | Check logs, cancel and retry |
| Schema validation fails | Migration output doesn't match schema | Fix migrateOne, restart with cursor: null |
| Partial progress | Error mid-migration | Resume (automatic) or restart from cursor |
Best Practices
- Idempotent migrations — always check if the change is already applied before patching. This makes migrations safe to re-run.
- Test with dryRun — validate migrations before committing changes, especially on production.
- Reduce batch size proactively for tables with large documents or high write traffic.
- Use
customRangewith an index when only a subset of documents need migration — avoids scanning the entire table. - Off-peak execution — run large migrations during low-traffic periods.
Code Organization
convex/
├── migrations.ts # Migrations client + runners
├── migrations/
│ ├── userMigrations.ts # User-related migrations
│ └── postMigrations.ts # Post-related migrations
└── convex.config.ts # Component registration
Reference Files
- Common patterns & testing: Adding required fields, renaming fields, converting types, denormalization, unit testing with convex-test → See references/patterns-and-testing.md