noboil

Schema Evolution

Safe field changes, migrations, and deployment strategies for production schema updates.

How schema changes work

Convex is schemaless at the storage layer — documents can have any shape. Your Zod schema in convex/s.ts is a TypeScript-level contract, not a database constraint. This means:

  • Adding an optional field: deploy immediately, no migration needed
  • Removing a field: old documents keep the field in the database but it's ignored
  • Renaming a field: requires a three-step add/migrate/remove process

Convex deploys atomically — schema and functions update together.

Adding a field

Add the field as optional in your Zod schema:

const owned = makeOwned({
  blog: object({
    title: string().min(1),
    content: string().min(3),
    category: zenum(['tech', 'life', 'tutorial']),
    published: boolean(),
    coverImage: file().nullable().optional(),
    subtitle: string().optional()
  })
})

Deploy. Existing documents have no subtitle field, which satisfies optional(). New documents can include it. Forms using <Text name='subtitle' /> will render an empty field for old documents.

If the field should have a default value for existing documents, backfill after deploying:

const backfillSubtitle = m({
  args: {},
  handler: async c => {
    const docs = await c.db.query('blog').collect()
    for (const doc of docs)
      if (doc.subtitle === undefined) await c.patch(doc._id, { subtitle: '' })
  }
})

Once all documents have the field, you can remove optional() to make it required.

Removing a field

  1. Remove all frontend code that reads or writes the field.
  2. Keep the field in the Zod schema as optional() during a transition period.
  3. Deploy the frontend changes.
  4. Remove the field from the Zod schema.
  5. Deploy the schema change.

Convex is schemaless at the storage layer — old documents keep the field in the database but it's ignored. No migration needed for removal.

If you want to clean up old data:

const cleanupField = m({
  args: {},
  handler: async c => {
    const docs = await c.db.query('blog').collect()
    for (const doc of docs)
      if ('oldField' in doc) await c.patch(doc._id, { oldField: undefined })
  }
})

Adding a table

Add the table to your schema and deploy:

const owned = makeOwned({
  blog: object({ ... }),
  tag: object({ name: string().min(1) })
})

export default defineSchema({
  blog: ownedTable(owned.blog),
  tag: ownedTable(owned.tag)
})

The new table starts empty. Existing data is unaffected.

Removing a table

Remove the table from your schema and deploy. Convex keeps the data in the database but stops serving it through the schema. Clean up the data manually if needed.

Renaming a field

Neither database supports field renames at the storage layer. The safe approach is add/migrate/remove.

  1. Add the new field name as optional():
blog: object({
  title: string().min(1),
  body: string().min(3),
  content: string().optional()
})
  1. Deploy and run a migration to copy values:
const migrateField = m({
  args: {},
  handler: async c => {
    const docs = await c.db.query('blog').collect()
    for (const doc of docs)
      if (doc.content === undefined && doc.body !== undefined)
        await c.patch(doc._id, { content: doc.body })
  }
})
  1. Update frontend to use content instead of body.
  2. Remove body from the schema, make content required.
  3. Deploy.

Changing a field's type

Similar to renaming — you can't change a field's type in-place. Use add/migrate/remove.

Option A: New field (safe)

blog: object({
  priority: string(),
  priorityLevel: number().optional()
})

Migrate, then remove the old field.

Option B: Widen the type temporarily

If the old and new types can coexist:

blog: object({
  priority: union([string(), number()])
})

Migrate all documents to the new type, then narrow:

blog: object({
  priority: number()
})

Adding an enum value

Add the value to the Zod enum:

category: zenum(['tech', 'life', 'tutorial', 'news'])

Deploy. No migration needed — existing documents keep their old values.

Removing an enum value

  1. Stop creating new documents with the old value.
  2. Migrate existing documents to a new value:
const migrateCategory = m({
  args: {},
  handler: async c => {
    const docs = await c.db.query('blog').collect()
    for (const doc of docs)
      if (doc.category === 'tutorial')
        await c.patch(doc._id, { category: 'tech' })
  }
})
  1. Remove the value from the enum.
  2. Deploy.

Index changes

If your field change affects a Convex index, update the index definition alongside the schema change. Convex rebuilds indexes automatically on deploy.

export default defineSchema({
  blog: ownedTable(owned.blog)
    .index('by_category', ['category'])
    .index('by_status', ['status'])
})

Reducer changes (SpacetimeDB)

Convex functions are deployed atomically with the schema. Adding, removing, or changing function signatures is safe as long as you follow the same backward-compatible patterns.

Deployment strategy

Change typeSafe to deploy directly?
Add optional fieldYes
Add enum valueYes
Remove unused fieldYes
Make optional field requiredOnly after backfill
Remove enum valueOnly after migration
Rename fieldNo — use add/migrate/remove
Change field typeNo — use add/migrate/remove

Zero-downtime pattern

For breaking changes, use a two-phase deployment:

Phase 1: Deploy backward-compatible schema

blog: object({
  oldField: string().optional(),
  newField: string().optional()
})

Phase 2: Run migration, then deploy final schema

blog: object({
  newField: string()
})

Form compatibility

When you add or remove a field, form components adapt automatically:

  • New optional field: form renders with empty/default value
  • Removed field: remove the <Text name='removedField' /> from JSX — if you forget, the form-field-exists ESLint rule catches it
  • Renamed field: update the name prop — form-field-exists catches typos
  • Type change: update the component — form-field-kind warns if you use <Text> for a boolean field

Checking the current schema

The Convex dashboard shows your current schema and all deployed functions. Run bunx convex dev --once to see any schema validation errors before deploying.

Resetting a local module (SpacetimeDB)

To reset local Convex data during development, use the Convex dashboard or run bunx convex dev --once after clearing the local database.

On this page