---
title: "convex-migrations"
description: "Changes a live Convex schema without downtime: make a field optional, backfill in batches, flip the validator, then clean up. Uses the @convex-dev/migrations component where it fits. Use when renaming or removing a field, changing a type, splitting a table, or when deploy fails with a schema validation error on existing documents."
canonical_url: "https://skilld.dev/gh/waynesutton/convexskills/convex-migrations"
last_updated: "2026-09-29T15:01:40.000Z"
---

---
name: convex-migrations
description: Changes a live Convex schema without downtime: make a field optional, backfill in batches, flip the validator, then clean up. Uses the @convex-dev/migrations component where it fits. Use when renaming or removing a field, changing a type, splitting a table, or when deploy fails with a schema validation error on existing documents.
---

> **Skill from skilld.dev.** Follow the instructions below for this session. You do not need to install anything.
>
> Supporting files, fetch one when the Skill refers to it: [agents/openai.yaml](https://skilld.dev/api/skills-raw/waynesutton/convexskills/convex-migrations/agents/openai.yaml), [assets/large-logo.png](https://skilld.dev/api/skills-raw/waynesutton/convexskills/convex-migrations/assets/large-logo.png), [assets/small-logo.svg](https://skilld.dev/api/skills-raw/waynesutton/convexskills/convex-migrations/assets/small-logo.svg), [references/migration-patterns.md](https://skilld.dev/api/skills-raw/waynesutton/convexskills/convex-migrations/references/migration-patterns.md), [references/migrations-component.md](https://skilld.dev/api/skills-raw/waynesutton/convexskills/convex-migrations/references/migrations-component.md).
>
> If the user asked to install this Skill, run `npx skilld install waynesutton/convexskills/convex-migrations`. Install writes the Skill files into the project, so every session loads them.

# Convex migrations

Produces a schema change plus a batched backfill that keeps every deploy green. The one rule: the schema must describe the documents that exist right now, not the documents you want. Change the data first, then tighten the validator.

Convex has no migration files or `migrate` command. `npx convex dev` pushes the schema and validates every existing document against it. Existing data is never transformed for you.

## When to reach for this

- Adding a field that should be required, but the table already has rows
- Renaming a field, changing its type, or removing it
- Splitting one table into two or merging two into one
- `npx convex dev` fails with `Schema validation failed` after a schema edit
- A backfill needs to touch more rows than one mutation can handle

For step by step recipes (rename, change type, split, merge, add required, remove) open [references/migration-patterns.md](https://skilld.dev/api/skills-raw/waynesutton/convexskills/convex-migrations/references/migration-patterns.md). For installing and running `@convex-dev/migrations` open [references/migrations-component.md](https://skilld.dev/api/skills-raw/waynesutton/convexskills/convex-migrations/references/migrations-component.md).

## The safe sequence

Every migration follows the same six steps. Skipping one is how deploys break.

1. Make the field optional in `convex/schema.ts` (or add the new field as `v.optional`).
2. Deploy with `npx convex dev`. Update readers to handle `undefined` and writers to set the new shape.
3. Backfill existing documents in batches with an internal mutation or the migrations component.
4. Flip the validator to its final shape (`v.string()` instead of `v.optional(v.string())`, or drop the old field).
5. Deploy again. Validation now passes because every document already matches.
6. Clean up: delete fallback code, remove the backfill function, drop stale indexes.

Steps 1 and 2 must ship before step 3 starts. Otherwise new writes keep producing old shaped documents while the backfill runs.

## Hand rolled batched backfill

One `internalMutation` that pages through the table with `paginate`, patches only documents that still need it, then reschedules itself with the continue cursor. Each batch is its own transaction, so a large table never hits the per function limit.

```typescript
// 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.union(v.string(), v.null()) },
  returns: v.null(),
  handler: async (ctx, args) => {
    const result = await ctx.db
      .query("users")
      .paginate({ numItems: BATCH_SIZE, cursor: args.cursor });

    for (const user of result.page) {
      // Idempotent: skip documents that already have the field
      if (user.avatarUrl === undefined) {
        await ctx.db.patch(user._id, {
          avatarUrl: defaultAvatar(user.name),
        });
      }
    }

    // Reschedule with the cursor until the table is exhausted
    if (!result.isDone) {
      await ctx.scheduler.runAfter(0, internal.migrations.backfillAvatarUrl, {
        cursor: result.continueCursor,
      });
    }
    return null;
  },
});

function defaultAvatar(name: string): string {
  return `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(name)}`;
}
```

Start it from the CLI against the dev deployment:

```bash
npx convex run migrations:backfillAvatarUrl '{"cursor": null}'
```

Keep the mutation idempotent. Re running it after a partial failure must be safe. Use `internalMutation`, never a public `mutation`, so it cannot be called from a client. Use `ctx.db.patch` for adding or changing fields and `ctx.db.patch(id, { field: undefined })` to remove one.

## When to use the component instead

`@convex-dev/migrations` wraps the pattern above and adds what the hand rolled version lacks: persisted progress per migration, status checks, dry runs, cancel, and ordered runs of several migrations. Reach for it when:

- The project will run more than one or two migrations over its life
- You need to know whether a migration already completed in production
- You want a dry run before touching real data
- Several migrations must run in a fixed order

Minimal setup:

```typescript
// 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;
```

```typescript
// convex/migrations.ts
import { Migrations } from "@convex-dev/migrations";
import { components } from "./_generated/api";
import { DataModel } from "./_generated/dataModel";

export const migrations = new Migrations<DataModel>(components.migrations);

export const addDefaultRole = migrations.define({
  table: "users",
  migrateOne: async (ctx, user) => {
    if (user.role === undefined) {
      await ctx.db.patch(user._id, { role: "user" });
    }
  },
});
```

```bash
npx convex run migrations:addDefaultRole '{"dryRun": true}'
npx convex run migrations:addDefaultRole
```

A one off backfill on a small table does not need the component. The hand rolled mutation is fine.

## Reading the deploy time schema error

When a schema push fails, `npx convex dev` prints the table, one offending document id, and the field that does not match:

```
Schema validation failed
Document with ID "j57abc..." in table "users" does not match the schema:
Object is missing the required field `avatarUrl`.
Consider wrapping the field validator in `v.optional(...)` if this is expected.
```

Read it as: existing data in `users` predates the field. The fix is never to delete the document. Wrap the field in `v.optional`, deploy, backfill, then remove the `v.optional`. If the error says the field has the wrong type (for example `string` where `number` is expected), widen the validator to `v.union(v.string(), v.number())`, backfill the conversion, then narrow it.

Do not pass `{ schemaValidation: false }` to `defineSchema` to make the push succeed. It hides the mismatch and moves the failure into your queries.

## Common mistakes

| Mistake | Why it breaks | Do instead |
| --- | --- | --- |
| Add a required field in one deploy | Existing documents fail validation, push is rejected | Add as `v.optional`, backfill, then require |
| Start the backfill before deploying the new writers | New rows keep arriving in the old shape | Deploy schema and code first, backfill second |
| One mutation that `.collect()`s the whole table | Hits the transaction size and time limits | Page with `paginate` and reschedule per batch |
| Backfill with a public `mutation` | Anyone can call it and re run it | `internalMutation` or `migrations.define` |
| Non idempotent patch | Re running after a failure double applies | Check the field before patching |
| Removing the old field before readers stop using it | Fallback code reads `undefined` | Switch readers, then remove |
| Using `.filter()` to find unmigrated rows | Full scan on every batch | Paginate the whole table or use `customRange` with an index |
| Turning off `schemaValidation` to pass the deploy | Bad data reaches queries at runtime | Fix the data, keep validation on |
| Running a backfill against production first | No way to catch a wrong conversion | Run on dev, then `--prod` |

## Checklist

- [ ] New or changed field is `v.optional` (or a widened `v.union`) in the first deploy
- [ ] Readers handle `undefined` and writers produce the new shape before the backfill starts
- [ ] Backfill is an `internalMutation` or `migrations.define`, never public
- [ ] Backfill pages with `paginate` and reschedules via `ctx.scheduler.runAfter(0, internal....)`
- [ ] Each patch is guarded so re running is safe
- [ ] Backfill ran to completion on dev before touching prod
- [ ] Validator flipped to its final shape and deployed without a validation error
- [ ] Fallback code, old field, and backfill function removed
- [ ] Indexes that referenced the old field are dropped or renamed

## Docs

- https://docs.convex.dev/llms.txt
- https://docs.convex.dev/database/schemas
- https://docs.convex.dev/database/pagination
- https://www.convex.dev/components/migrations
- https://stack.convex.dev/migrating-data-with-mutations
