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
- Remove all frontend code that reads or writes the field.
- Keep the field in the Zod schema as
optional()during a transition period. - Deploy the frontend changes.
- Remove the field from the Zod schema.
- 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.
- Add the new field name as
optional():
blog: object({
title: string().min(1),
body: string().min(3),
content: string().optional()
})- 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 })
}
})- Update frontend to use
contentinstead ofbody. - Remove
bodyfrom the schema, makecontentrequired. - 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
- Stop creating new documents with the old value.
- 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' })
}
})- Remove the value from the enum.
- 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 type | Safe to deploy directly? |
|---|---|
| Add optional field | Yes |
| Add enum value | Yes |
| Remove unused field | Yes |
| Make optional field required | Only after backfill |
| Remove enum value | Only after migration |
| Rename field | No — use add/migrate/remove |
| Change field type | No — 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, theform-field-existsESLint rule catches it - Renamed field: update the
nameprop —form-field-existscatches typos - Type change: update the component —
form-field-kindwarns 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.